feat: controller setup

This commit is contained in:
Bogdan Buduroiu 2026-09-14 20:18:57 +08:00
commit 41f63c9a3d
Signed by: bogdan
GPG key ID: F71EF75A4C09A095
19 changed files with 2128 additions and 247 deletions

View file

@ -4,7 +4,7 @@ Noctalia 5.0.1 replaces Waybar, Walker/Elephant, Mako, swaylock/hyprlock,
swayidle/hypridle, swaybg/hyprpaper, meeting guard, and `theme-switch`.
Their modules, launcher/locker/wallpaper flake inputs, Walker cache, and
Waybar-specific passwordless WireGuard polling rules are removed.
Framework, Phantom and Steam Deck use Niri. The live ISO still uses Hyprland.
Framework, Phantom and Steam Deck use Niri.
## Portable profiles
@ -26,6 +26,9 @@ account has a usable password before switching from Gaming Mode.
Jovian's “Switch to Desktop” now selects `niri`. In Noctalia's launcher, choose
“Return to Gaming Mode” to invoke Jovian's `steamosctl switch-to-game-mode`.
Controller-only use (one app per screen, the L5-held Steam Input layer that
sends Niri chords, and the Kando wheel) is described in
`home/profiles/steamdeck/CONTROLLER.md`; none of it reaches the other hosts.
Steam Deck now uses Nyx's vendored Jovian, with nixpkgs and Home Manager following
Nyx's pins independently of Framework's unstable packages. The current gaming
stack selects `7.2.4-valve1`; its Valve kernel patches remain enabled. Update
@ -215,7 +218,7 @@ Do not point generated output at an immutable Home Manager symlink.
| yazi | `noctalia` flavor for both modes, including syntax colors. |
| Claude Code | Generated in personal and work profile theme directories. Select Noctalia with `/theme`; restart once if newly added. |
| Codex | `tui.theme = "noctalia"`; start a new session to pick up syntax colors. |
| Discord | Midnight community CSS generated for Vencord/Vesktop. Requires a compatible client/mod and enabling the theme; stock Discord cannot load it. |
| Discord | Midnight community CSS written to Vesktop's theme directory. The official client cannot load it and is no longer installed. `vesktop.nix` seeds `noctalia.theme.css` into Vencord's `enabledThemes` at activation, and Vencord watches the themes directory, so switches re-inject without a restart. |
| fcitx5 | Classic UI theme selected on Framework; native D-Bus reload hook. |
| fzf | Community color options read through `FZF_DEFAULT_OPTS_FILE` on each invocation. Open a fresh login shell after activation. |
| jjui | `ui.theme = "noctalia"` reading a generated `themes/noctalia.toml`. Stylix's target is off: its static `ui.colors` is jjui's highest-priority layer and masked every other one. A running jjui recolors when the terminal reports the new polarity; otherwise reopen it. |

View file

@ -8,11 +8,6 @@
./steamdeck
];
"nixos@iso" = [
./iso/base.nix
./iso
];
"bogdan@phantom" = [
./phantom
];

View file

@ -9,6 +9,7 @@
# programs
../../programs
../../programs/vesktop.nix
../../programs/keepassxc.nix
../../programs/wayland
../../programs/wayland/noctalia-templates.nix

View file

@ -1,12 +0,0 @@
{ config, pkgs, ... }:
{
home = {
username = "nixos";
homeDirectory = "/home/nixos";
stateVersion = "25.11";
};
# Let Home Manager install and manage itself
programs.home-manager.enable = true;
}

View file

@ -1,38 +0,0 @@
{
imports = [
# Editors - vital!
../../editors/nvim
# Terminal configuration
../../terminal
../../terminal/emulators/ghostty.nix
# Wayland/Hyprland essentials
../../programs/wayland
../../programs/gtk.nix
../../programs/gpg.nix
# Desktop services
../../services/gpg.nix
../../services/media/playerctl.nix
# Tailscale auto-auth (prompts for YubiKey PIN after login)
./tailscale.nix
];
# Generic Hyprland settings for any display
wayland.windowManager.hyprland.settings = {
# Auto-detect monitors
monitor = [
", preferred, auto, 1"
];
# Generic touchpad settings
input = {
touchpad = {
natural_scroll = true;
tap-to-click = true;
};
};
};
}

View file

@ -1,76 +0,0 @@
# Tailscale auto-authentication for live ISO (user session)
# Runs after graphical login so pinentry-qt can prompt for YubiKey PIN
{ config, pkgs, inputs, ... }:
let
secretsFile = "${inputs.secrets}/secrets/iso/tailscale.yaml";
in {
# Systemd user service to authenticate Tailscale after graphical login
systemd.user.services.tailscale-autoconnect = {
Unit = {
Description = "Authenticate to Tailscale with YubiKey-decrypted auth key";
# Run after graphical session is ready (pinentry-qt needs display)
After = [ "graphical-session.target" ];
Wants = [ "graphical-session.target" ];
};
Service = {
Type = "oneshot";
RemainAfterExit = true;
# Environment for pinentry and age-plugin-yubikey
Environment = [
"PINENTRY_PROGRAM=${pkgs.pinentry-qt}/bin/pinentry-qt"
];
ExecStart = pkgs.writeShellScript "tailscale-autoconnect" ''
set -euo pipefail
echo "Tailscale auto-connect: checking status..."
# Check if already authenticated
status="$(${pkgs.tailscale}/bin/tailscale status -json 2>/dev/null | ${pkgs.jq}/bin/jq -r '.BackendState' 2>/dev/null || echo 'Unknown')"
if [ "$status" = "Running" ]; then
echo "Tailscale already running, skipping authentication"
exit 0
fi
echo "Tailscale auto-connect: decrypting auth key..."
echo "You may be prompted for your YubiKey PIN..."
# Decrypt the auth key using sops
# This will trigger pinentry-qt for the YubiKey PIV PIN
AUTH_KEY="$(${pkgs.sops}/bin/sops -d --extract '["tailscale_auth_key"]' ${secretsFile} 2>/dev/null)" || {
echo "ERROR: Failed to decrypt Tailscale auth key"
echo "Make sure your YubiKey is connected and try: tailscale up"
# Send desktop notification
${pkgs.libnotify}/bin/notify-send -u critical "Tailscale" "Failed to decrypt auth key. Run 'tailscale up' manually."
exit 1
}
if [ -z "$AUTH_KEY" ]; then
echo "ERROR: Auth key is empty"
exit 1
fi
echo "Tailscale auto-connect: authenticating..."
${pkgs.tailscale}/bin/tailscale up --authkey="$AUTH_KEY" --operator=nixos
echo "Tailscale authenticated successfully!"
${pkgs.libnotify}/bin/notify-send -u normal "Tailscale" "Connected to Tailscale network"
'';
};
Install = {
WantedBy = [ "graphical-session.target" ];
};
};
# Ensure required packages are available
home.packages = with pkgs; [
age-plugin-yubikey
sops
libnotify
];
}

View file

@ -1,79 +1,160 @@
# Steam Input desktop controls
The Steam Deck profile declares Kando 2.3.1's menu/configuration, a Niri-only
startup service, Thunar, and Steam's extest
Wayland input compatibility. Steam starts silently with Niri to supply Desktop
Layout mappings. Gaming Mode keeps its per-game layouts.
Niri has no gamepad support of its own. On the Deck every controller press
reaches the desktop through Steam Input's Desktop Layout, which emits X11 fake
input; the `extest` shim (`programs.steam.extest`) turns that into a uinput
device (`extest fake device`) that libinput and Niri read like a keyboard and
mouse. That path was verified working on 2026-09-14: the 64-bit client loads
the shim, the kernel creates the device, and native Wayland windows receive it.
The `wrong ELF class: ELFCLASS32` journal lines come from Steam's 32-bit
helpers and are harmless.
## One-time Steam Input layout setup
What was broken was the mapping. Steam loaded its stock `desktop_neptune.vdf`,
which can only produce arrows, Enter, Escape, Space, Tab and mouse clicks.
No Niri chord was reachable, and windows opened at half width on a 1280×800
panel, so the focused column sat off-screen where the pointer could not reach.
In Desktop Mode, open Steam Settings → Controller → Desktop Layout → Edit.
Start from a desktop layout with right trackpad mouse and R2 left click. Save
the result as a personal layout called **Niri + Kando Desktop**.
## Layout: one app per screen
1. Create an action-set layer named **App wheel**.
2. In the base layout, assign L2 soft pull to **Hold Action Set Layer → App wheel**.
Remove its old right-click binding and any full-pull command. Use L1 for
right click instead. Avoid toggle or repeated activation on L2.
3. Add commands to that same L2 soft pull for **Left Ctrl**, **Left Alt**, and
**F12**. They must be held for the duration of the pull, with turbo disabled.
Set F12's Fire Start Delay to 0.1 seconds so the modifiers arrive first.
Do not override L2 inside the App wheel layer: it must retain the base
layer's hold/release behaviour.
4. In App wheel, set right trackpad to Mouse, with trackball disabled. Set
right stick to Joystick Mouse. Start with moderate sensitivity and adjust
on the Deck. Keep R2 as left click. Set B to Escape.
5. Test **Ctrl+Alt+F12** on a keyboard first. Then hold L2, point towards an
entry with the pad or stick, and release L2 to select. B cancels. Opening
with a quick tap also allows ordinary point-and-click selection with R2.
The Deck profile appends a catch-all Niri window rule with
`open-maximized true`, so every tiled window opens as a maximized column and
fills the panel. `open-maximized` touches tiled columns only; a
`default-column-width` rule would also stretch floating Steam dialogs, which
is why it is not used. Navigating the scrolling layout collapses to
"previous / next column" and "previous / next workspace". Noctalia's Settings
window is tiled rather than floated on the Deck: the shared 1080×920 float is
taller than the panel. Everything else in `home/programs/wayland/niri.kdl` is
untouched; the Framework and Phantom configurations do not see these rules.
The opening shortcut is reserved in the Deck's Niri configuration. Kando's
Turbo Mode uses the held Ctrl/Alt state to select on release. The exact Steam
Input timing and modifier handoff require testing on the Deck; if release
selection does not work, use the tap-and-click path while diagnosing it.
Do not add a release-time mouse click: it could click the newly launched app.
## Steam Input layout: stock desktop + a held Niri layer
Steam owns its live controller files. This repository does not overwrite
account-specific Steam userdata or claim a layout has already been installed.
After hardware verification, export the personal layout from Steam and retain
the actual exported VDF alongside this document for reproducible re-import.
`controller_neptune_niri_desktop.vdf` is Valve's `desktop_neptune.vdf` with one
addition: an action layer named **Niri** that is active while the **L5** back
grip is held. The stock layout bound L5 to a bare Super key, which does nothing
on its own; everything else in the base layer is unchanged (right pad mouse,
R2/L2 clicks, A Enter, B Escape, X Steam keyboard, Y Space, D-pad arrows,
Steam-button chords).
## Menu
While L5 is held:
| Input | Sends | Niri action |
| --- | --- | --- |
| D-pad left / right | Super+H / Super+L | Focus column left / right (wraps) |
| D-pad up / down | Super+K / Super+J | Focus workspace up / down |
| A | Super+Space | Noctalia launcher |
| B | Super+Q | Close window |
| X | Super+O | Overview |
| Y | Super+Alt+K | Toggle on-screen keyboard (wvkbd) |
| L1 / R1 | Super+Shift+H / L | Move column left / right |
| Start (≡) | Super+A | Deck app wheel (Kando) |
| Select (⧉) | Super+Ctrl+Space | Noctalia control center |
| L4 | Super+Ctrl+G | Return to Gaming Mode |
| R4 | Super+Escape | Previous workspace |
| R5 | Super+Shift+F | Fullscreen window |
Super+A, Super+Ctrl+G and Super+Alt+K are Deck-only binds declared in
`controller-desktop.nix`; every other chord is a shared binding from
`niri.kdl`. The layer never rebinds L5 itself, so release always drops it.
A raw capture of the extest device on 2026-09-14 showed every chord arriving
with its modifiers held (for example LEFTSHIFT+LEFTMETA+H), so Steam's
repeated-`binding` chord encoding is confirmed on hardware.
## On-screen keyboard
Steam's keyboard (X in the base layer) is an override-redirect X11 surface.
Niri never lists it, cannot apply rules to it, and it ends up covering the
field being typed into. It still works for Steam's own windows, but the
desktop keyboard is **wvkbd**: a Wayland layer-shell keyboard that requests
an exclusive zone, so the focused window shrinks above it rather than being
overlapped, and types through the virtual-keyboard protocol into any client.
`deck-wvkbd.service` starts it hidden with Niri (280px landscape height,
static Rosé Pine colours). Toggle it with L5+Y, with the **Keyboard** button
next to **Apps** in the bar, or by tapping the panel and using it as a touch
keyboard. The toggle is `systemctl --user kill --signal=SIGRTMIN
deck-wvkbd.service`; SIGUSR1 hides and SIGUSR2 shows if a script needs a
definite state.
### Installing the layout
Home Manager activation installs the file into Steam's template directory,
`~/.local/share/Steam/controller_base/templates/`, which is where Steam looks
for additional layouts and lists them under **Templates** in its picker (the
same mechanism RetroDECK uses). The copy is repeated on every activation, so a
Steam client update that prunes the directory is undone by the next rebuild.
If Steam has not been bootstrapped yet the step is skipped with a message.
Selecting it is a one-time Steam GUI step, because Steam owns the per-account
selection file and rewrites it:
1. Desktop Mode → Steam → Settings → Controller → **Desktop Layout** → Edit.
2. Open the layout picker, choose the **Templates** tab, select **Niri Desktop**.
3. Confirm. `~/.local/share/Steam/logs/controller_ui.txt` should then report
the desktop config path as the template instead of
`controller_base/desktop_neptune.vdf`.
Steam's action-layer ordinal is positional: Default = 1, the stock Gamepad set
= 2, the Niri layer = 3. `hold_layer 3 0 0` in the base L5 binding refers to
that ordinal, the same numbering the stock `CHANGE_PRESET 2 1 1` uses. Adding
another action set before the layer would renumber it.
### Regenerating the file
The VDF is a static asset; do not hand-edit Steam's live copy. To rebuild it
from a newer stock layout, copy `controller_base/desktop_neptune.vdf` off the
Deck and re-run the generator used on 2026-09-14 (it replaces the L5 binding,
adds the layer, three groups with `"layer" "1"`, and the layer preset). Keep
the derived file in this directory so the repository is the source of truth.
## Menu (Kando)
Clockwise from the top: Firefox, Steam, Kodi, Files (Thunar), Terminal (Ghostty),
Desktop controls. Controls contains Overview, Settings, Audio, Network,
Gaming Mode, and All apps (Noctalia). Menu positions and commands are declared
in `controller-desktop.nix`; editing Kando's GUI does not persist those changes.
The menu opens centred, with anchored submenus and enlarged directional targets.
Kando's direct gamepad reader is disabled because Steam Input supplies pointer
events. Noctalia and existing keyboard bindings remain available.
Kando opens on **Super+A** (L5 + Start) or by tapping **Apps** at the left of
the 42px Noctalia bar. Point with the right pad or stick, select with R2,
cancel with B. Turbo Mode is off: the earlier design opened the wheel on a
timed Ctrl+Alt+F12 hold and selected on modifier release, which depended on
Steam Input's modifier hand-off timing and was never made to work.
## Verify on the Deck after activation
- Native Firefox and Thunar accept trackpad motion, R2 clicks and keyboard
input while Steam is running. Verify again after suspend/resume.
- Hold L2: one menu opens; no repeated opening, accidental right click, or
stuck Ctrl/Alt after selection or cancellation. Check both pad and stick.
- Each of the six entries launches; submenu actions affect the previous
desktop window after Kando closes.
- Gaming Mode returns through `steamosctl switch-to-game-mode`; the Kando
service stops when Niri exits and is absent from the Gaming Mode session.
- Test handheld and docked menu placement. Upstream describes Niri support as
having some flaws, so evaluation alone does not verify window placement.
- `ls ~/.local/share/Steam/controller_base/templates/ | grep niri` shows the file;
Steam's Templates tab lists **Niri Desktop**; select it once.
- Without L5: stock behaviour, nothing new. Hold L5 and press D-pad
left/right: Firefox, Steam and Heroic swap places one at a time, each filling
the panel. Release L5: D-pad is arrows again, no stuck Super.
- L5+A opens the launcher. L5+Y raises wvkbd along the bottom edge and the
launcher shrinks above it; type with pad clicks or touch, L5+Y hides it.
L5+B closes the focused window. L5+X shows the overview; pad-click a window.
- L5+Start opens the wheel; each of the six entries launches; R2 selects.
- L5+L4 returns to Gaming Mode; the Kando service is absent there.
- Retest after suspend/resume and after a Steam client update.
Steam supplies the only on-screen keyboard. The Deck disables
The Deck disables
Noctalia's lockscreen, startup lock, idle lock, and lock-before-suspend. Jovian's
automatic login enters Desktop Mode without a password prompt. SSH remains
key-authenticated; this does not assign a blank Unix account password.
## Access without a physical keyboard
## Steam menus and dialogs
Kando opens **Deck Apps** when Desktop Mode starts. To reopen it, click or tap
**Apps** at the left of the Noctalia bar. The Deck bar is 42 pixels tall to make
this control easier to hit. This button does not require a custom Steam
Input layout or opening Noctalia settings.
Steam's menu bar, context menus and the friends-list menus are X11
override-redirect popups. xwayland-satellite 0.8.2 reports the pointer leaving
them right after they map, so they flicker shut and anything behind them
(Games → Add a Non-Steam Game) cannot be reached. The Deck pins 0.8.1 through
Niri's `xwayland-satellite { path }` option in `xwayland-satellite-pin.nix`;
Niri spawns that binary on demand, so a fresh session picks it up. If a menu
still misbehaves, `steam steam://AddNonSteamGame` opens the dialog directly.
Known follow-ups, not addressed here: Framework's five named workspaces still
appear on the Deck strip; Steam starts with `-pipewire` and its recording
feature raises a "Share Screen" portal dialog on sign-in; wvkbd colours do not
follow Mod+F5 (no runtime reload).
References: [Kando navigation](https://kando.menu/usage/),
[Niri integration](https://kando.menu/installation-on-linux/),
[configuration](https://kando.menu/config-files/).
[Kando configuration](https://kando.menu/config-files/),
[RetroDECK layout install via templates](https://github.com/RetroDECK/RetroDECK/wiki/Controllers:-Steam-Deck),
[VDF action-layer syntax example](https://github.com/jsantorek/steamdeck-gw2-layout/blob/main/controller_neptune.vdf).

View file

@ -13,13 +13,29 @@ let
delayed = true;
};
};
# Steam Input Desktop Layout: Valve's desktop_neptune.vdf plus an action
# layer held on the L5 grip that sends Super chords for the shared Niri
# binds (see CONTROLLER.md for the table). Steam lists any *.vdf found in
# controller_base/templates under "Templates" in its layout picker; the file
# name only has to be unique there.
layoutFile = ./controller_neptune_niri_desktop.vdf;
layoutTarget = "${config.home.homeDirectory}/.local/share/Steam/controller_base/templates/controller_neptune_niri_desktop.vdf";
# Steam's own on-screen keyboard is an override-redirect X11 surface: Niri
# never lists it, cannot place it, and it lands on top of whatever field is
# being filled. wvkbd is a layer-shell keyboard that requests an exclusive
# zone, so the focused window shrinks above it instead of being covered,
# and it types through the virtual-keyboard protocol into any Wayland
# client. SIGRTMIN toggles it; it starts hidden.
wvkbdToggle = [ "${pkgs.systemd}/bin/systemctl" "--user" "kill" "--signal=SIGRTMIN" "deck-wvkbd.service" ];
in {
home.packages = [ pkgs.kando pkgs.thunar ];
home.packages = [ pkgs.kando pkgs.thunar pkgs.wvkbd ];
# Accessible before any Steam Input custom layout has been configured.
programs.noctalia.settings = {
bar.default.thickness = lib.mkForce 42;
bar.default.start = lib.mkBefore [ "deck-apps" ];
bar.default.start = lib.mkBefore [ "deck-apps" "deck-keyboard" ];
widget.deck-apps = {
type = "custom_button";
glyph = "apps";
@ -27,6 +43,13 @@ in {
tooltip = "Open Deck Apps";
actions.left = "exec ${pkgs.kando}/bin/kando --menu 'Deck Apps'";
};
widget.deck-keyboard = {
type = "custom_button";
glyph = "keyboard";
label = "Keyboard";
tooltip = "Toggle on-screen keyboard";
actions.left = "exec ${lib.escapeShellArgs wvkbdToggle}";
};
};
xdg.configFile."kando/config.json".text = builtins.toJSON {
@ -36,7 +59,10 @@ in {
showIntroductionDialog = false;
enableAchievements = false;
enableAchievementNotifications = false;
enableTurboMode = true;
# Turbo Mode selects on modifier release. The wheel now opens from a tap
# (Super+A or the bar button), so there is no held modifier to release;
# selection is point-and-click with R2, B cancels.
enableTurboMode = false;
enableGamepad = false; # Steam Input supplies pointer events; avoid double input.
keepInputFocus = false;
enableSelectionWedges = true;
@ -84,16 +110,42 @@ in {
}];
};
# Deck-only binds, inserted into the shared binds block for this host only.
# Plain Super+letter chords: Steam Input sends them as two held sub-commands
# (LEFT_WINDOWS + key), which is the most timing-tolerant shape it offers.
# Every other chord the layout sends is an existing shared bind in niri.kdl.
# Niri owns startup and teardown. Do not register Kando's XDG autostart:
# graphical-session.target is also used by Jovian's Gaming Mode.
desktop.niri.extraBinds = ''
Ctrl+Alt+F12 repeat=false hotkey-overlay-title="Deck app wheel" {
Mod+A repeat=false hotkey-overlay-title="Deck app wheel" {
spawn "${pkgs.kando}/bin/kando" "--menu" "Deck Apps";
}
Mod+Ctrl+G hotkey-overlay-title="Return to Gaming Mode" {
spawn "/run/current-system/sw/bin/steamosctl" "switch-to-game-mode";
}
Mod+Alt+K repeat=false hotkey-overlay-title="Toggle on-screen keyboard" {
spawn ${lib.concatMapStringsSep " " (a: "\"${a}\"") wvkbdToggle};
}
'';
desktop.niri.extraConfig = ''
spawn-at-startup "steam" "-silent"
spawn-at-startup "${pkgs.systemd}/bin/systemctl" "--user" "start" "deck-kando.service"
spawn-at-startup "${pkgs.systemd}/bin/systemctl" "--user" "start" "deck-kando.service" "deck-wvkbd.service"
// 1280x800 handheld: every tiled window opens as a maximized column, so
// the focused app always fills the panel and the pointer can reach all
// of it. Navigating the scrolling layout becomes previous/next column.
// open-maximized only affects tiled columns; floating Steam dialogs keep
// their own size (default-column-width would stretch those too).
window-rule {
open-maximized true
}
// The shared config floats Settings at 1080x920, taller than this panel.
// Later rules win per property: tile it and let it maximize instead.
window-rule {
match app-id=r#"^dev\.noctalia\.Noctalia$"#
open-floating false
}
window-rule {
match title="^Kando Menu$"
@ -119,4 +171,42 @@ in {
};
};
systemd.user.services.deck-wvkbd = {
Unit = {
Description = "Steam Deck on-screen keyboard (wvkbd)";
PartOf = [ "niri.service" "graphical-session.target" ];
After = [ "graphical-session.target" ];
ConditionEnvironment = "WAYLAND_DISPLAY";
};
Service = {
# Landscape height applies on the 1280x800 panel; colors are static
# Rosé Pine (wvkbd has no runtime theme reload).
ExecStart = lib.escapeShellArgs [
"${pkgs.wvkbd}/bin/wvkbd-mobintl"
"--hidden"
"-L" "280"
"-R" "6"
"--fn" "Hack Nerd Font Mono 15"
"--bg" "191724" "--fg" "26233a" "--fg-sp" "403d52"
"--press" "524f67" "--press-sp" "6e6a86"
"--swipe" "524f67" "--swipe-sp" "6e6a86"
"--text" "e0def4" "--text-sp" "908caa"
];
Restart = "on-failure";
RestartSec = 2;
};
};
# Steam owns controller_base; a client update may recreate it. Re-install
# the template on every activation so the repository stays the source of
# truth. Selecting it is a one-time GUI step: the per-account configset
# file is Steam's, and it rewrites that file.
home.activation.deckControllerLayout = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
templates="$(dirname ${lib.escapeShellArg layoutTarget})"
if [ -d "$templates" ]; then
run install -m 0644 ${layoutFile} ${lib.escapeShellArg layoutTarget}
else
echo "deck controller layout: $templates not found (Steam not bootstrapped yet); skipped"
fi
'';
}

File diff suppressed because it is too large Load diff

View file

@ -3,10 +3,13 @@
{
imports = [
./controller-desktop.nix
./xwayland-satellite-pin.nix
./syncthing.nix
../../programs/vesktop.nix
../../programs/keepassxc.nix
../../programs/games/minecraft
../../programs/games/openrct2
../../programs/games/heroic
# Configured Claude Code (settings/agents/MCP/plugins under ~/.claude). Required
# by the openrct2 coding-agent fork, whose in-game terminal launches `claude`.
../../terminal/programs/claude

View file

@ -0,0 +1,37 @@
{ pkgs, ... }:
let
# xwayland-satellite 0.8.2 (commit 3273a0f) reports the pointer as leaving
# Steam's override-redirect menu popups about 35 ms after they map, so every
# menu-bar and context menu closes the instant it opens, and anything only
# reachable through those menus (Games → Add a Non-Steam Game) is unreachable.
# 0.8.1 renders them correctly.
# https://github.com/Supreeeme/xwayland-satellite/issues/503
# https://github.com/niri-wm/niri/issues/4517
# https://github.com/ValveSoftware/steam-for-linux/issues/13566
# Deck-only: Niri is pointed at this binary through its own config option, so
# the shared niri.kdl and the other hosts keep whatever nixpkgs ships. Drop
# this file once a release past 0.8.2 fixes the popup pointer-leave.
xwaylandSatellite = pkgs.xwayland-satellite.overrideAttrs (old: rec {
version = "0.8.1";
src = pkgs.fetchFromGitHub {
owner = "Supreeeme";
repo = "xwayland-satellite";
tag = "v${version}";
hash = "sha256-BUE41HjLIGPjq3U8VXPjf8asH8GaMI7FYdgrIHKFMXA=";
};
# buildRustPackage derives cargoDeps from cargoHash at call time, so the
# vendor derivation has to be replaced, not the hash.
cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
inherit src;
hash = "sha256-16L6gsvze+m7XCJlOA1lsPNELE3D364ef2FTdkh0rVY=";
};
});
in {
desktop.niri.extraConfig = ''
// Pinned 0.8.1; see xwayland-satellite-pin.nix.
xwayland-satellite {
path "${xwaylandSatellite}/bin/xwayland-satellite"
}
'';
}

View file

@ -0,0 +1,77 @@
# Heroic Games Launcher — the Epic/GOG/Amazon client, added here to run Rocket League.
#
# Why a launcher and not a package
# Rocket League cannot be a nixpkgs derivation. Psyonix dropped the native Linux
# build in March 2020 and delisted the game from Steam that September; it now
# exists only as a free-to-play, Windows-only title on the Epic Games Store.
# So the store path holds the *client*, and the game itself is runtime state that
# Heroic downloads into ~/Games/Heroic (or wherever you point it — an SD card
# works). Same split as ../openrct2, which packages the engine but leaves the
# RCT2 game data to a first-launch prompt.
#
# Epic has never shipped an Epic Games Launcher for Linux, so an Epic-owned copy
# needs a third-party client regardless. Epic's own support page saying no such
# software is needed on SteamOS is scoped to the *Steam*-owned copy ("You can do
# the launch via Steam if you own it there") — Steam supplies Proton itself.
# Heroic (which drives Legendary underneath) is what speaks Epic's API here.
#
# `heroic`, NOT `heroic-unwrapped`
# These are two different packages and picking the wrong one breaks at runtime,
# not at build time. `heroic` is `steam.buildRuntimeEnv` wrapping
# `heroic-unwrapped` — i.e. the Steam FHS sandbox. That matters because Heroic's
# whole job is executing foreign, dynamically-linked binaries: the Windows game,
# and any Wine/Proton build it downloads itself. Outside an FHS environment those
# fail with the usual misleading "No such file or directory" (the missing path is
# /lib64/ld-linux-x86-64.so.2, not the binary) — exactly the trap in CLAUDE.md.
# heroic-unwrapped launches fine and then cannot run a single game.
#
# Anti-cheat — supported, not merely tolerated
# Psyonix shipped Easy Anti-Cheat on 2026-04-28 and explicitly enabled EAC's
# Linux/Proton path; Epic's support pages name Steam Deck and Linux as supported
# platforms. EAC is opt-out (there is a "Play without Easy Anti-Cheat" launch
# option) but online matchmaking requires it on, which also means BakkesMod and
# other mods are offline/LAN-only now. ProtonDB rates the game Platinum (0.88
# over ~864 reports, trending Platinum), and GamingOnLinux's 2026-04-30 test of
# the post-EAC build was made through Heroic specifically.
#
# heroic-unwrapped also puts `umu-launcher` on Heroic's PATH on x86_64-linux
# (see its makeWrapper call). umu is the Proton runtime shim that reproduces
# Steam's environment for non-Steam launches, including the EAC bits. Nothing to
# configure; it just has to be reachable, and nixpkgs handles that.
#
# The EOS Overlay is NOT supported on Linux/Steam Deck per Epic's own EOS docs,
# and will not load however it is toggled. It is cosmetic — unrelated to EAC —
# so this is not a problem to chase.
#
# Proton
# Heroic's own Rocket League wiki page says the game "should work just fine out
# of the box with GE-Proton". Get that from Heroic's built-in Wine Manager tab
# rather than adding pkgs.protonup-qt: Heroic downloads GE-Proton into
# ~/.config/heroic/tools, and because this package IS the Steam FHS runtime (see
# above), that downloaded build executes correctly — the one case where pulling a
# prebuilt binary from the internet is fine on NixOS, since it never leaves the
# sandbox. protonup-qt only becomes worth adding if you also want GE-Proton
# registered for Steam itself.
#
# Adding it to Gaming Mode
# heroic-unwrapped ships com.heroicgameslauncher.hgl.desktop (Exec=heroic) and the
# FHS wrapper symlinks share/applications + share/icons through, so Desktop Mode
# sees it without a hand-written xdg.desktopEntry.
#
# When adding it via Steam's "Add a Non-Steam Game", point the shortcut at
# /home/deck/.nix-profile/bin/heroic
# and NOT at the /nix/store/<hash>-heroic/bin/heroic that the file browser will
# land on. Steam bakes the absolute path into shortcuts.vdf, and a store path is
# invalidated by the next rebuild that bumps Heroic — the shortcut then dies on
# the following GC, from Gaming Mode, with no useful error. ~/.nix-profile/bin is
# the stable indirection: this host runs Home Manager with useUserPackages = false
# (see hosts/default.nix — the steamdeck system does not import
# system/programs/home-manager.nix), so HM installs into the user profile there.
#
# Heroic can also write the Steam shortcut itself ("Add to Steam" per game), which
# avoids hand-picking a path.
{ pkgs, ... }:
{
home.packages = [ pkgs.heroic ];
}

View file

@ -9,7 +9,6 @@ in
{
home.packages = with pkgs; [
pkgs-unstable.telegram-desktop
discord-ptb
signal-desktop
element-desktop
];

55
home/programs/vesktop.nix Normal file
View file

@ -0,0 +1,55 @@
{ config, lib, pkgs, ... }:
let
vesktopDir = "${config.xdg.configHome}/vesktop";
# Vencord resolves its data dir from VENCORD_USER_DATA_DIR, which Vesktop
# sets to its own Electron userData dir. So themes live in
# <vesktopDir>/themes and Vencord's own state in <vesktopDir>/settings.
themeFile = "noctalia.theme.css";
# Vencord persists all plugin and theme state to settings/settings.json, so
# declaring `programs.vesktop.vencord.settings` would link it in read-only
# and break the in-app settings panel. Seed the one key we care about into
# the mutable file instead, appending so Noctalia lands last in the cascade
# and never reordering what is already enabled.
enableTheme = pkgs.writeShellApplication {
name = "vesktop-enable-noctalia-theme";
runtimeInputs = with pkgs; [ jq coreutils ];
text = ''
dir="$1"
settings="$dir/settings/settings.json"
mkdir -p "$dir/themes" "$dir/settings"
if [ -f "$settings" ]; then
tmp="$(mktemp "$settings.XXXXXX")"
jq --arg t "${themeFile}" \
'if (.enabledThemes // []) | index($t) then .
else .enabledThemes = ((.enabledThemes // []) + [$t]) end' \
"$settings" > "$tmp"
mv "$tmp" "$settings"
else
jq -n --arg t "${themeFile}" '{ enabledThemes: [ $t ] }' > "$settings"
fi
'';
};
in
# Vesktop replaces the official client: it is the only Discord client that can
# load the Noctalia theme, and Vencord live-reloads it on every Mod+F5.
{
programs.vesktop = {
enable = true;
# Take Vencord from nixpkgs instead of letting Vesktop fetch and
# self-update its own copy at runtime.
vencord.useSystem = true;
# Intentionally no `settings` / `vencord.settings` — see enableTheme above.
};
# Vencord reads settings.json once at startup (it watches the themes dir and
# quickCss.css, but not its own settings), so a newly seeded theme applies
# from the next Vesktop launch onwards.
home.activation.vesktopNoctaliaTheme = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
run ${lib.getExe enableTheme} ${lib.escapeShellArg vesktopDir}
'';
}

View file

@ -399,6 +399,7 @@ window-rule {
// comms → named workspaces, hidden from screencasts
window-rule {
match app-id=r#"(?i)^discord"#
match app-id=r#"(?i)^vesktop"#
match app-id=r#"^org\.telegram\.desktop$"#
match app-id=r#"(?i)^signal"#
open-on-workspace "chat"

View file

@ -19,6 +19,14 @@
yazi.enable = false;
opencode.enable = false;
obsidian.enable = false;
# Wrote ~/.config/Vencord/themes/stylix.theme.css for Vencord-injected-into-
# Discord, a client this config no longer installs. Vesktop reads
# ~/.config/vesktop/themes instead, so the file was simply dead.
vencord.enable = false;
# Pre-asserts `vencord.settings.enabledThemes = [ "stylix.css" ]`, which
# would both mask Noctalia's live theme and freeze Vencord's writable
# settings.json as a store symlink. vesktop.nix seeds the theme instead.
vesktop.enable = false;
zathura.enable = false;
};
}

View file

@ -12,6 +12,25 @@ let
fzfTemplate = pkgs.runCommand "noctalia-fzf-template" { } ''
sed -n '/^--color=/p' ${source}/fzf/fzf.sh | sed 's/"$//' > "$out"
'';
# Midnight spends its three dimmest text slots on Material's outline roles,
# which are specified for dividers (~3:1, ~1.5:1) rather than for text. Under
# this dark palette that puts message body at 5.5:1, channel names at 3.0:1
# and timestamps at 1.7:1 against the chat background - the last two below
# WCAG AA. Shift each slot one rung up the on-surface scale, which does carry
# a text contrast guarantee, and let outline_variant fall out of text duty.
# This rewrites which role is read, not the color, so live retheming and the
# other palettes (light mode included) keep working.
discordTemplate = pkgs.runCommand "noctalia-discord-midnight" { } ''
cp ${source}/discord/discord-midnight.css "$out"
chmod u+w "$out"
substituteInPlace "$out" \
--replace-fail '--text-3: {{colors.on_surface_variant.default.hex}};' \
'--text-3: {{colors.on_surface.default.hex}};' \
--replace-fail '--text-4: {{colors.outline.default.hex}};' \
'--text-4: {{colors.on_surface_variant.default.hex}};' \
--replace-fail '--text-5: {{colors.outline_variant.default.hex}};' \
'--text-5: {{colors.outline.default.hex}};'
'';
# Upstream builds beside apply.sh, which is read-only in the Nix store.
libreofficeSource = pkgs.runCommand "noctalia-libreoffice" { } ''
cp -r ${source}/libreoffice "$out"
@ -41,10 +60,11 @@ in
"${userHome}/.claude-work/themes/noctalia.json"
];
codex = template "codex/codex.tmTheme" "${userHome}/.codex/themes/noctalia.tmTheme";
discord = template "discord/discord-midnight.css" [
"${cfg}/Vencord/themes/noctalia.theme.css"
"${cfg}/vesktop/themes/noctalia.theme.css"
];
# Vesktop points Vencord at its own userData dir, so themes live here.
discord = {
input_path = "${discordTemplate}";
output_path = "${cfg}/vesktop/themes/noctalia.theme.css";
};
fcitx5 = (template "fcitx5/theme.conf" "${config.xdg.dataHome}/fcitx5/themes/noctalia/theme.conf") // {
post_hook = "${pkgs.systemd}/bin/busctl --user call org.fcitx.Fcitx5 /controller org.fcitx.Fcitx.Controller1 ReloadAddonConfig s classicui";
};

View file

@ -7,7 +7,7 @@
homeImports = import "${self}/home/profiles";
mod = "${self}/system";
inherit (import mod) laptop steamdeck iso phantom;
inherit (import mod) laptop steamdeck phantom;
specialArgs = { inherit inputs self;};
# Shared modules may select unstable applications. Scope those selections
@ -89,37 +89,6 @@
];
};
# Live ISO - portable NixOS with Framework13-like environment
live-iso = nixosSystem {
inherit specialArgs;
modules = iso ++ [
./iso
"${mod}/programs/hyprland"
# Home Manager (stable, follows nixpkgs)
inputs.home-manager.nixosModules.home-manager
# Secrets management (for Yubikey-based sops decryption)
inputs.sops-nix.nixosModules.sops
# Private NixOS config (from the private flake)
inputs.secrets.nixosModules.default
{
home-manager = {
users.nixos.imports = homeImports."nixos@iso";
extraSpecialArgs = specialArgs;
backupFileExtension = ".hm-backup";
sharedModules = [
inputs.sops-nix.homeManagerModules.sops
inputs.nix4nvchad.homeManagerModules.default
];
};
}
];
};
# Phantom USB - ephemeral NixOS that leaves no trace
# LUKS-encrypted, impermanent root, WireGuard home overlay
phantom = nixosSystem {

View file

@ -3,7 +3,6 @@
#
# Inheritance structure:
# base → desktop → laptop
# → iso
# → phantom (ephemeral USB with impermanence)
# base → steamdeck (uses Jovian-NixOS for desktop)
@ -56,16 +55,6 @@ let
./services/fprintd.nix
];
# Live ISO - portable system with Hyprland environment
# Extends desktop with network auth and YubiKey support
# Note: boot.nix excluded - ISO uses its own boot configuration
iso = desktop ++ [
./network/tailscale.nix # Base Tailscale service
./network/tailscale-iso.nix # Auto-auth with sops-encrypted key
./services/yubikey.nix # For sops decryption with age-plugin-yubikey
];
# Phantom USB - ephemeral system with impermanence
# Extends desktop with LUKS boot, impermanence, and paranoid security
# Root is tmpfs - only /persist survives reboot
@ -98,5 +87,5 @@ let
];
in {
inherit laptop steamdeck iso phantom;
inherit laptop steamdeck phantom;
}