feat: implement the managed Hyprland desktop with Kitty and stable updates

This commit is contained in:
Coding Agent
2026-09-05 05:32:34 +00:00
parent 366d19a6b5
commit cb003093c2
17 changed files with 1177 additions and 95 deletions
+12 -4
View File
@@ -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.
+24
View File
@@ -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; }
+126
View File
@@ -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" ];
};
};
};
}
+3
View File
@@ -12,6 +12,9 @@
./users.nix
./tools.nix
./network.nix
./desktop.nix
./apps.nix
./updates.nix
./neovim.nix
];
+63
View File
@@ -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;
}
+107
View File
@@ -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")
+380
View File
@@ -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 = "<i>Password</i>";
fail_text = "<i>Try again ($ATTEMPTS)</i>";
}
];
};
};
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\" })'";
}
];
};
};
};
}
Generated
+2 -2
View File
@@ -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"
}
},
+34 -3
View File
@@ -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"
'';
};
};
}
+85
View File
@@ -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,
})
+13
View File
@@ -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.
}
+38 -86
View File
@@ -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;
};
};
};
+59
View File
@@ -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.
+113
View File
@@ -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 <nixos-updater@localhost>"
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)
+43
View File
@@ -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;
};
};
}
+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" width="3840" height="2160" viewBox="0 0 3840 2160">
<title>Quiet orbit — original desktop wallpaper</title>
<defs>
<linearGradient id="night" x2="1" y2="1">
<stop stop-color="#11141c"/><stop offset=".6" stop-color="#171a27"/><stop offset="1" stop-color="#202039"/>
</linearGradient>
<radialGradient id="blue">
<stop stop-color="#35578a" stop-opacity=".55"/><stop offset="1" stop-color="#35578a" stop-opacity="0"/>
</radialGradient>
<radialGradient id="violet">
<stop stop-color="#625080" stop-opacity=".4"/><stop offset="1" stop-color="#625080" stop-opacity="0"/>
</radialGradient>
<linearGradient id="arc" x1="0" y1="1" x2="1" y2="0">
<stop stop-color="#3ddbd9" stop-opacity="0"/><stop offset=".5" stop-color="#78a9ff" stop-opacity=".48"/><stop offset="1" stop-color="#be95ff" stop-opacity=".12"/>
</linearGradient>
</defs>
<path fill="url(#night)" d="M0 0h3840v2160H0z"/>
<ellipse cx="2650" cy="850" rx="1560" ry="1160" fill="url(#blue)"/>
<ellipse cx="3350" cy="1820" rx="1480" ry="990" fill="url(#violet)"/>
<g fill="none" stroke="url(#arc)">
<ellipse cx="2860" cy="1430" rx="1180" ry="680" transform="rotate(-32 2860 1430)" stroke-width="2"/>
<ellipse cx="2860" cy="1430" rx="1320" ry="815" transform="rotate(-32 2860 1430)" stroke-width="1.5"/>
<ellipse cx="2860" cy="1430" rx="1460" ry="950" transform="rotate(-32 2860 1430)" stroke-width="1"/>
</g>
<path d="M0 1900C700 1520 1260 2140 2010 1820S3160 1340 3840 1780V2160H0Z" fill="#10131d" opacity=".45"/>
<circle cx="2475" cy="685" r="5" fill="#82e9de" opacity=".8"/>
<circle cx="3380" cy="845" r="3" fill="#a6c8ff" opacity=".6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+47
View File
@@ -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.";
}
];
}