diff --git a/DECISIONS.md b/DECISIONS.md index 36a60a0..841eafc 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,80 @@ # Decisions +## 2026-09-11 – gtklock replaces swaylock; GTK theme moves from Frappé to Mocha + +- **Who**: Dominik, ClaudeCode +- **Why**: Two findings from looking at whether gtklock was worth it. (1) The GTK layer + was never on Mocha. `colloid-gtk-theme` with `tweaks = [ "catppuccin" ]` produces + **Frappé** with a grey accent — base `#292c3c`, selection `#ccd0da`, error `#ea999c`; + `#1e1e2e`, `#b4befe` and `#cba6f7` appear nowhere in its `gtk.css`. Kvantum + (`catppuccin-mocha-lavender`), the swaylock ring and Zed were all on Mocha, so GTK was + the one layer out of step, and nobody had noticed because the two dark bases are close. + (2) The old objection to gtklock is gone: 4.0.0 links `libgtk-session-lock.so.0`, so it + speaks ext-session-lock-v1 like swaylock and the compositor still holds the lock if it + crashes. The layer-shell versions that made a crash unlock the screen were ≤2.x. +- **Tradeoffs**: (1) **Locker** — swaylock's case was "one protocol, no toolkit, least + likely to break" (2026-08-14). gtklock adds GTK3 and two `dlopen`ed modules to the path + that decides whether the machine unlocks. Accepted for what the modules give: avatar, + clock and power buttons, none of which swaylock can do at all. Crash safety is + unchanged, which was the part that actually mattered. (2) **Blur** — no gain. GTK3 + 3.24.52 has no `filter` CSS property (verified against `libgtk-3.so.0.2420.32`: it + knows `background-image` and `box-shadow`, not `filter` or `backdrop-filter`), and + gtklock takes no screenshot, so there is nothing to blur at lock time. The pre-blurred + wallpaper stays, exactly as under swaylock. (3) **GTK theme** — `catppuccin-gtk` is + Colloid: it carries `vinceliuice/Colloid-gtk-theme` as a git submodule and only + recolors it. Identical widget shapes, so the switch is a recolor with no layout risk. + Its icon counterpart does not exist, and `Colloid-Grey-Catppuccin-Dark` stays — an icon + set has no background or accent surface to clash. (4) **Where the gtklock config + lives** — `/etc/xdg/gtklock/config.ini`, generated rather than copied, because both the + background and the module paths are store paths. gtklock searches `$XDG_CONFIG_DIRS`, + which swaylock did not, so the `$HOME` seed that swaylock needed is gone. (5) **Module + paths** — absolute store paths in the config, not `/run/current-system/sw/lib`. `-m` + resolves a bare name against the directory compiled into gtklock, which under Nix is + gtklock's own store path and holds no module. (6) **stasis** — its config is moonarch's + and names `swaylock` in four places. Rewritten here with `substitute`, the same + treatment waypaper gets, until moonarch switches too. The Quickshell power popout + spawns the locker itself (`PowerPopout.qml:56`) and needed the same, except that its + config is deployed as a whole directory, so the tree is copied and the one file + patched. Both take the command from one `lockCommand` binding, the popout's argv form + derived from it, so the locker is named once in this repo rather than three times. + (7) **Why both spawn it directly** — there is no Wayland protocol for "lock the + session": ext-session-lock-v1 is how a client *becomes* the lock, not a request a third + party can send, and no portal covers it either. logind's `Session.Lock` only emits a + signal someone has to be subscribed to, and nothing here is — niri owns + `org.freedesktop.ScreenSaver` with `Inhibit`/`UnInhibit` and no `Lock`, and stasis + ignores the signal on purpose (`services/dbus.rs`: "login1's Lock/Unlock signals are + requests, not completed state changes"). `xdg-screensaver lock` is no answer either: it + is an X11 script whose generic path runs `xautolock -locknow`. (8) **`stasis trigger + lock-screen` for the popout** — rejected. It would name the locker in one place, but + the Lock button would then fail silently whenever stasis is down, which is the worst + direction for a button whose only job is security. The reason to want it turned out not + to exist: the plan continues after a manual lock regardless, because stasis takes the + state from logind's `LockedHint` (`LockSource::LockedHint`, `core/events.rs`) and does + not need to have started the locker. Verified by locking by hand and watching DPMS fire + on schedule. (9) **`-d` is not quite `-f`** — swaylock forks once the screen is locked, + gtklock forks in `main()` before it has read its config (`source.c:284`), so a returned + `gtklock -d` means "forked", not "locked". Nothing chains off the exit here; it would + matter if something ever wanted to suspend only after the screen is covered. +- **How**: `flake.nix` `themePackages.gtk` → `catppuccin-gtk.override { variant = + "mocha"; accents = [ "lavender" ]; }`; theme name `catppuccin-mocha-lavender-standard` + in the new `gtkTheme` binding, in `programs.dconf` and in `modules/greetd.nix`. New + `gtkSettings` rewrites the Colloid name out of moonarch's `gtk-3.0`/`gtk-4.0` + `settings.ini` — necessary because GTK3 reads those files directly and the dconf entry + does not reach it on Wayland; without it every GTK3 application would have fallen back + to Adwaita. `swaylockConfig` → `gtklockConfig` (`writeText`, deployed to + `/etc/xdg/gtklock/config.ini`) plus `stasisConfig`; `swaylock` out of + `systemPackages`, `security.pam.services` and the tmpfiles seeds, `gtklock` + + `gtklock-userinfo-module` + `gtklock-powerbar-module` in. `modules/services.nix`: the + stasis unit's `path` carries `gtklock`. `playerctl-module` deliberately not installed — + it needs playerctld, which nothing here runs. +- **Remaining exception**: the userinfo module reads the avatar through AccountsService, + which resolves to `~/.face`. With no such file it warns once and falls back to + `avatar-default-symbolic` from the icon theme. There is no option for the path; drop an + image at `~/.face` to have one. +- **Open on the moonarch side**: `defaults/etc/swaylock/`, the `swaylock` entry in + `packages/official.txt`, `check_swaylock_config` in `moonarch-doctor` and the four + `swaylock` lines in `stasis.rune`. Once those move, `stasisConfig` here can go. + ## 2026-09-08 – nwg-displays replaces wdisplays; kanshi dropped for niri's own outputs - **Who**: Dominik, ClaudeCode diff --git a/README.md b/README.md index 5b94018..e80410d 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ NixOS configuration for a scrollable-tiling Wayland desktop: niri as the compositor, Quickshell as the bar and launcher, regreet as the login greeter, -swaylock as the screen locker, Catppuccin Mocha throughout. +gtklock as the screen locker, Catppuccin Mocha throughout. Ships Nix packages for the components that are not in nixpkgs — sweet-cursors, the moonarch helper scripts — and NixOS modules that assemble them into a working session. Greeter, locker and power menu are no longer own programs: -`regreet` and `swaylock` come from nixpkgs, the power menu is a Quickshell popout +`regreet` and `gtklock` come from nixpkgs, the power menu is a Quickshell popout inside the moonarch config. The XDG configuration files (niri, Quickshell, foot, GTK, Qt) come from the @@ -91,7 +91,7 @@ the NixOS ISO and run one command. The account comes up with the initial password declared in the host file (`users.users..initialPassword`, currently `moonarch`). Autologin gets -past the greeter either way, but swaylock authenticates through pam_unix and +past the greeter either way, but gtklock authenticates through pam_unix and cannot unlock a screen for an account without a password, so one has to exist from the first boot. Change it with `passwd` afterwards — `mutableUsers` is at its default, so that sticks and the declared value is only ever used to create @@ -268,7 +268,8 @@ NixOS module owns the destination: | File | Reason | |---|---| | `greetd/regreet.toml`, `regreet.css` | written by `services.displayManager.regreet` from module options; the wallpaper points at the store path | -| `swaylock/config` | wallpaper path, rewritten and seeded into `$HOME` — swaylock's sysconfdir is inside the store, so a system-wide copy is never read | +| `gtklock/config.ini` | generated, not copied: background and module paths are store paths. Deployed to `/etc/xdg`, which gtklock reads — no `$HOME` seed | +| `stasis/stasis.rune` | `swaylock` rewritten to `gtklock` in the four action lines, then seeded into `$HOME` | | `waypaper/config.ini` | wallpaper path, rewritten and seeded into `$HOME` | | fontconfig defaults | `/etc/fonts/conf.d` is owned by the NixOS module | diff --git a/flake.nix b/flake.nix index ff89a68..3a75569 100644 --- a/flake.nix +++ b/flake.nix @@ -54,13 +54,20 @@ ''; }); - # Colloid overrides, used by the desktop session and by the greeter. + # Theme overrides, used by the desktop session and by the greeter. # Defined once so the two cannot drift apart. themePackages = { - gtk = pkgs.colloid-gtk-theme.override { - themeVariants = [ "grey" ]; - colorVariants = [ "dark" ]; - tweaks = [ "catppuccin" ]; + # catppuccin/gtk is Colloid: it pulls vinceliuice's theme in as a git + # submodule and only recolors it, so the widget shapes are the same + # either way. Colloid's own `catppuccin` tweak produces Frappe with a + # grey accent (base #292c3c, selection #ccd0da) — which left GTK as the + # one layer not on Mocha, while Kvantum and Zed both are. This build is + # Mocha with the lavender accent, so #b4befe, #f38ba8 and #f9e2af match + # catppuccin-mocha-lavender in Kvantum exactly — and gtklock, being + # GTK3, takes its whole palette from here. + gtk = pkgs.catppuccin-gtk.override { + variant = "mocha"; + accents = [ "lavender" ]; }; icons = pkgs.colloid-icon-theme.override { diff --git a/hosts/desktop/default.nix b/hosts/desktop/default.nix index b61e388..6ece301 100644 --- a/hosts/desktop/default.nix +++ b/hosts/desktop/default.nix @@ -58,7 +58,7 @@ # gamemoded is denied the governor change on every game launch. extraGroups = [ "networkmanager" "wheel" "video" "input" "plugdev" "docker" "gamemode" ]; shell = pkgs.zsh; - # Autologin gets past the greeter without a password, but swaylock + # Autologin gets past the greeter without a password, but gtklock # authenticates through pam_unix and cannot unlock a screen for an account # that has none. `mutableUsers` is at its default, so this applies when the # account is created and `passwd` overrides it afterwards. Change it after diff --git a/hosts/thinkpad/default.nix b/hosts/thinkpad/default.nix index 40b9cb9..8abf66f 100644 --- a/hosts/thinkpad/default.nix +++ b/hosts/thinkpad/default.nix @@ -41,7 +41,7 @@ isNormalUser = true; extraGroups = [ "networkmanager" "wheel" "video" "input" "plugdev" "docker" ]; shell = pkgs.zsh; - # Autologin gets past the greeter without a password, but swaylock + # Autologin gets past the greeter without a password, but gtklock # authenticates through pam_unix and cannot unlock a screen for an account # that has none. `mutableUsers` is at its default, so this applies when the # account is created and `passwd` overrides it afterwards. Change it after diff --git a/modules/desktop.nix b/modules/desktop.nix index 8a33dcf..036d3f0 100644 --- a/modules/desktop.nix +++ b/modules/desktop.nix @@ -25,15 +25,100 @@ let --replace-fail /usr/share/moonarch/wallpaper.jpg ${wallpaper} ''; - # Same treatment for swaylock's wallpaper path. swaylock searches - # ~/.swaylock/config, $XDG_CONFIG_HOME/swaylock/config and - # SYSCONFDIR/swaylock/config — and its sysconfdir is inside the store here, - # so the home copy seeded below is the only one it ever reads. The file is a - # system config rather than an XDG one, which is why moonarch keeps it under - # defaults/etc/ rather than defaults/xdg/. - swaylockConfig = pkgs.runCommand "swaylock-config" { } '' - substitute ${etc}/swaylock/config "$out" \ - --replace-fail /usr/share/moonarch/wallpaper-blur.jpg ${wallpaperBlur} + # gtklock reads $XDG_CONFIG_HOME/gtklock/config.ini and gtklock/config.ini + # under every $XDG_CONFIG_DIRS entry, so /etc/xdg is enough and nothing has + # to be seeded into the home directory — the one thing swaylock could not do, + # its sysconfdir being inside the store. + # + # The background stays the pre-blurred image. gtklock cannot blur either: + # GTK3 has no `filter` CSS property at all (checked against + # libgtk-3.so.0.2420.32 — it knows `background-image` and `box-shadow`, and + # neither `filter` nor `backdrop-filter`), and it takes no screenshot of the + # session, so there is nothing to blur at lock time in the first place. + # + # No gtk-theme key: gtklock is GTK3 and reads the session theme out of + # /etc/xdg/gtk-3.0/settings.ini, so the theme name keeps living in one place. + # + # Modules are named by absolute store path. `-m` resolves a bare name against + # the directory compiled into gtklock, which under Nix is gtklock's own store + # path and contains no module at all. powerbar needs no configuration: its + # built-in commands are `systemctl reboot|-i poweroff|suspend`, and systemd + # is on the PATH the stasis unit sets in modules/services.nix. + gtklockConfig = pkgs.writeText "gtklock-config.ini" '' + [main] + background=${wallpaperBlur} + modules=${moduleList} + ''; + + moduleList = lib.concatStringsSep ";" [ + "${pkgs.gtklock-userinfo-module}/lib/gtklock/userinfo-module.so" + "${pkgs.gtklock-powerbar-module}/lib/gtklock/powerbar-module.so" + ]; + + # Both moonarch configs below still spawn swaylock, and each spawns it + # itself rather than asking anything to lock: there is no Wayland protocol + # for "lock the session" — ext-session-lock-v1 is how a client *becomes* the + # lock — and no portal either. logind's Session.Lock only emits a signal that + # something has to be subscribed to, and nothing here is: niri owns + # org.freedesktop.ScreenSaver with Inhibit/UnInhibit and no Lock, and stasis + # deliberately ignores the signal (its services/dbus.rs: "login1's + # Lock/Unlock signals are requests, not completed state changes"). So the + # command is duplicated, and the two rewrites take it from here. + # + # Routing the popout through `stasis trigger lock-screen` instead would put + # the name in one place, and was rejected: the button would then do nothing, + # silently, whenever stasis is down. The plan keeps running after a manual + # lock either way — stasis picks the state up from logind's LockedHint + # (LockSource::LockedHint in its core/events.rs), so DPMS and suspend still + # follow a lock it did not start. Verified by locking by hand and watching + # DPMS fire on schedule. + # + # `-d` is gtklock's detach flag, the counterpart of `swaylock -f`, but not + # quite the same promise: swaylock forks once the screen is locked, gtklock + # forks in main() before it has even read its config (source.c:284). A + # returned `gtklock -d` therefore means "forked", not "locked". Nothing here + # chains off the exit, so it does not matter — it would if something ever + # wanted to suspend only after the screen is actually covered. + lockCommand = "gtklock -d"; + + # stasis names the locker in four places. Its config is seeded into the home + # directory below rather than read from /etc, so the rewrite has to happen + # here. Drops out once moonarch names gtklock itself. + stasisConfig = pkgs.runCommand "stasis.rune" { } '' + substitute ${xdg}/stasis/stasis.rune "$out" \ + --replace-fail 'swaylock -f' '${lockCommand}' \ + --replace-warn '"swaylock"' '"gtklock"' \ + --replace-warn 'Niri + swaylock' 'Niri + gtklock' + ''; + + # The power popout spawns the locker as an argv array (PowerPopout.qml). + # Unlike the stasis config this is deployed as a whole directory, so the + # rewrite copies the tree and patches the one file. Drops out together with + # stasisConfig once moonarch names gtklock. + quickshellConfig = pkgs.runCommand "quickshell-moonarch" { } '' + cp -r ${xdg}/quickshell/moonarch "$out" + chmod -R u+w "$out" + substituteInPlace "$out/PowerPopout.qml" \ + --replace-fail '["swaylock", "-f"]' '[${lockArgv}]' \ + --replace-warn '`swaylock -f` daemonises itself' '`${lockCommand}` daemonises itself' + ''; + + # The same command as a QML string array: ["gtklock", "-d"]. + lockArgv = lib.concatMapStringsSep ", " (a: ''"${a}"'') + (lib.splitString " " lockCommand); + + # The GTK theme name, in the one place both consumers below read it from. + gtkTheme = "catppuccin-mocha-lavender-standard"; + + # moonarch's settings.ini still names Colloid, and GTK3 reads that file + # directly — the dconf entry further down only reaches applications that ask + # GSettings, which on Wayland is not the GTK3 ones. A stale name there would + # therefore put every GTK3 application on Adwaita rather than on the theme + # actually installed. Rewritten here until moonarch switches too, the same + # treatment waypaper and the stasis config get above. + gtkSettings = version: pkgs.runCommand "gtk${version}-settings.ini" { } '' + substitute ${xdg}/gtk-${version}/settings.ini "$out" \ + --replace-fail Colloid-Grey-Dark-Catppuccin ${gtkTheme} ''; # niri prefers ~/.config/niri/config.kdl over /etc/niri/config.kdl and does not @@ -104,13 +189,17 @@ in # moonarch's tree and with any user config that includes it. "niri/config.kdl".source = "${xdg}/niri/config.kdl"; "xdg/niri/config.kdl".source = "${xdg}/niri/config.kdl"; - "xdg/quickshell/moonarch".source = "${xdg}/quickshell/moonarch"; + "xdg/quickshell/moonarch".source = quickshellConfig; "xdg/foot/foot.ini".source = "${xdg}/foot/foot.ini"; "xdg/fastfetch/config.jsonc".source = "${xdg}/fastfetch/config.jsonc"; "xdg/Kvantum/kvantum.kvconfig".source = "${xdg}/Kvantum/kvantum.kvconfig"; "xdg/qt6ct/qt6ct.conf".source = "${xdg}/qt6ct/qt6ct.conf"; - "xdg/gtk-3.0/settings.ini".source = "${xdg}/gtk-3.0/settings.ini"; - "xdg/gtk-4.0/settings.ini".source = "${xdg}/gtk-4.0/settings.ini"; + # Not in moonarch's tree: gtklock arrived here first, and its config is + # generated rather than copied because both the background and the module + # paths are store paths. + "xdg/gtklock/config.ini".source = gtklockConfig; + "xdg/gtk-3.0/settings.ini".source = gtkSettings "3.0"; + "xdg/gtk-4.0/settings.ini".source = gtkSettings "4.0"; # No xdg/pipewire/ entry: PipeWire does not read /etc/xdg, so the file was # deployed and inert. It goes through services.pipewire.configPackages # below instead. @@ -218,7 +307,7 @@ in enable = true; profiles.user.databases = [{ settings."org/gnome/desktop/interface" = { - gtk-theme = "Colloid-Grey-Dark-Catppuccin"; + gtk-theme = gtkTheme; icon-theme = "Colloid-Grey-Catppuccin-Dark"; color-scheme = "prefer-dark"; font-name = "UbuntuSans Nerd Font 11"; @@ -297,7 +386,15 @@ in librewolf # Session lock. The power menu is a Quickshell popout, not a package. - swaylock + # gtklock speaks the same ext-session-lock-v1 as swaylock, through + # gtk-session-lock, so a crash still leaves the screen locked. The modules + # are the reason for it: avatar and clock, and the power buttons. They are + # installed for the sake of `nix-store --query --roots`; gtklock itself + # loads them from the store paths named in the config above. playerctl is + # deliberately absent — it needs playerctld, which nothing here runs. + gtklock + gtklock-userinfo-module + gtklock-powerbar-module # Compositor extras xwayland-satellite @@ -392,7 +489,11 @@ in claude-code ]; - security.pam.services.swaylock = {}; + # Without this gtklock takes the session lock and then rejects every + # password: there would be no /etc/pam.d/gtklock, and /etc/pam.d/other denies + # auth outright. The generated stack is `auth sufficient pam_unix.so`, the + # same one swaylock had. + security.pam.services.gtklock = {}; # Polkit agent for privilege prompts inside the session. security.polkit.enable = true; @@ -428,8 +529,8 @@ in "C %h/.config/waypaper/config.ini 0644 - - - ${waypaperConfig}" # stasis prefers the user config over /etc and generates its own if none # exists, so the moonarch config has to be in place before it starts. - "C %h/.config/stasis/stasis.rune 0644 - - - ${xdg}/stasis/stasis.rune" - "C %h/.config/swaylock/config 0644 - - - ${swaylockConfig}" + "C %h/.config/stasis/stasis.rune 0644 - - - ${stasisConfig}" + # No gtklock entry: it reads /etc/xdg, which swaylock could not. # nwg-displays needs this file to exist before it will start, and niri would # otherwise ignore the monitor.kdl it writes next to it. "C %h/.config/niri/config.kdl 0644 - - - ${niriUserConfig}" diff --git a/modules/greetd.nix b/modules/greetd.nix index 2017b1d..06ac86f 100644 --- a/modules/greetd.nix +++ b/modules/greetd.nix @@ -40,7 +40,7 @@ in # The rest of the GTK table is generated from these options. theme = { package = moonarchThemes.gtk; - name = "Colloid-Grey-Dark-Catppuccin"; + name = "catppuccin-mocha-lavender-standard"; }; iconTheme = { diff --git a/modules/services.nix b/modules/services.nix index add2950..5933c91 100644 --- a/modules/services.nix +++ b/modules/services.nix @@ -269,11 +269,11 @@ in niri pulseaudio libnotify - swaylock + gtklock brightnessctl systemd ]; - # No waylandClientHardening: swaylock is a child of this unit and + # No waylandClientHardening: the locker is a child of this unit and # authenticates through the setuid-root unix_chkpwd. `systemd --user` builds # mount namespaces through an unprivileged user namespace, whose mounts are # nosuid — the wrapper loses setuid and the session is no longer unlockable.