diff --git a/README.md b/README.md
index aeb56c2..70c2e50 100644
--- a/README.md
+++ b/README.md
@@ -8,10 +8,15 @@ Flat, explicit NixOS modules, with locked inputs. Required machine setup belongs
| `configuration.nix` | EC2 base, platform, Nix features, compatibility version, imports |
| `users.nix` | `dev`, SSH/sudo, Home Manager integration, workspace/repo ownership |
| `dev-authorized-keys` | Public SSH keys for `dev`—never private keys |
-| `tools.nix`, `colors.nix` | Zsh, Alacritty, Starship, fzf, Yazi, btop, Git policy and common CLI tools; shared readable palette |
+| `tools.nix`, `colors.nix` | Zsh, Kitty, Pi, Starship, fzf, Yazi, btop, Git policy and common CLI tools; shared readable palette |
+| `desktop.nix`, `hyprland.lua`, `anyrun.css`, `wallpaper.svg` | Hyprland/UWSM, bar, launcher, original wallpaper, notifications, lock/idle, PipeWire and desktop styling |
+| `apps.nix` | Firefox ESR, KeePassXC, Thunderbird, Steam, Element, Slack and Zathura |
+| `workstation.nix`, `nvidia.nix` | Separately selected physical-workstation/greeter and NVIDIA integration; not enabled on EC2 |
+| `updates.nix`, `update-system.sh` | Daily stable-input updates in an isolated Git worktree; validated commits, no forced reboot or GC |
+| `desktop-test.nix`, `desktop-test.py`, `update-test.py` | Disposable graphical audit and updater failure/concurrency tests |
| `network.nix` | systemd-resolved and network/WireGuard diagnostics; leaves interface management with the host |
| `neovim.nix` | Editor and deployment of the unchanged upstream dotfiles |
-| [DESKTOP.md](DESKTOP.md) | Dated Hyprland/component research and proposed desktop; not deployed configuration |
+| [DESKTOP.md](DESKTOP.md) | Historical component research; executable configuration is in the files above |
## Account
@@ -21,7 +26,9 @@ Flat, explicit NixOS modules, with locked inputs. Required machine setup belongs
- `/etc/nixos` is writable by `dev` through a native tmpfiles ownership rule, which does not follow store symlinks.
- Nix daemon access stays untrusted for ordinary use. Sudo is a separate, explicit administrative capability.
- The declared daily shell is Zsh, with completion, suggestions, highlighting, Starship, fzf (`Ctrl-R`, `Ctrl-T`, `Alt-C`), and zoxide (`z`, `zi`). Root/SSM shells are not changed.
-- Alacritty uses an opaque dark background, 13pt JetBrains Mono and generous padding. `Ctrl-Shift-+` / `Ctrl-Shift--` zoom its font; `Ctrl-Shift-0` resets it. `y` opens Yazi with shell-directory integration.
+- Kitty uses an opaque dark background, 13pt JetBrains Mono and generous padding. `Ctrl-Shift-+` / `Ctrl-Shift--` zoom its font; `Ctrl-Shift-Backspace` resets it. `y` opens Yazi with shell-directory integration.
+- Desktop keys: `Super-Enter` Kitty, `Super-Space` launcher, `Super-E` Yazi, `Super-B` Firefox, `Super-P` KeePassXC, `Super-Escape` lock, `Super-Q` close, `Super-1…0` workspaces, `Print` screenshot/annotation. Launch the **Hyprland (uwsm-managed)** session.
+- KeePassXC preferences are seeded once and remain writable. Open/create your own vault and pair its browser extension; no vault, account or VPN credentials are embedded. The physical-workstation module requires a securely provisioned `hashedPasswordFile`; EC2 gets no greeter or autologin.
- `wg` and `wg-quick` are installed without any tunnels, peers, keys or added firewall ports. `resolvectl` is backed by resolved; DHCP remains under the existing host network manager.
Enter from an administrator session with `sudo -iu dev`.
@@ -81,7 +88,8 @@ Make focused changes, format/evaluate them, and commit regularly. Build and acti
## Updates and safety
-- To change a pinned source, update its revision in `flake.nix`, regenerate `flake.lock` with `nix flake lock`, review the diff, then check/build before switching. No unattended updates.
+- `nixos-update.timer` checks daily with up to one hour of jitter. Only the stable Nixpkgs/Home Manager branches advance; Neovim's source stays fixed. The updater skips a dirty repository, builds in a detached worktree, records a tested commit and applies it without rebooting. It attempts rollback if activation fails and retains recovery generations. Inspect `journalctl -u nixos-update` or trigger it with `sudo systemctl start nixos-update`.
+- For a manual input refresh: `nix flake update nixpkgs home-manager`, review `flake.lock`, then check/build. The exact resolved revisions and hashes remain committed.
- Keep the EC2 module, sandboxing, signature verification and recovery access intact.
- `system.stateVersion` and `home.stateVersion` are both `26.05`; these preserve compatibility, not package versions.
- `test` activates changes too; it is not a dry run. Keep the original system generation.
diff --git a/anyrun.css b/anyrun.css
new file mode 100644
index 0000000..ef776c7
--- /dev/null
+++ b/anyrun.css
@@ -0,0 +1,24 @@
+* { font-family: Inter, sans-serif; font-size: 17px; }
+window { background: transparent; }
+box.main {
+ padding: 14px;
+ margin: 18px;
+ border-radius: 16px;
+ border: 2px solid #78a9ff;
+ background: #161616;
+ box-shadow: 0 8px 18px rgba(0, 0, 0, .35);
+}
+text {
+ color: #f2f4f8;
+ background: #262626;
+ min-height: 38px;
+ padding: 10px 14px;
+ border-radius: 9px;
+}
+.matches, list.plugin { background: transparent; }
+box.plugin:first-child { margin-top: 10px; }
+.match { padding: 9px; border-radius: 9px; background: transparent; }
+.match:selected { background: #2e3f5f; }
+label.match { color: #f2f4f8; }
+label.match.description { font-size: 14px; color: #a2a9b0; }
+label.plugin.info { color: #a2a9b0; }
diff --git a/apps.nix b/apps.nix
new file mode 100644
index 0000000..be3240a
--- /dev/null
+++ b/apps.nix
@@ -0,0 +1,126 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+
+let
+ c = import ./colors.nix;
+ browserAddon = pkgs.fetchurl {
+ name = "keepassxc-browser-1.10.3.xpi";
+ url = "https://addons.mozilla.org/firefox/downloads/file/4831838/keepassxc_browser-1.10.3.xpi";
+ sha256 = "4df9c54e0a293aa2e37e9bcf97ec307a35a00e78cebaa9a68edba552c07c4568";
+ };
+ keepassDefaults = (pkgs.formats.ini { }).generate "keepassxc-defaults.ini" {
+ General = {
+ ConfigVersion = 2;
+ UpdateCheckMessageShown = true;
+ };
+ GUI = {
+ ApplicationTheme = "dark";
+ ShowTrayIcon = true;
+ MinimizeToTray = true;
+ MinimizeOnStartup = true;
+ };
+ Browser = {
+ Enabled = true;
+ UpdateBinaryPath = false;
+ };
+ Security.LockDatabaseScreenLock = true;
+ FdoSecrets.Enabled = true;
+ };
+in
+{
+ nixpkgs.config.allowUnfreePredicate =
+ pkg:
+ builtins.elem (lib.getName pkg) (
+ [
+ "steam"
+ "steam-unwrapped"
+ "steam-run"
+ "steam-original"
+ "slack"
+ ]
+ ++ lib.optionals (builtins.elem "nvidia" config.services.xserver.videoDrivers) [
+ "nvidia-x11"
+ "nvidia-settings"
+ "nvidia-persistenced"
+ ]
+ );
+ programs.steam = {
+ enable = true;
+ remotePlay.openFirewall = false;
+ dedicatedServer.openFirewall = false;
+ localNetworkGameTransfers.openFirewall = false;
+ };
+ programs.firefox = {
+ enable = true;
+ # Stable's ESR is security-current (153.2); regular 155.0 is one patch behind.
+ package = pkgs.firefox-esr;
+ nativeMessagingHosts.packages = [ pkgs.keepassxc ];
+ policies = {
+ DisableTelemetry = true;
+ OfferToSaveLogins = false;
+ ExtensionSettings."keepassxc-browser@keepassxc.org" = {
+ installation_mode = "normal_installed";
+ install_url = "file://${browserAddon}";
+ };
+ };
+ };
+ services.dbus.packages = [ pkgs.keepassxc ];
+
+ home-manager.users.dev = {
+ home.packages = with pkgs; [
+ thunderbird
+ element-desktop
+ slack
+ ];
+ programs.keepassxc = {
+ enable = true;
+ autostart = true;
+ };
+ # C copies only when absent. Preferences stay writable; vaults, browser
+ # association and the Secret Service exposed group remain user-controlled.
+ systemd.user.tmpfiles.rules = [
+ "d %h/.config/keepassxc 0700 - - -"
+ "C %h/.config/keepassxc/keepassxc.ini 0600 - - - ${keepassDefaults}"
+ ];
+ programs.zathura = {
+ enable = true;
+ options = {
+ font = "Inter 12";
+ adjust-open = "best-fit";
+ zoom-step = 10;
+ recolor = false; # Preserve actual document colors; Ctrl-R toggles recolor.
+ default-bg = c.background;
+ default-fg = c.text;
+ statusbar-bg = c.surface;
+ statusbar-fg = c.text;
+ inputbar-bg = c.surface;
+ inputbar-fg = c.text;
+ completion-bg = c.background;
+ completion-fg = c.text;
+ completion-highlight-bg = "#354562";
+ completion-highlight-fg = c.text;
+ notification-bg = c.surface;
+ notification-fg = c.text;
+ notification-error-bg = c.red;
+ notification-error-fg = c.background;
+ recolor-darkcolor = c.text;
+ recolor-lightcolor = c.background;
+ };
+ };
+ xdg.mimeApps = {
+ enable = true;
+ defaultApplications = {
+ "application/pdf" = [ "org.pwmt.zathura.desktop" ];
+ "text/html" = [ "firefox-esr.desktop" ];
+ "x-scheme-handler/http" = [ "firefox-esr.desktop" ];
+ "x-scheme-handler/https" = [ "firefox-esr.desktop" ];
+ "x-scheme-handler/mailto" = [ "thunderbird.desktop" ];
+ "x-scheme-handler/matrix" = [ "element-desktop.desktop" ];
+ };
+ };
+ };
+}
diff --git a/configuration.nix b/configuration.nix
index 404fd81..fbea355 100644
--- a/configuration.nix
+++ b/configuration.nix
@@ -12,6 +12,9 @@
./users.nix
./tools.nix
./network.nix
+ ./desktop.nix
+ ./apps.nix
+ ./updates.nix
./neovim.nix
];
diff --git a/desktop-test.nix b/desktop-test.nix
new file mode 100644
index 0000000..d54b61d
--- /dev/null
+++ b/desktop-test.nix
@@ -0,0 +1,63 @@
+# Disposable graphical audit. Test credentials/autologin NEVER reach the host.
+{ pkgs, inputs }:
+pkgs.testers.runNixOSTest {
+ name = "development-desktop";
+ node.pkgsReadOnly = false;
+ node.specialArgs = { inherit inputs; };
+ requiredFeatures.kvm = false;
+ qemu.package = pkgs.qemu;
+ qemu.forceAccel = false;
+
+ nodes.machine = { pkgs, ... }: {
+ imports = [
+ inputs.home-manager.nixosModules.home-manager
+ ./users.nix
+ ./tools.nix
+ ./network.nix
+ ./neovim.nix
+ ./desktop.nix
+ ./apps.nix
+ ./workstation.nix
+ ];
+ system.stateVersion = "26.05";
+ virtualisation = {
+ memorySize = 6144;
+ cores = 4;
+ resolution = {
+ x = 1920;
+ y = 1080;
+ };
+ qemu.options = [
+ "-vga none"
+ "-device virtio-gpu-pci,xres=1920,yres=1080"
+ ];
+ };
+ environment.sessionVariables.LIBGL_ALWAYS_SOFTWARE = "1";
+ environment.systemPackages = [ pkgs.python3 ];
+ users.users.dev.hashedPasswordFile = toString (
+ pkgs.runCommand "test-only-password-hash" { nativeBuildInputs = [ pkgs.mkpasswd ]; } ''
+ mkpasswd --method=sha-512 --salt=nixostest desktop-test > "$out"
+ ''
+ );
+ services.greetd.settings.initial_session = {
+ user = "dev";
+ command = "${pkgs.uwsm}/bin/uwsm start -e -D Hyprland hyprland.desktop";
+ };
+ services.pipewire.extraConfig.pipewire."99-test-audio"."context.objects" = [
+ {
+ factory = "adapter";
+ args = {
+ "factory.name" = "support.null-audio-sink";
+ "node.name" = "test-speakers";
+ "node.description" = "Test speakers";
+ "media.class" = "Audio/Sink";
+ "audio.position" = [
+ "FL"
+ "FR"
+ ];
+ };
+ }
+ ];
+ };
+ testScript = builtins.readFile ./desktop-test.py;
+}
diff --git a/desktop-test.py b/desktop-test.py
new file mode 100644
index 0000000..deda192
--- /dev/null
+++ b/desktop-test.py
@@ -0,0 +1,107 @@
+import json
+import shlex
+
+
+def user(command):
+ return machine.succeed(
+ "runuser -u dev -- env XDG_RUNTIME_DIR=/run/user/1001 "
+ "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1001/bus "
+ "bash -lc " + shlex.quote(command)
+ )
+
+
+def session(command):
+ return user("systemd-run --quiet --user --wait --pipe --collect sh -c " + shlex.quote(command))
+
+
+def launch(name, command):
+ user("systemd-run --quiet --user --collect --unit=audit-" + name + " " + command)
+
+
+machine.start()
+machine.wait_for_unit("home-manager-dev.service", timeout=360)
+try:
+ machine.wait_until_succeeds(
+ "runuser -u dev -- env XDG_RUNTIME_DIR=/run/user/1001 "
+ "systemctl --user is-active graphical-session.target", timeout=180
+ )
+ # Mako is D-Bus activated on the first notification, not eagerly started.
+ for unit in ["ashell", "awww", "hypridle", "hyprpolkitagent", "pipewire", "wireplumber"]:
+ machine.wait_until_succeeds(
+ "runuser -u dev -- env XDG_RUNTIME_DIR=/run/user/1001 "
+ "systemctl --user is-active " + unit + ".service", timeout=60
+ )
+except Exception:
+ print(machine.succeed("journalctl -b --no-pager _UID=1001"))
+ print(machine.execute("find /home/dev/.cache/hyprland -type f -maxdepth 2 -exec tail -n 100 {} ';'"))
+ machine.screenshot("startup-failed")
+ raise
+
+machine.screenshot("startup")
+assert session("hyprctl configerrors").strip() in ("", "ok")
+assert "JetBrainsMono" in user("fc-match 'JetBrainsMono Nerd Font'")
+assert "Inter" in user("fc-match Inter")
+assert "0." in user("pi --version")
+assert "zsh" in user("getent passwd dev")
+assert "test-speakers" in session("wpctl status --name")
+machine.succeed("systemctl is-active systemd-resolved")
+
+launch("terminal", "kitty --title 'Workspace ready' sh -c " + shlex.quote(
+ "printf '\\n WORKSPACE READY\\n\\n'; "
+ "zsh --version; kitty --version; pi --version; git --version; "
+ "printf '\\n Ctrl-R history | Ctrl-T files | Alt-C directories\\n'; "
+ "printf ' Super-Space launcher | Super-Enter terminal\\n\\n'; exec zsh -i"
+))
+launch("monitor", "kitty --title 'System monitor' -e btop")
+machine.wait_until_succeeds("pgrep -u dev btop")
+machine.sleep(5)
+machine.screenshot("desktop-100")
+
+launch("launcher", "anyrun")
+machine.sleep(2)
+machine.send_chars("kitty")
+machine.sleep(2)
+machine.screenshot("launcher-100")
+machine.send_key("esc")
+session("notify-send 'Desktop ready' 'Readable text, working audio and native Wayland services.'")
+assert user("systemctl --user is-active mako").strip() == "active"
+machine.sleep(1)
+machine.screenshot("notification-100")
+
+monitors = json.loads(session("hyprctl -j monitors"))
+output = monitors[0]["name"]
+session("hyprctl eval " + shlex.quote(
+ 'hl.monitor({output=' + json.dumps(output) + ',mode="1920x1080@60",position="0x0",scale=1.5})'
+))
+machine.sleep(4)
+assert json.loads(session("hyprctl -j monitors"))[0]["scale"] == 1.5
+machine.screenshot("desktop-150")
+
+# Exercise real PAM/session locking using the disposable fixture password.
+session("loginctl lock-session")
+machine.wait_until_succeeds("pgrep -u dev hyprlock")
+machine.sleep(2)
+machine.screenshot("lock-150")
+machine.send_chars("incorrect")
+machine.send_key("ret")
+machine.sleep(3)
+machine.succeed("pgrep -u dev hyprlock")
+machine.send_chars("desktop-test")
+machine.send_key("ret")
+machine.wait_until_fails("pgrep -u dev hyprlock", timeout=30)
+
+# Restore scale before inspecting settings and ordinary application windows.
+session("hyprctl eval " + shlex.quote(
+ 'hl.monitor({output=' + json.dumps(output) + ',mode="1920x1080@60",position="0x0",scale=1})'
+))
+launch("audio", "pavucontrol")
+machine.sleep(3)
+machine.screenshot("audio-controls")
+
+# Preferences must not be a read-only Home Manager symlink.
+user("test -w ~/.config/keepassxc/keepassxc.ini && test ! -L ~/.config/keepassxc/keepassxc.ini")
+user("grep -q 'UpdateBinaryPath=false' ~/.config/keepassxc/keepassxc.ini")
+assert "libapplications.so" in user("cat ~/.config/anyrun/config.ron")
+session("hyprctl clients")
+machine.succeed("journalctl -b -p err --no-pager > /tmp/desktop-errors.log")
+machine.copy_from_vm("/tmp/desktop-errors.log")
diff --git a/desktop.nix b/desktop.nix
new file mode 100644
index 0000000..fb1d85a
--- /dev/null
+++ b/desktop.nix
@@ -0,0 +1,380 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+
+let
+ c = import ./colors.nix;
+ wallpaper = pkgs.runCommand "quiet-orbit.png" { nativeBuildInputs = [ pkgs.resvg ]; } ''
+ resvg ${./wallpaper.svg} "$out"
+ '';
+ screenshot = pkgs.writeShellApplication {
+ name = "desktop-screenshot";
+ runtimeInputs = with pkgs; [
+ grim
+ slurp
+ satty
+ coreutils
+ ];
+ text = ''
+ geometry=$(slurp) || exit 0
+ [ -n "$geometry" ] || exit 0
+ mkdir -p "$HOME/Pictures/Screenshots"
+ grim -g "$geometry" - | satty --filename - --copy-command wl-copy \
+ --output-filename "$HOME/Pictures/Screenshots/$(date +%Y-%m-%d_%H-%M-%S).png"
+ '';
+ };
+ launcherExec = pkgs.writeShellScript "anyrun-uwsm" ''
+ kind=$1
+ shift
+ if [ "$kind" = term ]; then
+ printf '%s\n' "$*"
+ else
+ printf 'uwsm app -- %s\n' "$*"
+ fi
+ '';
+ networkManager = config.networking.networkmanager.enable;
+ bluetooth = config.hardware.bluetooth.enable;
+ power = config.services.upower.enable;
+in
+{
+ programs.hyprland = {
+ enable = true;
+ withUWSM = true;
+ };
+ programs.hyprlock.enable = true;
+ services.hypridle.enable = true;
+ hardware.graphics.enable = true;
+ security.polkit.enable = true;
+ security.rtkit.enable = true;
+ programs.dconf.enable = true;
+
+ services.pipewire = {
+ enable = true;
+ alsa.enable = true;
+ alsa.support32Bit = true;
+ pulse.enable = true;
+ wireplumber.enable = true;
+ };
+
+ # NixOS's Hyprland module already installs the Hyprland and GTK portals.
+ xdg.portal.config.hyprland = {
+ default = [
+ "hyprland"
+ "gtk"
+ ];
+ "org.freedesktop.impl.portal.FileChooser" = [ "gtk" ];
+ };
+ environment.sessionVariables.NIXOS_OZONE_WL = "1";
+ environment.systemPackages = with pkgs; [
+ wl-clipboard
+ grim
+ slurp
+ satty
+ screenshot
+ pavucontrol
+ playerctl
+ brightnessctl
+ xdg-utils
+ libnotify
+ ];
+ services.udev.packages = [ pkgs.brightnessctl ];
+ fonts.packages = with pkgs; [
+ inter
+ noto-fonts
+ noto-fonts-cjk-sans
+ noto-fonts-color-emoji
+ ];
+ fonts.fontconfig.defaultFonts = {
+ sansSerif = [
+ "Inter"
+ "Noto Sans"
+ ];
+ serif = [ "Noto Serif" ];
+ emoji = [ "Noto Color Emoji" ];
+ };
+
+ home-manager.users.dev = {
+ xdg.enable = true;
+ xdg.userDirs = {
+ enable = true;
+ createDirectories = true;
+ };
+ xdg.autostart.enable = true;
+ home.pointerCursor = {
+ package = pkgs.bibata-cursors;
+ name = "Bibata-Modern-Ice";
+ size = 24;
+ gtk.enable = true;
+ x11.enable = true;
+ };
+ gtk = {
+ enable = true;
+ font = {
+ name = "Inter";
+ size = 11;
+ };
+ theme = {
+ name = "Adwaita-dark";
+ package = pkgs.gnome-themes-extra;
+ };
+ iconTheme = {
+ name = "Papirus-Dark";
+ package = pkgs.papirus-icon-theme;
+ };
+ gtk3.extraConfig.gtk-application-prefer-dark-theme = true;
+ gtk4.extraConfig.gtk-application-prefer-dark-theme = true;
+ };
+ qt = {
+ enable = true;
+ platformTheme.name = "gtk3";
+ style = {
+ name = "adwaita-dark";
+ package = pkgs.adwaita-qt;
+ };
+ };
+ dconf.settings."org/gnome/desktop/interface" = {
+ color-scheme = "prefer-dark";
+ font-name = "Inter 11";
+ monospace-font-name = "JetBrainsMono Nerd Font 13";
+ };
+
+ wayland.windowManager.hyprland = {
+ enable = true;
+ package = null;
+ portalPackage = null;
+ systemd.enable = false; # UWSM owns the session and environment.
+ configType = "lua";
+ extraConfig = builtins.readFile ./hyprland.lua;
+ };
+ services.hyprpolkitagent.enable = true;
+ services.awww.enable = true;
+ systemd.user.services.awww.Service = {
+ Type = "notify"; # awww 0.12 signals socket readiness; no guessed sleep.
+ ExecStartPost = "${pkgs.awww}/bin/awww img ${wallpaper} --transition-type fade --transition-duration 0.5";
+ };
+
+ programs.ashell = {
+ enable = true;
+ systemd.enable = true;
+ settings = {
+ log_level = "warn";
+ position = "Top";
+ modules = {
+ left = [
+ [
+ "appLauncher"
+ "Workspaces"
+ ]
+ ];
+ center = [ "Tempo" ];
+ right = [
+ "SystemInfo"
+ [
+ "Tray"
+ "Privacy"
+ "Settings"
+ ]
+ ];
+ };
+ CustomModule = [
+ {
+ name = "appLauncher";
+ icon = "";
+ command = "uwsm app -- anyrun";
+ }
+ ];
+ tempo.clock_format = "%a %d %b %H:%M";
+ workspaces = {
+ visibility_mode = "MonitorSpecific";
+ enable_workspace_filling = true;
+ };
+ system_info = {
+ indicators = [
+ "Cpu"
+ "Memory"
+ ];
+ interval = 5;
+ };
+ settings = {
+ lock_cmd = "loginctl lock-session";
+ logout_cmd = "uwsm stop";
+ audio_sinks_more_cmd = "uwsm app -- pavucontrol -t 3";
+ audio_sources_more_cmd = "uwsm app -- pavucontrol -t 4";
+ wifi_more_cmd = "uwsm app -- nm-connection-editor";
+ vpn_more_cmd = "uwsm app -- nm-connection-editor";
+ bluetooth_more_cmd = "uwsm app -- blueman-manager";
+ indicators = [
+ "IdleInhibitor"
+ "Audio"
+ "Microphone"
+ ]
+ ++ lib.optionals networkManager [
+ "Network"
+ "Vpn"
+ ]
+ ++ lib.optionals bluetooth [ "Bluetooth" ]
+ ++ lib.optionals power [
+ "PowerProfile"
+ "Battery"
+ "Brightness"
+ ];
+ audio_indicator_format = "IconAndPercentage";
+ };
+ appearance = {
+ font_name = "Inter";
+ scale_factor = 1.15;
+ style = "Islands";
+ opacity = 1.0;
+ primary_color = c.blue;
+ text_color = c.text;
+ success_color = c.green;
+ danger_color = c.red;
+ background_color = {
+ base = c.background;
+ weak = c.surface;
+ strong = c.raised;
+ };
+ secondary_color.base = c.surface;
+ workspace_colors = [
+ c.blue
+ c.purple
+ c.cyan
+ ];
+ };
+ };
+ };
+ systemd.user.services.ashell.Unit.PartOf = [ "graphical-session.target" ];
+
+ programs.anyrun = {
+ enable = true;
+ config = {
+ plugins = [
+ "${pkgs.anyrun}/lib/libapplications.so"
+ "${pkgs.anyrun}/lib/librink.so"
+ ];
+ width.fraction = 0.42;
+ y.fraction = 0.2;
+ hidePluginInfo = true;
+ closeOnClick = true;
+ maxEntries = 8;
+ };
+ extraCss = builtins.readFile ./anyrun.css;
+ extraConfigFiles."applications.ron".text = ''
+ (
+ desktop_actions: false,
+ max_entries: 8,
+ terminal: Some((command: "uwsm", args: "app -- kitty -e {}")),
+ preprocess_exec_script: Some("${launcherExec}"),
+ )
+ '';
+ };
+
+ services.mako = {
+ enable = true;
+ settings = {
+ font = "Inter 11";
+ width = 360;
+ height = 160;
+ margin = "12";
+ padding = "14";
+ border-size = 2;
+ border-radius = 12;
+ background-color = c.background;
+ text-color = c.text;
+ border-color = c.border;
+ default-timeout = 6000;
+ max-visible = 4;
+ max-icon-size = 48;
+ "urgency=critical" = {
+ border-color = c.red;
+ default-timeout = 0;
+ };
+ };
+ };
+ programs.hyprlock = {
+ enable = true;
+ package = null;
+ settings = {
+ general = {
+ hide_cursor = true;
+ ignore_empty_input = true;
+ };
+ background = [
+ {
+ monitor = "";
+ path = "${wallpaper}";
+ blur_passes = 2;
+ }
+ ];
+ label = [
+ {
+ monitor = "";
+ text = "cmd[update:1000] date +'%H:%M'";
+ font_size = 84;
+ font_family = "Inter";
+ color = "rgb(f2f4f8)";
+ position = "0,100";
+ halign = "center";
+ valign = "center";
+ }
+ {
+ monitor = "";
+ text = "$USER";
+ font_size = 18;
+ font_family = "Inter";
+ color = "rgb(a2a9b0)";
+ position = "0,0";
+ halign = "center";
+ valign = "center";
+ }
+ ];
+ input-field = [
+ {
+ monitor = "";
+ size = "320,60";
+ position = "0,-100";
+ halign = "center";
+ valign = "center";
+ outline_thickness = 2;
+ dots_center = true;
+ font_family = "Inter";
+ inner_color = "rgb(262626)";
+ outer_color = "rgb(78a9ff)";
+ font_color = "rgb(f2f4f8)";
+ check_color = "rgb(3ddbd9)";
+ fail_color = "rgb(ff6b7a)";
+ capslock_color = "rgb(f1c21b)";
+ placeholder_text = "Password";
+ fail_text = "Try again ($ATTEMPTS)";
+ }
+ ];
+ };
+ };
+ services.hypridle = {
+ enable = true;
+ package = null; # NixOS owns the service and PAM integration.
+ settings = {
+ general = {
+ lock_cmd = "pidof hyprlock || hyprlock";
+ before_sleep_cmd = "loginctl lock-session";
+ after_sleep_cmd = "hyprctl dispatch 'hl.dsp.dpms({ action = \"enable\" })'";
+ inhibit_sleep = 3;
+ };
+ listener = [
+ {
+ timeout = 600;
+ on-timeout = "loginctl lock-session";
+ }
+ {
+ timeout = 900;
+ on-timeout = "hyprctl dispatch 'hl.dsp.dpms({ action = \"disable\" })'";
+ on-resume = "hyprctl dispatch 'hl.dsp.dpms({ action = \"enable\" })'";
+ }
+ ];
+ };
+ };
+ };
+}
diff --git a/flake.lock b/flake.lock
index fba0333..8ca1bcd 100644
--- a/flake.lock
+++ b/flake.lock
@@ -16,8 +16,8 @@
},
"original": {
"owner": "nix-community",
+ "ref": "release-26.05",
"repo": "home-manager",
- "rev": "65258d5c65a250189fde2e35f490d15e064c4c62",
"type": "github"
}
},
@@ -50,8 +50,8 @@
},
"original": {
"owner": "NixOS",
+ "ref": "nixos-26.05",
"repo": "nixpkgs",
- "rev": "a5cc6f2c37bf518436dc8d1c288ccd0c43c2f4c4",
"type": "github"
}
},
diff --git a/flake.nix b/flake.nix
index ac42955..57ef7d8 100644
--- a/flake.nix
+++ b/flake.nix
@@ -2,11 +2,11 @@
description = "Development host";
inputs = {
- # Reviewed stable 26.05 snapshot; update deliberately, not at boot/login.
- nixpkgs.url = "github:NixOS/nixpkgs/a5cc6f2c37bf518436dc8d1c288ccd0c43c2f4c4";
+ # Release branches may advance; flake.lock records every exact snapshot.
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
home-manager = {
- url = "github:nix-community/home-manager/65258d5c65a250189fde2e35f490d15e064c4c62";
+ url = "github:nix-community/home-manager/release-26.05";
inputs.nixpkgs.follows = "nixpkgs";
};
@@ -28,5 +28,36 @@
};
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt;
+
+ nixosModules = {
+ desktop = ./desktop.nix;
+ apps = ./apps.nix;
+ tools = ./tools.nix;
+ network = ./network.nix;
+ workstation = ./workstation.nix;
+ nvidia = ./nvidia.nix;
+ };
+ checks.x86_64-linux =
+ let
+ pkgs = nixpkgs.legacyPackages.x86_64-linux;
+ in
+ {
+ desktop = import ./desktop-test.nix { inherit inputs pkgs; };
+ updates =
+ pkgs.runCommand "update-workflow-check"
+ {
+ nativeBuildInputs = with pkgs; [
+ python3
+ git
+ bash
+ coreutils
+ util-linux
+ ];
+ }
+ ''
+ python ${./update-test.py} ${./update-system.sh}
+ touch "$out"
+ '';
+ };
};
}
diff --git a/hyprland.lua b/hyprland.lua
new file mode 100644
index 0000000..5d55cb9
--- /dev/null
+++ b/hyprland.lua
@@ -0,0 +1,85 @@
+-- Native Hyprland 0.55 configuration, deployed by Home Manager.
+-- Services belong to UWSM/systemd, never a second exec-once process tree.
+hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
+
+hl.config({
+ general = {
+ gaps_in = 6, gaps_out = 12, border_size = 2,
+ layout = "dwindle", resize_on_border = true, allow_tearing = false,
+ col = {
+ active_border = { colors = { "rgb(78a9ff)", "rgb(be95ff)" }, angle = 45 },
+ inactive_border = "rgb(393939)",
+ },
+ },
+ decoration = {
+ rounding = 12,
+ active_opacity = 1.0, inactive_opacity = 1.0,
+ shadow = { enabled = true, range = 16, render_power = 3, color = 0x55000000 },
+ blur = { enabled = false },
+ },
+ animations = { enabled = true },
+ dwindle = { preserve_split = true },
+ input = {
+ kb_layout = "us", follow_mouse = 1,
+ repeat_rate = 35, repeat_delay = 250,
+ touchpad = { natural_scroll = true, tap_to_click = true },
+ },
+ misc = { disable_hyprland_logo = true, force_default_wallpaper = 0 },
+})
+hl.env("XCURSOR_SIZE", "24")
+hl.env("HYPRCURSOR_SIZE", "24")
+
+hl.curve("settle", { type = "bezier", points = { { 0.2, 0.85 }, { 0.25, 1 } } })
+hl.animation({ leaf = "global", enabled = true, speed = 2.5, bezier = "settle" })
+hl.animation({ leaf = "windowsIn", enabled = true, speed = 2.5, bezier = "settle", style = "popin 97%" })
+hl.animation({ leaf = "windowsOut", enabled = true, speed = 1.5, bezier = "settle", style = "popin 97%" })
+hl.animation({ leaf = "workspaces", enabled = true, speed = 2.5, bezier = "settle", style = "slidefade 12%" })
+hl.animation({ leaf = "layers", enabled = true, speed = 2, bezier = "settle", style = "fade" })
+hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
+
+local function app(command) return hl.dsp.exec_cmd("uwsm app -- " .. command) end
+hl.bind("SUPER + Return", app("kitty"))
+hl.bind("SUPER + Space", app("anyrun"))
+hl.bind("SUPER + E", app("kitty --class files -e yazi"))
+hl.bind("SUPER + B", app("firefox-esr"))
+hl.bind("SUPER + P", app("keepassxc"))
+hl.bind("SUPER + Escape", hl.dsp.exec_cmd("loginctl lock-session"))
+hl.bind("SUPER + Q", hl.dsp.window.close())
+hl.bind("SUPER + V", hl.dsp.window.float({ action = "toggle" }))
+hl.bind("SUPER + F", hl.dsp.window.fullscreen())
+hl.bind("SUPER + J", hl.dsp.layout("togglesplit"))
+hl.bind("SUPER + N", hl.dsp.exec_cmd("makoctl dismiss"))
+hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("makoctl restore"))
+hl.bind("Print", hl.dsp.exec_cmd("desktop-screenshot"))
+
+for _, direction in ipairs({ "left", "right", "up", "down" }) do
+ hl.bind("SUPER + " .. direction, hl.dsp.focus({ direction = direction }))
+ hl.bind("SUPER + SHIFT + " .. direction, hl.dsp.window.move({ direction = direction }))
+end
+for i = 1, 10 do
+ local key = i % 10
+ hl.bind("SUPER + " .. key, hl.dsp.focus({ workspace = i }))
+ hl.bind("SUPER + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }))
+end
+hl.bind("SUPER + S", hl.dsp.workspace.toggle_special("scratch"))
+hl.bind("SUPER + SHIFT + S", hl.dsp.window.move({ workspace = "special:scratch" }))
+hl.bind("SUPER + mouse_down", hl.dsp.focus({ workspace = "e+1" }))
+hl.bind("SUPER + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
+hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true })
+hl.bind("SUPER + mouse:273", hl.dsp.window.resize(), { mouse = true })
+
+hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"), { locked = true, repeating = true })
+hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { locked = true, repeating = true })
+hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true })
+hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true })
+hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl set +5%"), { locked = true, repeating = true })
+hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl --min-value=1 set 5%-"), { locked = true, repeating = true })
+hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
+hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
+hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
+
+hl.window_rule({ name = "ignore-maximize", match = { class = ".*" }, suppress_event = "maximize" })
+hl.window_rule({
+ name = "fix-xwayland-drag", match = { class = "^$", title = "^$", xwayland = true, float = true, fullscreen = false, pin = false },
+ no_focus = true,
+})
diff --git a/nvidia.nix b/nvidia.nix
new file mode 100644
index 0000000..74e0617
--- /dev/null
+++ b/nvidia.nix
@@ -0,0 +1,13 @@
+# Opt in only on a supported physical NVIDIA machine, not EC2/Mesa targets.
+{ config, ... }:
+{
+ services.xserver.videoDrivers = [ "nvidia" ];
+ hardware.nvidia = {
+ modesetting.enable = true;
+ open = true; # Turing or newer; select deliberately for the real hardware.
+ package = config.boot.kernelPackages.nvidiaPackages.stable;
+ powerManagement.enable = true;
+ nvidiaSettings = true;
+ };
+ # No global renderer overrides or Firefox sandbox-disabling variables.
+}
diff --git a/tools.nix b/tools.nix
index 72cb842..04f5d3b 100644
--- a/tools.nix
+++ b/tools.nix
@@ -9,6 +9,7 @@ in
environment.systemPackages = with pkgs; [
nixfmt
+ pi-coding-agent
ripgrep
fd
eza
@@ -155,94 +156,45 @@ in
};
};
- programs.alacritty = {
+ programs.kitty = {
enable = true;
+ font = {
+ name = "JetBrainsMono Nerd Font";
+ size = 13;
+ };
settings = {
- window = {
- padding = {
- x = 16;
- y = 12;
- };
- dynamic_padding = true;
- decorations = "None";
- opacity = 1.0;
- };
- font = {
- normal = {
- family = "JetBrainsMono Nerd Font";
- style = "Regular";
- };
- bold = {
- family = "JetBrainsMono Nerd Font";
- style = "Bold";
- };
- italic = {
- family = "JetBrainsMono Nerd Font";
- style = "Italic";
- };
- size = 13.0;
- offset.y = 2;
- };
- cursor = {
- style = {
- shape = "Beam";
- blinking = "Off";
- };
- unfocused_hollow = true;
- };
- scrolling.history = 20000;
- selection.save_to_clipboard = false;
- colors = {
- primary = {
- background = c.background;
- foreground = c.text;
- };
- cursor = {
- text = c.background;
- cursor = c.cyan;
- };
- selection = {
- text = c.text;
- background = "#354562";
- };
- normal = {
- black = c.surface;
- red = c.red;
- green = c.green;
- yellow = c.yellow;
- blue = c.blue;
- magenta = c.purple;
- cyan = c.cyan;
- white = "#dde1e6";
- };
- bright = {
- black = c.muted;
- red = "#ff99a0";
- green = "#6fdc8c";
- yellow = "#f7d75c";
- blue = "#a6c8ff";
- magenta = "#d4bbff";
- cyan = "#82e9de";
- white = c.text;
- };
- };
- keyboard.bindings = [
- {
- key = "Equals";
- mods = "Control|Shift";
- action = "IncreaseFontSize";
- }
- {
- key = "Minus";
- mods = "Control|Shift";
- action = "DecreaseFontSize";
- }
- {
- key = "Key0";
- mods = "Control|Shift";
- action = "ResetFontSize";
- }
- ];
+ window_padding_width = 14;
+ background_opacity = "1.0";
+ hide_window_decorations = true;
+ scrollback_lines = 20000;
+ enable_audio_bell = false;
+ cursor_shape = "beam";
+ cursor_blink_interval = 0;
+ adjust_line_height = "110%";
+ foreground = c.text;
+ background = c.background;
+ cursor = c.cyan;
+ selection_foreground = c.text;
+ selection_background = "#354562";
+ url_color = c.blue;
+ active_border_color = c.blue;
+ inactive_border_color = c.border;
+ color0 = c.surface;
+ color1 = c.red;
+ color2 = c.green;
+ color3 = c.yellow;
+ color4 = c.blue;
+ color5 = c.purple;
+ color6 = c.cyan;
+ color7 = "#dde1e6";
+ color8 = c.muted;
+ color9 = "#ff99a0";
+ color10 = "#6fdc8c";
+ color11 = "#f7d75c";
+ color12 = "#a6c8ff";
+ color13 = "#d4bbff";
+ color14 = "#82e9de";
+ color15 = c.text;
};
};
};
diff --git a/update-system.sh b/update-system.sh
new file mode 100644
index 0000000..bda4979
--- /dev/null
+++ b/update-system.sh
@@ -0,0 +1,59 @@
+# Run as dev; privileged activation uses the already declared scoped sudo rule.
+repo=${NIXOS_CONFIG_REPO:-/etc/nixos}
+state=${CACHE_DIRECTORY:-/var/cache/nixos-update}
+mkdir -p "$state"
+exec 9>"$state/lock"
+flock -n 9 || exit 0
+
+cd "$repo"
+if [ -n "$(git status --porcelain)" ]; then
+ echo 'Skipping automatic update: the configuration has local changes.'
+ exit 0
+fi
+baseline=$(git rev-parse HEAD)
+branch=$(git symbolic-ref HEAD)
+work=$(mktemp -d "$state/work.XXXXXXXX")
+cleanup() {
+ git -C "$repo" worktree remove --force "$work" >/dev/null 2>&1 || true
+ rm -rf -- "$work"
+}
+trap cleanup EXIT
+
+git worktree add --detach "$work" "$baseline"
+cd "$work"
+# Only these stable release inputs advance. Neovim's source stays pinned.
+nix flake update nixpkgs home-manager
+if git diff --quiet -- flake.lock; then
+ echo 'Stable inputs are already current.'
+ exit 0
+fi
+nix flake check --no-build --no-update-lock-file
+nix build .#checks.x86_64-linux.updates --no-update-lock-file --no-link
+git add flake.lock
+git -c user.name='NixOS Updater' -c user.email='nixos-updater@localhost' \
+ commit -m 'chore: update stable NixOS and Home Manager inputs'
+built=$(nix build .#nixosConfigurations.dev.config.system.build.toplevel \
+ --no-update-lock-file --no-link --print-out-paths)
+candidate=$(git rev-parse HEAD)
+
+# Never overwrite work started while the candidate was building.
+cd "$repo"
+if [ "$(git rev-parse HEAD)" != "$baseline" ] || \
+ [ "$(git symbolic-ref HEAD)" != "$branch" ] || \
+ [ -n "$(git status --porcelain)" ]; then
+ echo 'Configuration changed during the build; leaving it untouched.'
+ exit 0
+fi
+previous=$(readlink -f /run/current-system)
+sudo "$built/sw/bin/nixos-rebuild" dry-activate --no-reexec --store-path "$built"
+git merge --ff-only "$candidate"
+if ! sudo "$built/sw/bin/nixos-rebuild" switch --no-reexec --store-path "$built"; then
+ echo 'Activation failed; restoring the previous system. See the journal.' >&2
+ sudo "$previous/sw/bin/nixos-rebuild" switch --no-reexec --store-path "$previous"
+ if [ "$(git rev-parse HEAD)" = "$candidate" ] && [ -z "$(git status --porcelain)" ]; then
+ git -c user.name='NixOS Updater' -c user.email='nixos-updater@localhost' \
+ revert --no-edit "$candidate"
+ fi
+ exit 1
+fi
+# No forced reboot or garbage collection: recovery generations are retained.
diff --git a/update-test.py b/update-test.py
new file mode 100644
index 0000000..6617131
--- /dev/null
+++ b/update-test.py
@@ -0,0 +1,113 @@
+"""Test the real updater with real Git and mocked Nix/sudo; no host activation."""
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import tempfile
+
+script = Path(sys.argv[1]).resolve()
+python = sys.executable
+mock = r'''import json, os, pathlib, sys
+name = pathlib.Path(sys.argv[0]).name
+args = sys.argv[1:]
+scenario = os.environ["SCENARIO"]
+with open(os.environ["CALLS"], "a") as f:
+ f.write(json.dumps([name, *args]) + "\n")
+if name == "nix":
+ if args[:2] == ["flake", "update"]:
+ assert args[2:] == ["nixpkgs", "home-manager"]
+ if scenario != "unchanged":
+ pathlib.Path("flake.lock").write_text('{"revision":2}\n')
+ elif args[:2] == ["flake", "check"]:
+ if scenario == "evaluation-failure": sys.exit(42)
+ elif args[0] == "build":
+ if scenario == "build-failure": sys.exit(43)
+ if scenario == "concurrent-edit":
+ (pathlib.Path(os.environ["NIXOS_CONFIG_REPO"]) / "notes").write_text("user work\n")
+ print(os.environ["BUILT"])
+ else:
+ raise AssertionError(args)
+elif name == "sudo":
+ assert "--no-reexec" in args and "--store-path" in args
+ if scenario == "dry-activation-failure" and args[1] == "dry-activate": sys.exit(44)
+ if scenario == "activation-failure" and args[1] == "switch" and args[-1] == os.environ["BUILT"]:
+ sys.exit(45)
+elif name == "readlink":
+ assert args == ["-f", "/run/current-system"]
+ print(os.environ["PREVIOUS"])
+else:
+ raise AssertionError(name)
+'''
+
+
+def run_case(scenario):
+ with tempfile.TemporaryDirectory(prefix="update-test-") as directory:
+ root = Path(directory)
+ repo = root / "repo with spaces"
+ cache = root / "cache"
+ mocks = root / "bin"
+ repo.mkdir()
+ mocks.mkdir()
+ calls_path = root / "calls.jsonl"
+ env = dict(os.environ, HOME=str(root / "home"), CACHE_DIRECTORY=str(cache),
+ NIXOS_CONFIG_REPO=str(repo), SCENARIO=scenario,
+ CALLS=str(calls_path), BUILT=str(root / "candidate-system"),
+ PREVIOUS=str(root / "previous-system"),
+ GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null")
+ Path(env["HOME"]).mkdir()
+ for name in ["nix", "sudo", "readlink"]:
+ executable = mocks / name
+ executable.write_text("#!" + python + "\n" + mock)
+ executable.chmod(0o755)
+ env["PATH"] = str(mocks) + os.pathsep + os.environ["PATH"]
+
+ def git(*args):
+ return subprocess.check_output(
+ ["git", "-C", str(repo), "-c", "user.name=Update Test",
+ "-c", "user.email=update-test@localhost", *args], env=env, text=True
+ ).strip()
+
+ git("init", "--quiet", "--initial-branch=main")
+ (repo / "flake.lock").write_text('{"revision":1}\n')
+ (repo / "unchanged-editor-input").write_text("380eb86778a7c53a0f1c18e84f14037456155347\n")
+ git("add", ".")
+ git("commit", "--quiet", "-m", "fixture")
+ baseline = git("rev-parse", "HEAD")
+ if scenario == "dirty":
+ (repo / "notes").write_text("user work\n")
+ result = subprocess.run(
+ [shutil.which("bash"), "-euo", "pipefail", str(script)],
+ env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
+ )
+ calls = [json.loads(line) for line in calls_path.read_text().splitlines()] if calls_path.exists() else []
+ sudo = [call for call in calls if call[0] == "sudo"]
+ failed = scenario.endswith("failure")
+ assert (result.returncode != 0) == failed, (scenario, result.stdout)
+ if scenario == "success":
+ assert json.loads((repo / "flake.lock").read_text())["revision"] == 2
+ assert git("log", "-1", "--format=%an <%ae>") == "NixOS Updater "
+ assert [call[2] for call in sudo] == ["dry-activate", "switch"]
+ assert git("status", "--porcelain") == ""
+ elif scenario == "activation-failure":
+ assert json.loads((repo / "flake.lock").read_text())["revision"] == 1
+ assert [call[2] for call in sudo] == ["dry-activate", "switch", "switch"]
+ assert sudo[-1][-1] != env["BUILT"]
+ assert git("log", "-1", "--format=%s").startswith("Revert")
+ else:
+ assert git("rev-parse", "HEAD") == baseline, (scenario, result.stdout)
+ assert json.loads((repo / "flake.lock").read_text())["revision"] == 1
+ assert not sudo or scenario == "dry-activation-failure"
+ assert (repo / "unchanged-editor-input").read_text().strip() == "380eb86778a7c53a0f1c18e84f14037456155347"
+ assert len(git("worktree", "list", "--porcelain").split("worktree ")) == 2
+ if scenario in ["dirty", "concurrent-edit"]:
+ assert (repo / "notes").read_text() == "user work\n"
+ if scenario == "dirty":
+ assert not calls
+ print("PASS", scenario)
+
+
+for scenario in ["dirty", "unchanged", "evaluation-failure", "build-failure", "concurrent-edit",
+ "dry-activation-failure", "activation-failure", "success"]:
+ run_case(scenario)
diff --git a/updates.nix b/updates.nix
new file mode 100644
index 0000000..e1bb6ff
--- /dev/null
+++ b/updates.nix
@@ -0,0 +1,43 @@
+{ pkgs, ... }:
+let
+ updater = pkgs.writeShellApplication {
+ name = "update-system";
+ runtimeInputs = with pkgs; [
+ nix
+ git
+ coreutils
+ util-linux
+ ];
+ text = builtins.readFile ./update-system.sh;
+ };
+in
+{
+ environment.systemPackages = [ updater ];
+ systemd.tmpfiles.rules = [ "d /var/cache/nixos-update 0700 dev users -" ];
+ systemd.services.nixos-update = {
+ description = "Build, record and apply stable NixOS updates without disturbing local work";
+ wants = [ "network-online.target" ];
+ after = [ "network-online.target" ];
+ path = [ "/run/wrappers" ];
+ serviceConfig = {
+ Type = "oneshot";
+ User = "dev";
+ Group = "users";
+ WorkingDirectory = "/etc/nixos";
+ CacheDirectory = "nixos-update";
+ UMask = "0077";
+ Nice = 10;
+ IOSchedulingClass = "idle";
+ TimeoutStartSec = "2h";
+ ExecStart = "${updater}/bin/update-system";
+ };
+ };
+ systemd.timers.nixos-update = {
+ wantedBy = [ "timers.target" ];
+ timerConfig = {
+ OnCalendar = "daily";
+ RandomizedDelaySec = "1h";
+ Persistent = true;
+ };
+ };
+}
diff --git a/wallpaper.svg b/wallpaper.svg
new file mode 100644
index 0000000..22c4eb0
--- /dev/null
+++ b/wallpaper.svg
@@ -0,0 +1,28 @@
+
diff --git a/workstation.nix b/workstation.nix
new file mode 100644
index 0000000..40b26f1
--- /dev/null
+++ b/workstation.nix
@@ -0,0 +1,47 @@
+# Opt-in physical workstation integration. Never imported by the EC2 host.
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+{
+ networking.networkmanager.enable = true;
+ networking.dhcpcd.enable = false;
+ users.users.dev.extraGroups = [ "networkmanager" ];
+ hardware.bluetooth = {
+ enable = true;
+ powerOnBoot = false;
+ };
+ services.blueman.enable = true;
+ # Keep Blueman's manager/mechanism, not a second tray applet next to ashell.
+ home-manager.users.dev.xdg.configFile."autostart/blueman.desktop".text = ''
+ [Desktop Entry]
+ Type=Application
+ Name=Blueman
+ Hidden=true
+ '';
+ services.upower.enable = true;
+ services.power-profiles-daemon.enable = true;
+ services.udisks2.enable = true;
+ home-manager.users.dev.services.udiskie.enable = true;
+ environment.systemPackages = [ pkgs.networkmanagerapplet ];
+
+ programs.regreet = {
+ enable = true;
+ theme.name = "Adwaita-dark";
+ font = {
+ package = pkgs.inter;
+ name = "Inter";
+ size = 13;
+ };
+ settings.GTK.application_prefer_dark_theme = true;
+ };
+ # No autologin. The physical host must supply a secure credential file.
+ assertions = [
+ {
+ assertion = config.users.users.dev.hashedPasswordFile != null;
+ message = "workstation.nix requires users.users.dev.hashedPasswordFile (provision outside the Nix store); no production password is invented.";
+ }
+ ];
+}