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 @@ + + Quiet orbit — original desktop wallpaper + + + + + + + + + + + + + + + + + + + + + + + + + + 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."; + } + ]; +}