feat: implement the managed Hyprland desktop with Kitty and stable updates
This commit is contained in:
@@ -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
@@ -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; }
|
||||
@@ -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" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,9 @@
|
||||
./users.nix
|
||||
./tools.nix
|
||||
./network.nix
|
||||
./desktop.nix
|
||||
./apps.nix
|
||||
./updates.nix
|
||||
./neovim.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;
|
||||
}
|
||||
+107
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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 |
@@ -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.";
|
||||
}
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user