feat: complete and visually audit the Hyprland workstation

Refresh system/tool pins, install official Element Nightly, expand development tools, and finish desktop workflows with a shared charcoal/gold design. Retain host-specific updates and NixOS recovery generations.
This commit is contained in:
OpenAI Coding Assistant
2026-09-05 23:22:27 -05:00
parent 35ac95651d
commit 527d989a01
26 changed files with 2043 additions and 447 deletions
+3 -1
View File
@@ -13,7 +13,9 @@
!/.env.example
/secrets/
# Editor temporary files.
# Python bytecode and editor temporary files.
__pycache__/
*.pyc
*.swp
*.swo
*~
+69 -53
View File
@@ -1,76 +1,92 @@
# Desktop research and proposed baseline
# Workstation design and visual audit
Researched **2026-09-05 UTC**. **This is a proposal, not an installed desktop.** Hyprland and Firefox are selected; the supporting applications below are recommendations for review. No desktop, authentication, driver or input-lock changes have been activated.
Reviewed 2026-09-06 UTC on the physical laptop and against the pinned upstream sources. JaKooLit is a **functional reference, not the visual template**. The goal is an informative, polished development workstation—not a sparse desktop and not a collection of theme selectors.
The comparison uses the current official NixOS Wiki, upstream documentation/release notes, project screenshots, and the actual package/module sources locked by this repository:
## Design
- Nixpkgs: `d57af924f160a5084293c71c2043f058bd1cdb60`.
- Home Manager: `65258d5c65a250189fde2e35f490d15e064c4c62`.
- Package versions below are **available in that pin**, not necessarily current upstream releases. Screenshots illustrate upstream styling, not a locally tested session or a promise that every pictured feature exists in our pin.
- Dark **One Ring** wallpaper, charcoal surfaces, parchment text and restrained old-gold accents. Terminal ANSI colors remain distinct; Neovim's theme and configuration are unchanged.
- One continuous top bar: launcher/workspaces/help and active-window context on the left, date/time in the center, media, CPU/RAM/temperature, notification count, privacy and laptop status on the right. Details and actions are available without filling the bar with permanent buttons.
- The bar's quick-settings panel owns audio, microphone, brightness, network/VPN, Bluetooth, power profiles and idle inhibition. Its **Actions** and **Health** buttons open the action palette and real system/update diagnostics.
- SwayNC owns notification history, actions, DND and media—not a duplicate hardware settings dashboard.
- Anyrun is the application/calculator launcher; Fuzzel provides consistent searchable action, clipboard, window and help pickers. Help is read from live described bindings and never executes a selected shortcut.
- Flat NixOS modules, Home Manager files, native Hyprland Lua, standard systemd services and small packaged helpers. No copied dotfile framework, downloaded login scripts, dynamic theme generators or additional recovery framework.
## Recommended small desktop
The laptop is **Intel Lunar Lake / Arc 130V140V**, using `xe`, with a **1920×1200 internal panel at 150%**. The CPU temperature source is configured on this host, not guessed for every machine. No NVIDIA configuration, driver replacement, DNS change or storage migration is part of this work.
| Role | Recommendation | Pinned version | Rationale / trade-off |
| --- | --- | --- | --- |
| Compositor/session | Hyprland + UWSM | 0.55.4 / 0.26.4 | Selected compositor; native NixOS session integration, with one owner for systemd startup/shutdown. |
| Browser | Firefox | 154.0.1 | Selected browser; keep its sandbox and ordinary browser configuration. Update freshness needs attention below. |
| Terminal | **Alacritty** (Rust) | 0.17.0 | Focused terminal with native Wayland support. No built-in tabs/splits; Hyprland already arranges windows. Ghostty 1.3.1 (Zig) is the richer alternative if terminal tabs, splits or image protocols are wanted. |
| Bar/quick settings | **ashell** (Rust/Iced) | 0.8.0 | Rounded, ready-made bar with workspaces, tray, audio controls and settings; avoids building a shell from widgets. Ironbar 0.19.0 (Rust/GTK4) is preferable for more bespoke panels/CSS. |
| Launcher | **Anyrun** (Rust/GTK4) | 25.12.0 | Application search with optional calculator/symbol plugins; enable only useful plugins, not indexing/network integrations by default. Fuzzel 1.14.1 (C) is the simpler alternative with a direct launch-prefix option. |
| Lock/idle | **hyprlock + hypridle** (C++) | 0.9.5 / 0.1.7 | Hyprland-native integration, PAM, session-lock protocol and lock-completion-aware sleep inhibition. Security and recovery matter more than implementation language here. |
| Notifications | **mako** (C) | 1.11.0 | Small, styleable notification daemon. SwayNotificationCenter 0.12.6 (Vala) adds a full notification drawer if that is wanted later. |
| Privilege prompts | **hyprpolkitagent** | 0.1.3 | Existing Home Manager session-bound integration. Soteria 0.3.1 is a genuine Rust alternative, but has session-registration considerations and a newer restart-related fix upstream. |
| Audio/screensharing | PipeWire + WirePlumber; Hyprland and GTK portals | — | Audio, screen capture and file-picker plumbing, not optional decoration. Use native NixOS modules. |
| Clipboard/screenshots | wl-clipboard; grim + slurp; **Satty** (Rust) for annotation | 2.3.0 / 1.5.0 / 1.5.0 / 0.20.1 | Normal copy/paste and area capture; annotation runs on demand. No persistent clipboard history by default. |
## What the audit repaired
Use a small font/icon set, restrained dark colors, modest rounding and short animations. Do not add a theme framework, custom shell framework, downloaded startup scripts, weather accounts or a second bar. This does not change the Neovim theme or any Neovim source.
1. **Session ownership:** the original SDDM selection started plain Hyprland without `graphical-session.target`; the configured bar, wallpaper, locker and polkit services consequently did not start. SDDM now offers only **Hyprland (uwsm-managed)**. Plasma and the `kbot` account are removed as requested; `/home/kbot` remains intact.
2. **Physical updates:** the laptop previously excluded the updater, whose script selected the EC2 host. Host selection is now explicit. Daily physical updates stage checked generations for the next boot; they do not log out the user or reboot.
3. **Shared styling:** native CSS and Lua are rendered from `colors.nix`, rather than keeping several unrelated palettes.
4. **Launcher geometry:** upstream gives the row, boxes, image and labels the same `.match` class. Applying padding to that class multiplied row height. Padding now applies only to `row.match`; application descriptions are hidden, results are bounded, and calculator results remain readable. A session-owned Anyrun daemon supports calculator clipboard output.
5. **Actual visual sizing:** screenshots led to a shorter/wider action picker, shorter help, smaller notification drawer, consistent borders, and removal of the thick upstream notification-focus background. Bar information was restored after an overly sparse iteration; information density is intentional.
6. **Duplicate/broken controls:** an unconfigured SwayNC backlight widget was present but uninitialized. Hardware controls now live in ashell's working native panel, not in both panels.
7. **Clipboard/lock behavior:** history is session-local; a clipboard write cannot race past the lock wipe, and a failed wipe cannot prevent the screen from locking. OSD notifications are transient and replace only other OSD messages, not screenshot/error notifications.
8. **Toolkit and apps:** the expanded toolset is declarative. Element is the official pinned Nightly, with its matching Electron/native modules and libsecret storage. Thunar, archives, image/video viewers and MIME defaults complement Yazi.
**Optional wallpaper tool:** [awww](https://codeberg.org/LGFae/awww), Rust, pinned 0.12.1, if image switching/transitions are wanted. The old `swww` GitHub repository is archived and explicitly redirects to this renamed project; Nixpkgs warns about the old attribute. A solid background does not need another daemon.
## Useful workflows
Visual references: [ashell gallery](https://github.com/MalpenZibo/ashell#-screenshots), [Ironbar examples](https://github.com/JakeStanger/ironbar), [Anyrun](https://github.com/anyrun-org/anyrun), [Sherlock](https://github.com/Skxxtz/sherlock), [Veila](https://github.com/naurissteins/Veila). The reviewed ashell design uses compact rounded groups and popovers; Ironbar's minimal example is a flatter, denser strip. Both are legitimate aesthetic choices.
The authoritative full list is **Super-H** or **Super-Shift-K**, also available through the bar's `?` button.
## Rust screen lockers: real candidates, not a blanket dismissal
| Capability | Entry point / implementation |
| --- | --- |
| Apps and calculator | **Super-D**, bar launcher; Anyrun applications/Rink |
| Files | **Super-E** Thunar; **Super-Ctrl-E** Yazi in Kitty |
| Window overview | **Super-A**, **Super-Ctrl-S**; workspace-labelled picker, validated addresses |
| Window operations | Super-arrows focus; Ctrl modifier moves, Alt swaps, Shift resizes; Super-G groups; Super-Ctrl-Tab changes group tab |
| Floating/fullscreen | **Super-Space** floating; **Super-Shift-F** fullscreen; **Super-Ctrl-F** maximize |
| Workspaces | Super-1…0; Shift moves and follows, Ctrl moves silently; Super-Tab cycles |
| Scratch/drop-down terminal | **Super-U** scratchpad; **Super-Shift-U** move to it; **Super-Shift-Enter** persistent drop-down terminal |
| Actions / quick settings | **Super-Shift-E** action palette; right side of bar opens hardware controls |
| Notifications / DND | **Super-Shift-N** history; **Super-Ctrl-N** DND; bell/count in bar |
| Clipboard | **Super-Alt-V**; text/images, delete, clear and pause/resume from Actions |
| Screenshots | Print menu; Super-Print output; Super-Shift-Print region; Alt-Print window; Ctrl variants delay 5/10 seconds; Super-Shift-S annotation |
| Recording | **Super-Alt-R**; region/output, no audio by default, explicit desktop-audio option; **REC** bar control stops recording |
| Capture feedback/privacy | Screenshot copy/save notification; recording owns a notification inhibitor without overwriting DND preferences; bar privacy indicators |
| Media/OSD | MPRIS bar module and media keys; volume/mic/brightness/keyboard-backlight keys have replacing feedback |
| Night light | **Super-N**; 4200 K from 21:00, identity from 07:00; manual toggle |
| Laptop controls | Power profile, airplane mode and temporary display scaling in Actions; Super-Alt-T touchpad; low/critical battery alerts |
| Emoji / color / search | Super-Alt-E emoji; color picker in Actions; Super-S URL-encoded web search |
| Session | Ctrl-Alt-L lock; Ctrl-Alt-P power menu; destructive menu actions require confirmation |
| Health | Bar settings → Health, Actions → System/update health, or `desktop health`; real units, journal and running/booted/selected generation |
- **Veila** is an attractive, standalone Rust option using `ext-session-lock-v1`, with a packaged build in this Nixpkgs. However, the pin has **0.4.0** and upstream **0.4.4** explicitly lists password-memory handling, daemon-authorized unlock, and fail-closed unlock-handoff fixes. Do **not** select the older package merely to keep the desktop Rust-based. Its daemon and PAM setup must also be declared. [0.4.4 release notes](https://github.com/naurissteins/Veila/releases/tag/0.4.4).
- **Cthulock** is Rust/Slint with a configurable UI and the same session-lock protocol. Latest observed release: 0.1.2, 2025-08-31. Its upstream Nix integration is an additional flake rather than a package/module already present in this pin. It is a possible experiment, not the lowest-maintenance baseline. [Project](https://github.com/FriederHannenheim/cthulock).
- **veiland** is a newer Rust locker with process-isolated GPU plugins, including upstream reports of NVIDIA suspend/hotplug testing. That is upstream evidence, not our testing. Its extensible scene/plugin architecture is more than this minimal desktop needs. [Project](https://github.com/sylflo/veiland).
- **hyprlock** remains the recommendation, not a claim of audited or bug-free software. Its newer 0.9.6 release also contains PAM, rendering and output-handling fixes; review it when refreshing the pin. [Release notes](https://github.com/hyprwm/hyprlock/releases/tag/v0.9.6).
Clipboard data lives in `$XDG_RUNTIME_DIR/workstation`, with a 200-item limit and private permissions. It is cleared on lock and service/session exit. Sources which label sensitive clipboard content are excluded; **universal password detection is not promised**. Pause history when appropriate.
The important property is a compositor-enforced lock, not a fullscreen password window. The [Wayland session-lock protocol](https://wayland.app/protocols/ext-session-lock-v1) requires the session to stay locked if the locker dies after acquiring the lock. It does not prevent authentication bugs in a client, guarantee successful initial locking, or protect against every compositor/GPU failure.
Screenshots go to the XDG Pictures directory under `Screenshots`; recordings go to XDG Videos under `Recordings`. Region cancellation produces no empty capture. Recording has no microphone option enabled by default; the audio choice explicitly captures the output's monitor source. Recording suppression does not claim to detect every browser/portal screenshare.
**Login is separate:** ReGreet and tuigreet are Rust greeters for greetd, not screen lockers. ReGreet is the graphical option; tuigreet is the smaller terminal option. A display manager remains a separate choice, not a silent addition to the EC2 host. COSMIC's greeter is not a drop-in Hyprland locker.
## Visual and interaction checks
## Compatibility details that affect implementation
Native screenshots are kept locally under `~/.cache/desktop-audit/resume/`, **not committed**. They include the real 150% bar/wallpaper, application search, calculator, help, actions, notification drawer, quick settings and Element Nightly startup. Earlier iterations are retained for comparison; filenames alone are not proof that a check passed.
1. **Use the new Hyprland configuration format.** The [current NixOS Wiki](https://wiki.nixos.org/wiki/Hyprland) explicitly flags its older examples as outdated: Hyprland 0.55 introduced Lua configuration. Our locked Home Manager defaults to `configType = "lua"` for `home.stateVersion = "26.05"`. Write a small native `hyprland.lua`, using the [0.55.4 example/API](https://github.com/hyprwm/Hyprland/blob/v0.55.4/example/hyprland.lua), rather than copying old `bind = ...` examples into Lua settings. Do not change stateVersion to work around this.
2. **Only one session manager.** NixOS recommends `programs.hyprland.withUWSM = true`; disable Home Manager's separate Hyprland systemd integration. Upstream also warns UWSM adds its own quirks. Bind bar/agent services to the graphical session and launch applications through the session manager. [Session documentation](https://wiki.hypr.land/Useful-Utilities/Systemd-start/).
3. **ashell needs small but important overrides.** Its pinned default logout command is `loginctl kill-user $(whoami)`, which is broader than logging out of the desktop and could terminate other sessions. Use `uwsm stop`. The pinned Home Manager service has startup ordering but no `PartOf`; explicitly tie its lifetime to `graphical-session.target`. Version 0.8 does not advertise the notification manager shown in the current 0.10 README, so do not assume it replaces mako. [Pinned settings](https://github.com/MalpenZibo/ashell/blob/0.8.0/website/versioned_docs/version-0.8.0/configuration/modules/settings.md).
4. **Rust does not eliminate NVIDIA rendering issues.** ashell 0.8 documents startup freezes with the Vulkan backend and an application-scoped `WGPU_BACKEND=gl` workaround. Anyrun documents a driver-dependent GTK close/hang problem and `GSK_RENDERER=ngl`. Test the actual target first; apply a needed workaround only to that application on that target. Do not export these globally to integrated-graphics machines. [ashell troubleshooting](https://github.com/MalpenZibo/ashell/blob/0.8.0/website/versioned_docs/version-0.8.0/configuration/troubleshooting.md), [Anyrun warning](https://github.com/anyrun-org/anyrun#anyrun).
5. **Anyrun's provider is already packaged correctly.** Since 25.12 it needs `anyrun-provider`; our Nixpkgs wrapper supplies it and the plugin search path. Do not add a manual Cargo install or another flake. The applications plugin searches desktop entries, and its version-specific preprocessing hook must be used when arranging UWSM application launching.
6. **Do not install the wrong Sherlock.** The Rust launcher is `pkgs.sherlock-launcher` (0.1.14-3), and `programs.sherlock` in Home Manager correctly selects it. `pkgs.sherlock` (0.16.0) is an unrelated Python social-account search tool. Sherlock has a polished widget-style UI, but Anyrun/Fuzzel are a more focused starting point.
7. **Portals and idle services already have system integration.** The pinned NixOS Hyprland module adds both Hyprland and GTK portals; the latter supplies a file picker. Do not install competing portal stacks or add sleep/kill/restart hacks. The NixOS hyprlock module creates its PAM service and enables the system-provided hypridle user unit; Home Manager can supply idle configuration with `package = null` instead of creating another unit. A valid lock configuration is required: installing a locker is not enough.
8. **Wait for locking, not an arbitrary delay.** The pinned hypridle supports `general.inhibit_sleep = 3`, waiting for Hyprland's lock notification before releasing its sleep inhibitor, subject to logind's inhibitor timeout. It does not support the newer conditional-timeout options in current upstream documentation. Configure idle lock/display-off without silently adding automatic suspend to a development host. [hypridle documentation](https://wiki.hypr.land/Hypr-Ecosystem/hypridle/).
The native audit caught real issues that configuration evaluation did not: nested launcher padding, overly tall pickers, the notification focus slab and an uninitialized duplicate brightness control. The quick-settings screenshot confirms that the actual panel contains the audio/mic/brightness sliders, network/Bluetooth, idle inhibition, power profile, Actions and Health controls.
## RTX 4090 versus integrated graphics
`desktop-test.py` exercises the disposable graphical VM: session ownership, fonts, real PipeWire nodes, launcher geometry at 100%/150%, clipboard picker, recording container and no-audio default, notification ownership/inhibition, described help, and real wrong/correct-password PAM locking. Test credentials never reach the host. `desktop-actions-test.py` covers cancellation, untrusted input, byte-preserving clipboard behavior, lock failure handling, display timeout restoration and recording-inhibitor cleanup.
Keep the shared desktop separate from a deliberately imported `nvidia.nix`. Do not put NVIDIA options/environment variables into every machine's common module, invent PRIME bus IDs, or reuse EC2's boot/storage configuration on a workstation.
**Completed validation (2026-09-06):** flake evaluation, Nix formatting, generated Hyprland configuration, physical-host assertions, 124 CLI executable smoke checks, 14 desktop-action tests, both updater-policy regression suites, and the full graphical/PAM/clipboard/recording VM check all passed on the refreshed inputs. Native clipboard, recording and rootless Podman checks passed too. The 150% six-result launcher and help screenshots were inspected visually; the transparent launcher click-catcher's IPC dimensions are not mistaken for the visible palette bounds.
- **Intel/AMD-only targets:** start with the standard kernel/Mesa graphics stack. Device-generation-specific video decoding or firmware adjustments require actual hardware identification.
- **RTX 4090 target:** NVIDIA's open kernel modules support Ada/RTX 4090. Use the NixOS driver module with `hardware.nvidia.open = true`, modesetting and power-management support. The userspace driver remains proprietary and needs a scoped unfree allowance. `services.xserver.videoDrivers = [ "nvidia" ]` selects the driver even for Wayland; this does not require enabling the X server.
- **Pinned driver:** stable and production both resolve to **595.71.05**. With open modules, the pinned NixOS module selects the new **kernel suspend notifier** path. Enabling power management sets the appropriate module parameters without the legacy `nvidia-suspend`/`nvidia-resume` services. Do not unconditionally paste older service recipes on top.
- **Suspend storage:** NVIDIA recommends enough temporary backing storage for total VRAM plus about 5%; a 24-GiB 4090 needs roughly 25.2 GiB in the conservative worst case. Check the target filesystem and `/tmp` policy before promising reliable suspend. Do not force early KMS or hibernation configuration without checking the machine.
- **Firefox video decoding is a separate question.** The NVIDIA VA-API bridge's documented setup disables Firefox's RDD sandbox. Do not silently adopt that security trade-off for hardware video decoding. Native Wayland rendering and hardware video decoding are not the same feature.
**Account/hardware boundaries:**
Sources: [official NixOS NVIDIA page](https://wiki.nixos.org/wiki/NVIDIA), [Hyprland NVIDIA guidance](https://wiki.hypr.land/Nvidia/), [NVIDIA supported GPUs](https://github.com/NVIDIA/open-gpu-kernel-modules#compatible-gpus), [595.71.05 power-management documentation](https://download.nvidia.com/XFree86/Linux-x86_64/595.71.05/README/powermanagement.html), [NVIDIA VA-API bridge](https://github.com/elFarto/nvidia-vaapi-driver#firefox).
- Element Nightly launches with libsecret enforced. The live audit reached the encryption warning because no unlocked/configured Secret Service vault was available. The insecure fallback was **not selected**. Open/configure KeePassXC's Secret Service group before signing in; account/vault setup remains the user's responsibility.
- Bluetooth pairing, real suspend/resume, external-monitor hotplug and interactive browser portal sharing require their respective hardware/account interaction. Package and VM tests are not substitutes.
- Temporary display scaling preserves output mode, position and rotation and reverts on timeout/cancel. Specific dock/mirror profiles are not invented without attached displays.
## Before implementation/activation
## Wallpaper provenance
- **Review an input refresh.** Mozilla's [release metadata](https://product-details.mozilla.org/1.0/firefox_versions.json) reports **155.0.1**, versus the pin's **154.0.1**. Reproducible does not mean current. Review the stable Nixpkgs update, browser/locker fixes, evaluation and build separately; no input was updated during this research. Do not move to development snapshots merely for cosmetic features.
- **Establish local authentication.** `dev` currently has a locked Unix password. SSH authorization and passwordless sudo do not give a graphical greeter, locker or polkit agent a usable password. Choose and declare an appropriate credential/secret mechanism before enabling a usable local login/lock workflow. No invented password, plaintext secret in the Nix store, empty-password workaround or silent autologin.
- **Keep the implementation flat:** shared desktop integration, native `hyprland.lua`, and opt-in NVIDIA settings, with each real machine retaining its own hardware/boot module. No deep host/profile framework and no Neovim changes.
[Wallhaven 01e5v4](https://wallhaven.cc/w/01e5v4), a dark One Ring inscription, **1920×1200**. Wallhaven lists uploader **ulairi88**, not a verified original artist, and provides no redistribution license. No artist attribution or open license is invented.
## What was validated
`wallpaper.nix` fetches immutable bytes:
Non-activating evaluations of candidate module compositions passed NixOS and Home Manager assertions for both Mesa-default and opt-in NVIDIA settings. They verified Lua defaults, UWSM ownership, both automatically supplied portals, PAM/idle integration without duplicate idle units, bar lifecycle/logout settings, unchanged Neovim package/source, and the NVIDIA 595 kernel-notifier parameters. These were evaluation fixtures using the existing host as a base, **not bootable physical-host definitions or desktop builds**.
```text
https://w.wallhaven.cc/full/01/wallhaven-01e5v4.jpg
sha256-3jkKzJ0q4MTlHygwUs3SuSiUIjUjkiTqSaM+q8EL/oc=
```
The EC2 host exposes only a simple framebuffer DRM device, with no render node. No actual Hyprland session, hardware acceleration, screen sharing, password unlock, hotplug or suspend/resume was tested. Those checks must be run on the target machines, including wrong/correct-password behavior, locker-crash behavior, monitor changes while locked, and repeated suspend/resume with recovery access retained.
No wallpaper service is contacted at login. `wallpaper.svg` remains the original locally authored alternative. Existing generations retain the fetched image if the source later disappears.
## Reference, not imitation
Reviewed [JaKooLit/Hyprland-Dots](https://github.com/JaKooLit/Hyprland-Dots), its [keybindings](https://github.com/JaKooLit/Hyprland-Dots/blob/main/config/hypr/configs/Keybinds.conf), [scripts](https://github.com/JaKooLit/Hyprland-Dots/tree/main/config/hypr/scripts), Waybar modules and SwayNC configuration, plus the announced successor [LinuxBeginnings/Hyprland-Dots](https://github.com/LinuxBeginnings/Hyprland-Dots). Familiar general shortcuts are retained without copying the installers, mutable `.conf` edits, presentation style or duplicate ownership.
**Excluded deliberately:** animation/theme/bar-layout selectors, online radio/weather/location services, live wallpaper effects, opacity/layout preset collections, speculative GPU/game-mode tuning and broad process-killing refresh scripts. Alt-Tab/window search supplies a useful overview without a second desktop shell for thumbnails.
Implementation references: [Hyprland 0.55 Lua example](https://github.com/hyprwm/Hyprland/blob/v0.55.4/example/hyprland.lua), installed Lua API stubs, [ashell](https://github.com/MalpenZibo/ashell), [Anyrun](https://github.com/anyrun-org/anyrun), [Fuzzel](https://codeberg.org/dnkl/fuzzel), [SwayNC](https://github.com/ErikReider/SwayNotificationCenter), [cliphist](https://github.com/sentriz/cliphist), [UWSM](https://github.com/Vladimir-csp/uwsm), and the locked package/module sources.
+134 -116
View File
@@ -1,168 +1,186 @@
# Development hosts
# Development workstation
Flat, explicit NixOS modules, with locked inputs. Required machine setup belongs in these configs—not a list of manual installs or dotfile copies.
Flat, explicit NixOS modules with locked inputs. Required setup belongs here—not in manual package installs, copied dotfiles or an extra framework.
**Choose the host, not the login name:** `nixosConfigurations.nixos` is this physical UEFI/NVMe machine; `nixosConfigurations.dev` is EC2 only. Both add the `dev` account. Never activate the EC2 closure on the physical machine.
**Select the host, not the login name:** `nixosConfigurations.nixos` is this physical UEFI/NVMe laptop; `nixosConfigurations.dev` is EC2 only. Both use the `dev` account. Never activate the EC2 closure on the laptop.
## Layout
| File | Owns |
| --- | --- |
| `flake.nix`, `flake.lock` | Host entry point and exact Nixpkgs/Home Manager/dotfile revisions and content hashes |
| `configuration.nix` | EC2-only boot/storage/integration, updater and repo ownership |
| `common.nix` | Shared dev environment, platform, Nix features and compatibility version |
| `physical.nix`, `hardware-configuration.nix` | This physical host's systemd-boot/NVMe/Btrfs setup; retains `kbot`, Plasma/SDDM and NetworkManager while adding `dev` |
| `physical-test.nix` | Regression checks for physical-host recovery access, storage, boot and VPN tools |
| `users.nix` | `dev`, SSH/sudo, Home Manager integration and dev workspace ownership |
| `dev-authorized-keys` | Public SSH keys for `dev`—never private keys |
| `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 package-input updates in an isolated Git worktree; validated commits, no forced reboot or GC |
| `desktop-test.nix`, `desktop-test.py`, `audit-desktop.sh`, `update-test.py` | Disposable graphical audit and updater failure/concurrency tests |
| `network.nix` | WireGuard/OpenVPN clients and network diagnostics; optional resolved, host-owned interface management |
| `neovim.nix`, `neovim-test.lua` | Editor, unchanged upstream dotfile deployment and opt-in native runtime audit |
| [DESKTOP.md](DESKTOP.md) | Historical component research; executable configuration is in the files above |
| `flake.nix`, `flake.lock` | Host entry points, exact Nixpkgs/Home Manager/tool/dotfile revisions and hashes |
| `common.nix` | Shared environment, platform, Nix features and compatibility version |
| `physical.nix`, `hardware-configuration.nix` | Laptop boot/storage, SDDM with only managed Hyprland, networking, Bluetooth, power, firmware and host-specific updates |
| `configuration.nix` | EC2-only boot/storage/SSM integration, updater and repo ownership |
| `users.nix` | `dev`, SSH authorization, sudo, Home Manager and workspace ownership |
| `tools.nix`, `network.nix` | Development toolkit, terminal/shell, rootless Podman, VPN clients and network diagnostics |
| `colors.nix`, `wallpaper.nix`, `wallpaper.svg` | Shared One Ring palette, hash-pinned wallpaper and original fallback artwork |
| `desktop.nix`, `hyprland.lua`, `anyrun.css`, `swaync.css`, `desktop-help.py`, `desktop-actions.py` | Session/bar, launchers, described help, capture, clipboard, notifications, lock/idle and styling |
| `apps.nix`, `element-nightly.nix` | Firefox ESR, KeePassXC, Thunderbird, Steam, pinned Element Nightly, Slack, file/media viewers and MIME defaults |
| `neovim.nix`, `neovim-test.lua` | Unmodified upstream editor deployment and opt-in native runtime audit |
| `updates.nix`, `update-system.sh`, `update-test.py` | Daily isolated-worktree updates and failure/concurrency regression tests |
| `physical-test.nix`, `tools-test.nix` | Built host/session assertions and bounded offline tool/help smoke tests |
| `desktop-test.nix`, `desktop-test.py`, `audit-desktop.sh` | Disposable graphical/PAM/audio/scaling audit |
| `workstation.nix`, `nvidia.nix` | Optional ReGreet workstation and NVIDIA modules; not this Intel laptop's configuration |
| [DESKTOP.md](DESKTOP.md) | Live audit, wallpaper provenance, JaKooLit comparison and explicit feature-completion plan |
## Account
## Account and session
- Daily user: `dev`, UID 1001, home `/home/dev`, workspace `~/projects`.
- Passwordless sudo is scoped to `dev`; EC2's SSM-user administration and root SSH recovery remain intact. The physical host retains `kbot`'s existing wheel/sudo access.
- `dev` is authorized by the public key in this repo. No private key or password is embedded.
- On EC2 only, `/etc/nixos` is writable by `dev` through a native tmpfiles ownership rule, which does not follow store symlinks. The physical target leaves the original `/etc/nixos` recovery configs and `/home/kbot` untouched; `/etc/nix` remains administrator-managed.
- 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.
- 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 optional ReGreet module (`workstation.nix`, not used by either host here) requires a securely provisioned `hashedPasswordFile`; the physical host keeps SDDM and a locally set `dev` password. EC2 gets no greeter or autologin.
- Element uses libsecret for encrypted local storage through KeePassXC. Before using it, create/open your own vault and select a dedicated application-token group in **Database Settings → Secret Service Integration**. Keep that vault unlocked when requested. Do not select Element's weaker/no-encryption fallback. Vault setup and application authentication are intentionally user-controlled.
- `wg`, `wg-quick`, OpenVPN, `iperf3`, `nmap`, `traceroute`, `whois`, `mtr`, DNS tools, `tcpdump`, `ethtool`, `nc` and `socat` are installed without any tunnels, peers, keys or added firewall ports. The physical host also has NetworkManager's OpenVPN plugin and keeps its existing DNS setup. EC2 uses resolved; DHCP remains under each host's network manager.
- Daily account: **dev**, UID 1001, `/home/dev`, workspace `~/projects`, Zsh.
- `dev` has **full passwordless sudo**, not a rebuild-only privilege. Nix daemon trusted-user access is not separately granted.
- The physical host uses the locally established `dev` password; mutable users preserve it. No plaintext password, private key or password hash is embedded in this repo/store.
- At the user's request, **Plasma and the previously managed `kbot` account are removed**. This configuration does not delete `/home/kbot`; retained files and old generations are not erased. Home Manager only manages `dev`.
- SDDM exposes **Hyprland (uwsm-managed)** only. Plain Hyprland is deliberately hidden because it does not start the session-bound bar, wallpaper, idle and polkit services. UWSM is the single session owner.
- On EC2, SSM/root recovery and the host's integration remain intact; there is no graphical greeter or autologin. `/etc/nixos` is dev-owned there. On the laptop `/etc/nix` remains administrator-owned and the original `/etc/nixos` files are untouched.
- The SSH key in `dev-authorized-keys` is public. Account/cloud/browser/VPN credentials and vaults remain user-controlled.
Enter from an administrator session with `sudo -iu dev`.
### Everyday controls
Git author identity and remote destination are deliberately unset. `user.useConfigOnly` is now a Home Manager setting in `tools.nix`, not a required manual `git config` step.
| Key / command | Action |
| --- | --- |
| `Super-H`, bar `?` | Search described shortcuts from the live compositor; selecting a row does not execute it |
| `Super-Enter` | Kitty |
| `Super-D` | Anyrun applications/calculator |
| `Super-E`, `Super-Ctrl-E`, `y` | Thunar / Yazi; `y` includes shell-directory integration |
| `Super-B`, `Super-Alt-P` | Firefox / KeePassXC |
| `Ctrl-Alt-L`, `Super-Escape` | Lock |
| `Super-Q`, `Super-Shift-F`, `Super-Space` | Close / fullscreen / floating |
| `Super-1…0`, `Super-Shift-1…0` | Focus workspace / move and follow; Ctrl moves silently |
| `Super-U`, `Super-Shift-U` | Scratch workspace / move window to it |
| `Super-Shift-Enter` | Drop-down terminal |
| `Super-Shift-E`, `Ctrl-Alt-P` | Actions / confirmed session-power menu |
| `Super-Shift-N`, `Super-Ctrl-N` | Notification history / DND |
| `Super-Alt-V` | Clipboard history |
| `Print`, `Super-Shift-S` | Screenshot menu / region annotation |
| `Super-Alt-R` | Start/stop recording; red REC indicator when active |
| `Super-N`, `Super-Alt-E` | Night light / emoji |
| `Super-A`, `Alt-Tab` | Window search / cycle windows |
| `Ctrl-R`, `Ctrl-T`, `Alt-C` in Zsh | fzf history / files / directories |
| `z`, `zi` | zoxide directory navigation |
| `direnv allow` | Explicitly approve a project's `.envrc`; no automatic trust whitelist |
| `tmux`, `zellij`, `lazygit` | Persistent terminal workspaces and Git UI; not auto-started or forced |
Kitty uses an opaque charcoal background, 12pt JetBrains Mono and compact padding. Font zoom remains `Ctrl-Shift-+` / `Ctrl-Shift--`; reset with `Ctrl-Shift-Backspace`. Semantic ANSI colors remain distinguishable from the gold UI accent. Neovim's own theme is unchanged.
The **informative top bar** includes workspaces/window context, clock, CPU/RAM/host temperature, media, notification count, privacy and laptop status. Click its right-hand status area for audio, microphone, brightness, network/Bluetooth, idle inhibition and power-profile controls. **Actions** and **Health** open the searchable action palette and real system/update diagnostics. Notification history is a separate compact drawer, not another settings dashboard.
See [DESKTOP.md](DESKTOP.md) for the screenshot-led audit, functional coverage, wallpaper provenance and remaining hardware/account checks. JaKooLit informed the general shortcuts and workflow coverage—not the visual design.
## Tool coverage
`tools.nix` is organized by purpose, not a single unexplained package dump:
- **Native/debug:** GCC, Make, pkg-config, CMake, Ninja, Meson, ccache, clang tools, GDB, LLDB, Valgrind, Heaptrack, rr, ELF utilities and bpftrace.
- **Rust:** rustc/Cargo/rustfmt/Clippy/rust-analyzer, nextest, cargo-audit/deny/expand/edit.
- **Go:** Go, gopls, Delve, golangci-lint.
- **Python:** Python, uv, Ruff, Pyright; existing Lua/LuaRocks prerequisites stay for Neovim.
- **JS/TS:** Node, pnpm, TypeScript, Biome, Bun, Deno.
- **Additional ecosystems:** OpenJDK 25, Maven, Gradle 9, Kotlin, .NET SDK 10, Ruby/Bundler, PHP/Composer, Zig/ZLS and Elixir/Erlang.
- **Project/CI:** just, watchexec, hyperfine, tokei, ShellCheck, shfmt, yamllint, actionlint, pre-commit, dprint, StyLua, Taplo, Marksman, markdownlint, SQLFluff, Hadolint, ast-grep and ripgrep-all.
- **Source control:** Git/LFS, gh, glab, LazyGit, delta, difftastic, Jujutsu, git-absorb/filter-repo. No guessed Git identity or account authentication.
- **Nix:** direnv/nix-direnv, nix-output-monitor, nvd, nix-tree/diff, nixd, statix, deadnix, nixpkgs-review and the repo formatter.
- **Containers/cloud:** rootless Podman, Compose, Buildah, Skopeo, Dive, AWS CLI, kubectl, Helm, k9s, kubectx, Stern, Kustomize, OpenTofu, Ansible. No Docker daemon, docker-group privilege, deployed infrastructure or automatic image downloads.
- **Data/API:** SQLite, DuckDB, pgcli/litecli, Redis tools, Miller, csvlens, jq/yq/jless, xh, grpcurl, websocat, Protobuf/Buf, Hurl and oha. Installing these does not start database servers or load-test an endpoint.
- **Security/backup:** age, sops, GnuPG, Gitleaks, Trivy, Cosign, Syft, Grype, step, mkcert, restic, rclone, rsync, Mosh and SSHFS. No keys, trusted CA, backup destination, schedules or scan targets are created.
- **Diagnostics:** btop, procs, lnav, sysstat, iotop, dust/duf/ncdu, strace/lsof, NVMe/SMART/USB/PCI/sensor tools.
- **Media/documents:** FFmpeg, ImageMagick, ExifTool, MediaInfo, Poppler utilities, Pandoc, yt-dlp, Chafa, Asciinema, VHS and archive/compression tools.
- **Networking (`network.nix`):** WireGuard/OpenVPN, NetworkManager VPN integration on the laptop, mtr, iperf3, nmap, tcpdump/tshark, doggo/dig, iftop/bandwhich, traceroute, whois, ethtool, netcat and socat. No tunnels, peers, credentials, extra capture privileges or opened firewall ports.
These are system-owned executables from Nix, not unmanaged `npm -g`, `pip install --user` or `cargo install` bootstraps. Project dependencies may still be downloaded by their ordinary package managers. `nix develop` / `.envrc` remain appropriate for project-specific versions; this is not a promise that every language project uses the same global toolchain.
## Build and apply
### Physical machine: keep kbot and add dev
`physical.nix` preserves `kbot` (UID 1000, `/home/kbot`, existing password, wheel/NetworkManager groups), KDE Plasma, SDDM, printing, locale/time zone, and the installed filesystems/boot loader. Home Manager manages **only `dev`**, UID 1001 with its own `/home/dev`. No autologin, password copying, or replacement greeter is enabled. The EC2 automatic updater is deliberately **not imported**.
The hardware file is a tracked copy of this machine's `/etc/nixos/hardware-configuration.nix`; do not use it unchanged on another machine. The original `/etc/nixos` files remain available as recovery configuration.
**Recovery from the earlier EC2 switch attempt:** that failure selected system-profile generation 2 even though generation 1 is still running and remains systemd-boot's default. Before testing the physical configuration, if the profile still points at the failed EC2 closure, restore its selection without activating anything:
```sh
readlink -f /nix/var/nix/profiles/system
sudo nix-env --profile /nix/var/nix/profiles/system --switch-generation 1
```
This recovery command is specific to the observed generations on this machine; it is not a routine rebuild step.
From an administrator shell:
On the **physical laptop**, from an administrator shell:
```sh
cd /etc/nix
nixfmt --check ./*.nix
nix flake check --no-build --no-update-lock-file
nix build \
nix build .#checks.x86_64-linux.updates \
.#checks.x86_64-linux.desktop-config \
.#checks.x86_64-linux.physical-config \
.#checks.x86_64-linux.updates .#checks.x86_64-linux.desktop-config \
.#checks.x86_64-linux.tools .#checks.x86_64-linux.desktop-actions \
--no-update-lock-file --no-link
nix build \
.#nixosConfigurations.nixos.config.system.build.toplevel \
nix build .#nixosConfigurations.nixos.config.system.build.toplevel \
--no-update-lock-file --out-link result-nixos
built=$(readlink -f result-nixos)
sudo "$built/sw/bin/nixos-rebuild" dry-activate --no-reexec --store-path "$built"
```
Use **`result-nixos`**, not an old `result` pointing at the EC2 closure. The dry run must not remove `kbot`, SDDM, NetworkManager or the `/boot`, `/home`, `/nix` mounts. Existing services can still need a restart; keeping an account does not guarantee its current graphical session stays running.
New source files must be added to Git for flakes to include them. A build or dry activation does not activate the result. Review the dry activation; removing Plasma and `kbot` is intentional, removing `dev`, NetworkManager or the installed mounts is not.
Save your work, then activate from a text console (`Ctrl-Alt-F3`, log in as `kbot`, or use root). Keep that console available during testing:
**Save work before activating.** A desktop/display-manager change can end a graphical session. Use `Ctrl-Alt-F3`, log in as `dev`, and retain that console:
```sh
cd /etc/nix
built=$(readlink -f result-nixos) # Re-establish this shell variable on the new console.
# Activates users/services, but does NOT change the boot default. This is not a dry run.
built=$(readlink -f /etc/nix/result-nixos)
sudo "$built/sw/bin/nixos-rebuild" test --no-reexec --store-path "$built"
# Choose a new password locally; mutable users preserve it across later rebuilds.
sudo passwd dev
```
At SDDM, choose **dev → Hyprland (uwsm-managed)** to try the new desktop, or **kbot → Plasma** for the existing environment. Once satisfied, persist the exact tested closure:
```sh
# After testing login/session, persist exactly that closure:
sudo "$built/sw/bin/nixos-rebuild" switch --no-reexec --store-path "$built"
```
If testing fails, use the retained text console to restore the booted system without changing the boot default:
`test` is a real activation, not a dry run. For a non-disruptive deployment that takes effect on next boot, use `boot` instead of `test`/`switch`. The daily physical updater uses that policy.
On **EC2 only**, use `/etc/nixos` and `.#nixosConfigurations.dev.config.system.build.toplevel`. Run the same evaluation/checks and activate the exact output with its own `nixos-rebuild --no-reexec --store-path`. Never select a target merely because it matches your username.
### Recovery
Keep existing generations. Inspect what is actually selected and booted:
```sh
readlink -f /run/current-system /run/booted-system /nix/var/nix/profiles/system
sudo nix-env --profile /nix/var/nix/profiles/system --list-generations
```
To restore the booted closure temporarily from the retained console:
```sh
previous=$(readlink -f /run/booted-system)
sudo "$previous/sw/bin/nixos-rebuild" test --no-reexec --store-path "$previous"
```
If a later boot fails, select the original generation in systemd-boot (hold Space during startup). Keep recovery generations; do not garbage-collect them during migration. A working `kbot` account cannot recover an unbootable kernel/filesystem by itself.
If boot fails, choose a known-good systemd-boot generation (hold Space at startup). Do not blindly select “generation 1”: generation numbers are machine/history-specific. System rollback does not restore mutable user data, Git changes, application databases or backups.
### EC2 only
## Automatic freshness, precisely
As `dev`, from `/etc/nixos` on the EC2 host:
- **Every day**, `nixos-update.timer` runs with up to one hour of jitter and catches missed runs. It advances `nixpkgs`, `home-manager`, and `nixpkgs-latest`; the Neovim source remains fixed.
- The system/desktop use the stable `nixos-26.05` and matching Home Manager release branch. Most standalone CLI tools use the locked **Nixpkgs master** input to avoid stable-release feature lag. They are imported separately, **not** overlaid onto the desktop's GCC/Python/libraries. The Nix daemon and service-integrated packages remain from the system pin.
- **Laptop:** administrator-owned `/etc/nix`, explicit `nixos` target, build/check/record and **stage for next boot**. No surprise logout, live compositor replacement or automatic reboot. Installed versions change when that generation is booted, or when explicitly switched by the user.
- **EC2:** dev-owned `/etc/nixos`, explicit `dev` target and live `switch`, retaining the prior activation/rollback behavior.
- Both policies skip dirty/detached repositories, serialize runs, use an isolated worktree, check for edits again after dry activation, commit only the tested lock, attempt rollback on failure and retain recovery generations. A failed rollback is reported as failure, not silently marked successful.
- **“Current” means newest successfully checked/built versions packaged in those branch heads**, not a guarantee of every upstream release immediately. Master can contain breakage and uncached builds; failure retains the previous working generation. Upstream Pi, Mason/plugin downloads, browser add-ons, firmware and project dependencies are separate update boundaries. No runtime self-updater is bolted on to override Nix-owned executables.
- The updater does not fetch/merge repository code from origin. Configuration code is reviewed separately. A dirty working tree intentionally prevents automatic input changes until work is committed/stashed.
Inspect or trigger:
```sh
nixfmt --check ./*.nix
nix flake check --no-build --no-update-lock-file
nix build .#checks.x86_64-linux.updates .#checks.x86_64-linux.desktop-config --no-link
nix build .#nixosConfigurations.dev.config.system.build.toplevel --no-update-lock-file
systemctl list-timers nixos-update.timer
journalctl -u nixos-update
sudo systemctl start nixos-update.service
sudo less /var/cache/nixos-update/last-success
```
New source files must be added to Git for flakes to see them. Keep `flake.lock` in version control. A build does **not** activate changes.
Manual refresh: `nix flake update nixpkgs home-manager nixpkgs-latest`, review `flake.lock`, then check/build. `system.stateVersion` / `home.stateVersion` remain `26.05`: they govern compatibility, not package freshness. Channels are disabled; the Nixpkgs registry and `<nixpkgs>` lookup follow the locked system input.
Review and activate exactly the built closure:
## Editor and application data
Neovim imports [the existing neovim-dots source](https://git.cyber.ayyalasomayajula.net/marsultor/neovim-dots), pinned to `380eb86778a7c53a0f1c18e84f14037456155347`. Lua, AstroNvim, Lazy, Mason, plugins and keymaps are unchanged. No Nixvim, replacement loader or plugin-manager migration.
Home Manager links configuration files under `~/.config/nvim`. Lazy's writable lock lives under `~/.local/state/nvim/locks/<revision>.json`, seeded once per dotfile revision. An earlier manual checkout is preserved at `~/projects/neovim-dots-before-nix`; activation refuses to overwrite an existing backup. The host input is reproducible; Lazy/Mason's mutable runtime downloads are not claimed to be fully Nix-reproducible.
As `dev`, the separate runtime audit may download dependencies but does not modify managed Lua or save buffers:
```sh
built=$(readlink -f result)
sudo "$built/sw/bin/nixos-rebuild" dry-activate --no-reexec --store-path "$built"
sudo "$built/sw/bin/nixos-rebuild" switch --no-reexec --store-path "$built"
nvim --headless ~/.config/nvim/init.lua -c 'luafile /etc/nix/neovim-test.lua'
```
Use the rebuild tool from that closure with `--no-reexec`: otherwise the bootstrap tool can try to rebuild itself through the old channel even when `--store-path` is supplied. Capturing `built` also keeps review and activation on the same immutable result.
KeePassXC preferences are seeded once and stay writable. Create/open your own vault, pair the browser extension and choose a dedicated **Secret Service Integration** group for application tokens. Element Nightly is forced to libsecret storage; **do not select its unencrypted fallback**. The native audit confirmed that Nightly starts, but login needs an unlocked/configured vault. No vault, account or VPN credentials are embedded.
For initial deployment on a compatible NixOS EC2 base where flakes are not enabled yet, check out this repo and run the build as an existing administrator with the temporary CLI flag:
`element-nightly.nix` packages the official Element Nightly **2026090401** Debian artifact by URL and SHA-256, preserving its matched Electron/native modules. It uses Chromium's user-namespace sandbox rather than the unusable setuid helper; `--no-sandbox` is not used. Its Nightly profile is separate from stable Element; existing account data is not copied or deleted. The launcher is **Element-Nightly**, command `element-desktop-nightly`; `element-desktop` is a compatibility alias.
```sh
nix --extra-experimental-features 'nix-command flakes' build \
.#nixosConfigurations.dev.config.system.build.toplevel --no-update-lock-file
```
**Nightly update boundary:** this upstream binary is not packaged by the pinned Nixpkgs inputs. Its URL/version/hash must be refreshed in `element-nightly.nix` from the official `packages.element.io` package index, then rebuilt. The daily flake-input updater does not silently mutate this source file. This intentionally keeps the setup simple and reproducible rather than adding another downloader/self-updater.
Then review/apply the resulting closure as above. The configuration creates `dev`, sets permissions and deploys its files. **No separate Neovim clone, copy, useradd, chown or global Git-config recipe is required.** After either host configuration is active, `nix-command` and `flakes` are system defaults through `nix.settings.experimental-features`; the temporary flag is only for bootstrapping older bases that cannot read flakes yet.
## Validation and contributions
This host build does not use a mutable channel. NixOS's native flake integration pins the `nixpkgs` registry entry and login-shell `<nixpkgs>` lookup to the system input; the global `nix-path` setting keeps the same pin when `NIX_PATH` is unset. Old root channel profiles are retained for recovery, not used as build inputs. Dev-environment templates/composition remain deferred; there is no flake framework here.
`./audit-desktop.sh` runs the disposable graphical VM; `./audit-desktop.sh /dev/dri/renderD128` optionally uses an accessible render node. Logs/screenshots go to `~/.cache/desktop-audit/run.*`. The VM tests session services, fonts, audio, scaling and real PAM with **test-only** credentials. The native live screenshot audit covers this laptop; neither substitutes for real hardware suspend, hotplug or browser portal tests.
## Neovim: import, do not rewrite
The input is [the existing neovim-dots repository](https://git.cyber.ayyalasomayajula.net/marsultor/neovim-dots), initially pinned to `380eb86778a7c53a0f1c18e84f14037456155347`.
Home Manager deploys its files under `~/.config/nvim`, with the **Lua, AstroNvim, Lazy, Mason, plugins and keymaps unchanged**. `programs.neovim.configure` stays empty so Neovim discovers `init.lua` normally. No Nixvim or custom Lua loader.
- `tools.nix` supplies GCC/Make, pkg-config, Python, Node, Lua 5.1/LuaRocks and `nix-ld` for the existing plugin builds and Mason's upstream Linux executables. These are runtime prerequisites, not a replacement plugin manager or project-template framework.
- Configuration files are linked from the pinned source and managed by Nix. Change the upstream repo and its input revision rather than editing generated links.
- Lazy's `lazy-lock.json` must remain writable. The config seeds a copy at `~/.local/state/nvim/locks/<dotfile-revision>.json` and links to it. A new dotfile revision gets its own original lock; repeated activation preserves runtime changes to an existing lock.
- A declared migration preserves the earlier manual checkout intact at `~/projects/neovim-dots-before-nix`. It refuses to overwrite an existing backup. On a clean home this migration does nothing.
**Reproducibility boundary:** Nix locks the host inputs and dotfile source, and reproduces their deployment. The existing Lua still bootstraps Lazy and manages plugin/Mason downloads at runtime. The supplied Lazy lock records plugin revisions, but it is writable and Mason's tool versions are not pinned by this Nix config. This is not a claim that every runtime download/cache is a Nix-reproducible build. Changing that policy requires a separate agreement; do not silently replace the user's plugin managers.
## Validation and commits
The initial deployment was tested with an empty disposable home: all upstream files were reproduced byte-for-byte, the Lazy lock remained writable across repeated activation, and the migration preserved local data and refused to overwrite an existing backup. The real plugin bootstrap is a separate runtime test, not covered by these file-deployment checks. As `dev`, run `nvim --headless ~/.config/nvim/init.lua -c 'luafile /etc/nixos/neovim-test.lua'` for a bounded runtime check of Lazy, the configured Mason tools (including executable startup), and nine parsers. This uses the existing writable plugin/Mason cache and may download dependencies; it does not edit the managed Lua or save buffers.
Run the graphical test separately with `./audit-desktop.sh`. An optional accessible render node, for example `./audit-desktop.sh /dev/dri/renderD128`, moves rendering out of QEMU's CPU emulation without touching the host display. Screenshots and logs go to `~/.cache/desktop-audit/run.*`. The test uses an isolated VM, test-only credentials and an emulated sound card with a silent backend. It checks session services, fonts, 100%/150% scaling, real PAM lock/unlock, speaker volume, microphone mute and audio controls. Hardware audio, NVIDIA, suspend and mixed-monitor behavior still need the target workstation. `nix flake check` without `--no-build` also runs the software-rendered VM and can be very slow without KVM.
Make focused changes, format/evaluate them, and commit regularly. Build and activate a reviewed commit rather than accumulating uncommitted setup. `system.configurationRevision` records the source revision in the system generation. Do not put human identity guesses in Git settings or push to an unapproved remote.
## Updates and safety
- **EC2 only:** `nixos-update.timer` checks daily with up to one hour of jitter. Nixpkgs/Home Manager release branches and the latest developer-tool package input 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 nixpkgs-latest`, review `flake.lock`, then check/build. The exact resolved revisions and hashes remain committed.
- Keep the EC2 module on the EC2 target only. Preserve sandboxing, signature verification and each host's recovery access.
- `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.
- Rollbacks do not restore mutable user/application data, lockfile updates, backups, or this Git working tree.
- No private keys, plaintext secrets, build outputs or agent notes in this repo.
Plain `nix flake check` also builds/runs the VM and may be slow without KVM. Use `--no-build` for evaluation, then select bounded checks explicitly. Keep changes focused, format/evaluate/test them, and commit reviewed configuration. Do not push to an unapproved remote, invent a human Git identity, commit secrets, screenshots of personal windows, build outputs or agent scratch notes.
+17 -14
View File
@@ -1,24 +1,27 @@
* { font-family: Inter, sans-serif; font-size: 17px; }
* { font-family: Inter, sans-serif; font-size: 16px; }
window { background: transparent; }
box.main {
padding: 14px;
margin: 18px;
border-radius: 16px;
border: 2px solid #78a9ff;
background: #161616;
padding: 12px;
margin: 12px;
border-radius: 6px;
border: 2px solid @accent@;
background: @background@;
box-shadow: 0 8px 18px rgba(0, 0, 0, .35);
}
text {
color: #f2f4f8;
background: #262626;
color: @text@;
background: @surface@;
min-height: 38px;
padding: 10px 14px;
border-radius: 9px;
border-radius: 4px;
}
.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; }
/* The row, boxes, icon AND labels all share .match upstream. Padding that
class multiplies row height until the launcher extends off the screen. */
.match { background: transparent; }
row.match { padding: 7px 10px; border-radius: 4px; }
row.match:selected { background: @selection@; }
label.match { color: @text@; }
label.match.description { font-size: 14px; color: @muted@; }
label.plugin.info { color: @muted@; }
+51 -14
View File
@@ -7,16 +7,7 @@
let
c = import ./colors.nix;
# Select secure storage without rebuilding the cached Electron application.
elementWithKeyring = pkgs.symlinkJoin {
name = "element-desktop-with-keyring-${pkgs.element-desktop.version}";
paths = [ pkgs.element-desktop ];
nativeBuildInputs = [ pkgs.makeWrapper ];
postBuild = ''
wrapProgram "$out/bin/element-desktop" --add-flags "--password-store=gnome-libsecret"
'';
inherit (pkgs.element-desktop) meta;
};
elementNightly = import ./element-nightly.nix { inherit pkgs; };
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";
@@ -58,6 +49,13 @@ in
"nvidia-persistenced"
]
);
programs.thunar = {
enable = true;
plugins = [
pkgs.thunar-archive-plugin
pkgs.thunar-volman
];
};
programs.steam = {
enable = true;
remotePlay.openFirewall = false;
@@ -66,11 +64,31 @@ in
};
programs.firefox = {
enable = true;
# Stable's ESR is security-current (153.2); regular 155.0 is one patch behind.
# Use the security-supported ESR line from the system pin.
package = pkgs.firefox-esr;
nativeMessagingHosts.packages = [ pkgs.keepassxc ];
policies = {
DisableTelemetry = true;
DontCheckDefaultBrowser = true;
NoDefaultBookmarks = true;
Preferences = {
"browser.compactmode.show" = {
Value = true;
Status = "default";
};
"browser.uidensity" = {
Value = 1;
Status = "default";
};
"browser.newtabpage.activity-stream.showSponsored" = {
Value = false;
Status = "locked";
};
"browser.newtabpage.activity-stream.showSponsoredTopSites" = {
Value = false;
Status = "locked";
};
};
OfferToSaveLogins = false;
ExtensionSettings."keepassxc-browser@keepassxc.org" = {
installation_mode = "normal_installed";
@@ -83,11 +101,20 @@ in
home-manager.users.dev = {
home.packages = with pkgs; [
thunderbird
file-roller
imv
# Electron does not reliably detect a keyring under Hyprland. Use the
# KeePassXC Secret Service explicitly; never fall back to basic_text.
elementWithKeyring
elementNightly
slack
];
programs.mpv = {
enable = true;
config = {
hwdec = "auto-safe";
keep-open = true;
};
};
programs.keepassxc = {
enable = true;
autostart = true;
@@ -113,7 +140,7 @@ in
inputbar-fg = c.text;
completion-bg = c.background;
completion-fg = c.text;
completion-highlight-bg = "#354562";
completion-highlight-bg = c.selection;
completion-highlight-fg = c.text;
notification-bg = c.surface;
notification-fg = c.text;
@@ -127,11 +154,21 @@ in
enable = true;
defaultApplications = {
"application/pdf" = [ "org.pwmt.zathura.desktop" ];
"inode/directory" = [ "thunar.desktop" ];
"application/zip" = [ "org.gnome.FileRoller.desktop" ];
"image/png" = [ "imv.desktop" ];
"image/jpeg" = [ "imv.desktop" ];
"image/webp" = [ "imv.desktop" ];
"video/mp4" = [ "mpv.desktop" ];
"video/x-matroska" = [ "mpv.desktop" ];
"audio/mpeg" = [ "mpv.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" ];
"x-scheme-handler/matrix" = [ "element-desktop-nightly.desktop" ];
"x-scheme-handler/element" = [ "element-desktop-nightly.desktop" ];
"x-scheme-handler/io.element.desktop" = [ "element-desktop-nightly.desktop" ];
};
};
};
+17 -14
View File
@@ -1,16 +1,19 @@
# Small shared palette, not a theme framework. Hyprland's native Lua uses the
# same accent/border colors explicitly; wallpaper.svg is original source art.
# One Ring: charcoal, old gold, parchment and muted woodland accents.
# Semantic UI colors are separate from ANSI terminal colors. Consumers substitute
# these values into native Lua/CSS; there is no runtime theme generator.
{
background = "#161616";
surface = "#262626";
raised = "#393939";
border = "#525252";
text = "#f2f4f8";
muted = "#a2a9b0";
blue = "#78a9ff";
cyan = "#3ddbd9";
purple = "#be95ff";
green = "#42be65";
yellow = "#f1c21b";
red = "#ff6b7a";
background = "#121311";
surface = "#1e201b";
raised = "#2c3027";
border = "#4a5142";
selection = "#3a3d2e";
text = "#e9e4d5";
muted = "#adb2a0";
accent = "#c6a664";
blue = "#8faeb5";
cyan = "#92b8a0";
purple = "#b5a5be";
green = "#a3b878";
yellow = "#d8bb78";
red = "#df8b78";
}
+2 -12
View File
@@ -11,18 +11,8 @@
];
nixpkgs.hostPlatform = "x86_64-linux";
nixpkgs.overlays = [
(_final: prev: {
# Pi is a fast-moving agent harness; stable branches can be generations old.
# Use the latest resolving Nixpkgs input while keeping the rest of the
# system on the stable release branch.
pi-coding-agent =
(import inputs.nixpkgs-latest {
inherit (prev.stdenv.hostPlatform) system;
config = prev.config or { };
}).pi-coding-agent;
})
];
# tools.nix/network.nix select current standalone CLIs without overriding the
# system package set (or mixing unstable libraries into the desktop stack).
nix = {
channel.enable = false;
+127
View File
@@ -0,0 +1,127 @@
"""Offline safety/behavior tests for desktop actions: no host desktop or power calls."""
import importlib.util
import os
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("actions", sys.argv.pop(1))
actions = importlib.util.module_from_spec(spec)
spec.loader.exec_module(actions)
class ActionsTest(unittest.TestCase):
def test_picker_cancel_and_untrusted_output(self):
for code, text in [(1, "0"), (0, "$(touch /oops)"), (0, "-1"), (0, "9")]:
with patch.object(actions, "run", return_value=subprocess.CompletedProcess([], code, text)):
self.assertIsNone(actions.pick("Test", ["only item"]))
def test_picker_uses_index_and_sanitizes_labels(self):
with patch.object(actions, "run", return_value=subprocess.CompletedProcess([], 0, "0\n")) as run:
self.assertEqual(actions.pick("Test", ["title\nsecond\x00row"]), 0)
self.assertEqual(run.call_args.kwargs["input"], "title second row")
self.assertIn("--index", run.call_args.args)
def test_address_validation(self):
self.assertEqual(actions.address("0xabc123"), "address:0xabc123")
for value in ["", "0xABC; os.execute('bad')", "123", '"}']:
with self.assertRaises(ValueError):
actions.address(value)
def test_no_power_action_on_cancel(self):
with patch.object(actions, "pick", return_value=None), patch.object(actions, "run") as run:
actions.power()
run.assert_not_called()
with patch.object(actions, "pick", return_value=4), patch.object(actions, "confirm", return_value=False), patch.object(actions, "run") as run:
actions.power()
run.assert_not_called()
def test_screenshot_cancellation(self):
with patch.object(actions, "capture_geometry", return_value=None), patch.object(actions, "run") as run:
actions.screenshot()
run.assert_not_called()
def test_sensitive_and_locked_clipboard_not_stored(self):
with tempfile.TemporaryDirectory() as directory:
with patch.dict(os.environ, {"XDG_RUNTIME_DIR": directory, "CLIPBOARD_STATE": "sensitive"}), patch.object(actions, "run") as run:
actions.clipboard("store")
run.assert_not_called()
with patch.dict(os.environ, {"XDG_RUNTIME_DIR": directory, "CLIPBOARD_STATE": "data"}), patch.object(actions, "run") as run:
(actions.runtime() / "locked").touch()
actions.clipboard("store")
run.assert_not_called()
def test_clear_removes_database_not_just_entries(self):
with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"XDG_RUNTIME_DIR": directory}):
database = actions.runtime() / "clipboard.db"
database.write_bytes(b"old history pages")
actions.clipboard("clear")
self.assertFalse(database.exists())
def test_clipboard_roundtrip_preserves_bytes(self):
data = b" leading\ntrailing \x00\xff"
with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"XDG_RUNTIME_DIR": directory}):
(actions.runtime() / "clipboard.db").touch()
calls = []
def fake(*args, **kwargs):
calls.append((args, kwargs))
value = "4\tpreview\n" if args == ("cliphist", "list") else data
return subprocess.CompletedProcess(args, 0, value)
with patch.object(actions, "run", side_effect=fake), patch.object(actions, "pick", return_value=0):
actions.clipboard("pick")
self.assertEqual(calls[-1][0], ("wl-copy",))
self.assertEqual(calls[-1][1]["input"], data)
def test_web_search_is_url_encoded_not_shell(self):
result = subprocess.CompletedProcess([], 0, "hello; $(bad) & stuff")
with patch.object(actions, "run", return_value=result) as run:
actions.web_search()
self.assertEqual(run.call_args.args[:4], ("uwsm", "app", "--", "xdg-open"))
self.assertIn("hello%3B+%24%28bad%29+%26+stuff", run.call_args.args[4])
def test_lua_strings_do_not_use_json_control_escapes(self):
self.assertEqual(actions.lua('a"\n\x00'), '"\\097\\034\\010\\000"')
def test_lock_still_runs_when_clipboard_wipe_fails(self):
with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, {"XDG_RUNTIME_DIR": directory}):
with patch.object(actions, "clipboard", side_effect=subprocess.CalledProcessError(1, "cliphist")), patch.object(actions, "run", return_value=subprocess.CompletedProcess([], 0)) as run:
actions.lock_start()
run.assert_called_once_with("hyprlock", check=False)
self.assertFalse((actions.runtime() / "locked").exists())
def test_record_inhibitor_is_removed_on_failure(self):
calls = []
def fake(*args, **kwargs):
calls.append(args)
if args[0] == "wf-recorder":
raise subprocess.CalledProcessError(1, args)
return subprocess.CompletedProcess(args, 0)
with patch.object(actions.signal, "signal"), patch.object(actions, "run", side_effect=fake):
with self.assertRaises(subprocess.CalledProcessError):
actions.record_run(["-f", "test.mkv"])
self.assertEqual(calls[-1], ("swaync-client", "-Ir", "workstation-recording", "-sw"))
def test_only_osd_notifications_are_transient(self):
with patch.object(actions, "run") as run:
actions.notify("Saved", "Screenshot")
self.assertNotIn("int:transient:1", run.call_args.args)
actions.notify("Volume", "50%", 50)
self.assertIn("int:transient:1", run.call_args.args)
def test_display_timeout_restores_scale(self):
monitor = '[{"name":"eDP-1","width":1920,"height":1200,"scale":1.5}]'
calls = []
def fake(*args, **kwargs):
calls.append(args)
if args[0] == "fuzzel":
raise subprocess.TimeoutExpired(args, 15)
return subprocess.CompletedProcess(args, 0, "ok")
with patch.object(actions, "output", return_value=monitor), patch.object(actions, "pick", side_effect=[0, 0]), patch.object(actions, "run", side_effect=fake):
actions.displays()
self.assertIn("scale=1}", calls[0][-1])
self.assertIn("scale=1.5}", calls[-1][-1])
unittest.main()
+520
View File
@@ -0,0 +1,520 @@
"""Small desktop actions, not a shell framework. No selected text is executed.
Control state is session-local; explicit captures use XDG media directories.
Dependencies are supplied by desktop.nix.
"""
import datetime
import fcntl
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess as sp
import sys
import time
from urllib.parse import quote_plus
def run(*args, check=True, **kwargs):
return sp.run(list(args), check=check, **kwargs)
def output(*args):
return run(*args, capture_output=True, text=True).stdout.strip()
def notify(title, body="", value=None):
args = ["notify-send", "-a", "Workstation"]
if value is not None:
# OSD replaces only OSD, never a saved screenshot or action failure.
args += ["-h", "string:x-canonical-private-synchronous:workstation-osd",
"-h", "int:transient:1", "-h", f"int:value:{max(0, min(100, int(value)))}", "-t", "1500"]
run(*args, title, body, check=False)
def pick(prompt, choices):
"""Return a validated index; Escape/custom unmatched input never acts."""
if not choices:
notify(prompt, "Nothing available")
return None
labels = [re.sub(r"[\x00-\x1f\x7f]", " ", str(x)) for x in choices]
result = run("fuzzel", "--dmenu", "--index", "--prompt", prompt + " ",
input="\n".join(labels), text=True, capture_output=True, check=False)
if result.returncode != 0:
return None
try:
index = int(result.stdout.strip())
except ValueError:
return None
return index if 0 <= index < len(choices) else None
def confirm(action):
return pick(action + "?", ["Cancel", action]) == 1
def runtime():
path = Path(os.environ["XDG_RUNTIME_DIR"]) / "workstation"
path.mkdir(mode=0o700, exist_ok=True)
return path
def clients():
return json.loads(output("hyprctl", "-j", "clients"))
def dispatch(expression):
response = output("hyprctl", "dispatch", expression)
if response not in ("", "ok"):
raise RuntimeError(response)
def lua(value):
if isinstance(value, str):
# Lua does not support JSON's \\uXXXX escapes. Fixed-width decimal byte
# escapes also keep newlines, quotes and arbitrary device names inert.
return '"' + ''.join(f'\\{byte:03d}' for byte in value.encode()) + '"'
return json.dumps(value, allow_nan=False)
def address(value):
if not re.fullmatch(r"0x[0-9a-fA-F]+", value):
raise ValueError("Invalid compositor window address")
return "address:" + value
def window_picker():
windows = sorted(clients(), key=lambda c: (c["workspace"]["id"], c.get("focusHistoryID", 0)))
labels = [f'{c["workspace"]["name"]} · {c["class"]}{c["title"]}' for c in windows]
index = pick("Windows", labels)
if index is not None:
dispatch("hl.dsp.focus({window=" + lua(address(windows[index]["address"])) + "})")
def scratch():
existing = any(c["class"] == "dropterminal" for c in clients())
dispatch('hl.dsp.workspace.toggle_special("terminal")')
if not existing:
run("uwsm", "app", "--", "kitty", "--class", "dropterminal")
def clipboard(action):
# Lock checks and database writes together: an in-flight store cannot put
# a clipboard item back AFTER the lock-screen wipe has completed.
if action not in {"store", "clear"}:
_clipboard(action)
return
with (runtime() / "clipboard.lock").open("w") as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
_clipboard(action)
def _clipboard(action):
root = runtime()
env = dict(os.environ, CLIPHIST_DB_PATH=str(root / "clipboard.db"), CLIPHIST_MAX_ITEMS="200")
database = root / "clipboard.db"
if action == "store":
if os.environ.get("CLIPBOARD_STATE") in ("sensitive", "nil", "clear"):
return
if (root / "clipboard-paused").exists() or (root / "locked").exists():
return
run("cliphist", "store", env=env)
elif action == "clear":
# cliphist wipe leaves its database file (and free pages) behind.
# Stores are serialized above, so remove the session database itself.
database.unlink(missing_ok=True)
elif action == "pause":
marker = root / "clipboard-paused"
if marker.exists():
marker.unlink()
notify("Clipboard history resumed")
else:
marker.touch(mode=0o600)
notify("Clipboard history paused")
elif action in ("pick", "delete"):
if not database.exists():
notify("Clipboard history", "No items in this session")
return
rows = run("cliphist", "list", env=env, capture_output=True, text=True).stdout.splitlines()
index = pick("Clipboard" if action == "pick" else "Delete clipboard item", rows)
if index is None:
return
row = rows[index] + "\n"
if action == "delete":
run("cliphist", "delete", env=env, input=row, text=True)
else:
data = run("cliphist", "decode", env=env, input=row.encode(), capture_output=True).stdout
run("wl-copy", input=data)
def lock_start():
root = runtime()
with (root / "locker.lock").open("w") as handle:
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return
(root / "locked").touch(mode=0o600)
try:
clipboard("clear")
except (OSError, sp.SubprocessError) as error:
# Clipboard trouble must NEVER prevent the screen from locking.
print(f"Clipboard wipe failed before lock: {error}", file=sys.stderr)
result = run("hyprlock", check=False)
# A crash must not resume clipboard collection on a still-locked session.
if result.returncode == 0:
(root / "locked").unlink(missing_ok=True)
else:
raise RuntimeError("Locker failed; clipboard history remains paused")
def power():
choices = ["Lock", "Suspend", "Log out", "Reboot", "Shut down"]
index = pick("Session", choices)
if index is None:
return
if index == 0:
run("loginctl", "lock-session")
elif confirm(choices[index]):
if index == 2:
run("uwsm", "stop")
else:
run("systemctl", {1: "suspend", 3: "reboot", 4: "poweroff"}[index])
def capture_geometry(mode):
if mode == "area":
result = run("slurp", capture_output=True, text=True, check=False)
if result.returncode != 0 or not result.stdout.strip():
return None
return ["-g", result.stdout.strip()]
if mode == "window":
window = json.loads(output("hyprctl", "-j", "activewindow"))
if not window.get("address"):
return None
x, y = map(int, window["at"])
w, h = map(int, window["size"])
if w <= 0 or h <= 0:
return None
return ["-g", f"{x},{y} {w}x{h}"]
if mode == "output":
monitors = json.loads(output("hyprctl", "-j", "monitors"))
return ["-o", next(m["name"] for m in monitors if m["focused"])]
if mode == "all":
return []
raise ValueError("Unknown capture mode")
def destination(kind, extension):
# User's XDG directory setting, with a conventional fallback.
key = "PICTURES" if kind == "Screenshots" else "VIDEOS"
base = Path(output("xdg-user-dir", key))
folder = base / kind
folder.mkdir(mode=0o700, parents=True, exist_ok=True)
stamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S_%f")
return folder / (stamp + extension)
def screenshot(mode="area", annotate=False, delay=0, copy_only=False):
if delay:
notify("Screenshot", f"Capturing in {delay} seconds")
time.sleep(delay)
geometry = capture_geometry(mode)
if geometry is None:
return
image = run("grim", *geometry, "-", capture_output=True).stdout
if annotate:
target = destination("Screenshots", ".png")
run("satty", "--filename", "-", "--copy-command", "wl-copy",
"--output-filename", str(target), input=image)
else:
run("wl-copy", "--type", "image/png", input=image)
if not copy_only:
target = destination("Screenshots", ".png")
target.write_bytes(image)
notify("Screenshot saved and copied", str(target))
else:
notify("Screenshot copied")
def screenshot_menu():
actions = [("Region · annotate", "area", True, 0, False),
("Region · copy only", "area", False, 0, True),
("Active window", "window", False, 0, False),
("Current display", "output", False, 0, False),
("All displays", "all", False, 0, False),
("Current display · 5 second delay", "output", False, 5, False),
("Current display · 10 second delay", "output", False, 10, False)]
index = pick("Screenshot", [a[0] for a in actions])
if index is not None:
screenshot(*actions[index][1:])
def recording():
return run("systemctl", "--user", "is-active", "--quiet", "desktop-recording.service", check=False).returncode == 0
def record():
root = runtime()
# Serialize two rapid keypresses so they cannot start competing recorders.
with (root / "record.lock").open("w") as handle:
fcntl.flock(handle, fcntl.LOCK_EX)
if recording():
run("systemctl", "--user", "stop", "desktop-recording.service")
notify("Recording saved", (root / "recording-file").read_text() if (root / "recording-file").exists() else "")
return
index = pick("Record", ["Region · no audio", "Display · no audio", "Display · desktop audio"])
if index is None:
return
geometry = capture_geometry("area" if index == 0 else "output")
if geometry is None:
return
args = list(geometry)
if index == 2:
# Explicit monitor source: never silently record the microphone.
sink = output("pactl", "get-default-sink")
args += ["--audio=" + sink + ".monitor"]
target = destination("Recordings", ".mkv")
run("systemd-run", "--user", "--collect", "--unit=desktop-recording",
"--property=KillSignal=SIGINT", "--property=TimeoutStopSec=20",
"--property=PartOf=graphical-session.target", "--property=UMask=0077",
"--setenv=PATH=" + os.environ["PATH"], "--",
shutil.which("desktop"), "record-run", *args, "-f", str(target))
(root / "recording-file").write_text(str(target))
# The REC bar indicator is the start feedback. A popup here can race
# the recorder's inhibitor and end up embedded in the captured video.
def record_run(args):
# A separate inhibitor does not overwrite the user's DND preference. The
# unit sends SIGINT to both processes; let wf-recorder finish its container.
signal.signal(signal.SIGINT, lambda *_: None)
run("swaync-client", "-Ia", "workstation-recording", "-sw")
try:
run("wf-recorder", *args)
finally:
run("swaync-client", "-Ir", "workstation-recording", "-sw", check=False)
def osd(kind, change):
if kind in ("volume", "microphone"):
device = "@DEFAULT_AUDIO_SOURCE@" if kind == "microphone" else "@DEFAULT_AUDIO_SINK@"
if change == "mute":
run("wpctl", "set-mute", device, "toggle")
else:
run("wpctl", "set-volume", "-l", "1", device, "5%+" if change == "up" else "5%-")
value = output("wpctl", "get-volume", device)
percent = round(float(value.split()[1]) * 100)
notify("Microphone" if kind == "microphone" else "Volume", "Muted" if "MUTED" in value else f"{percent}%", percent)
else:
selector = ["-c", "leds", "-d", "*kbd_backlight*"] if kind == "keyboard" else ["-c", "backlight"]
run("brightnessctl", *selector, "--min-value=1", "set", "+5%" if change == "up" else "5%-")
value = output("brightnessctl", *selector, "-m")
percent = int(value.split(",")[3].rstrip("%"))
notify("Keyboard backlight" if kind == "keyboard" else "Brightness", f"{percent}%", percent)
def night():
current = output("hyprctl", "hyprsunset", "temperature")
temperature = int(current)
run("hyprctl", "hyprsunset", "identity" if temperature < 6000 else "temperature", *([] if temperature < 6000 else ["4200"]))
notify("Night light", "Off" if temperature < 6000 else "4200 K")
def displays():
monitors = json.loads(output("hyprctl", "-j", "monitors"))
choices = [f'{m["name"]} · {m["width"]}×{m["height"]} · {m["scale"]:g}×' for m in monitors]
index = pick("Display", choices)
if index is None:
return
m = monitors[index]
actions = ["Scale 100%", "Scale 125%", "Scale 150%", "Scale 175%", "Scale 200%", "Restore declared configuration"]
action = pick(m["name"], actions)
if action is None:
return
if action == 5:
run("hyprctl", "reload")
return
scale = [1, 1.25, 1.5, 1.75, 2][action]
def set_scale(value):
# Scaling must not silently change refresh rate, dock position or rotation.
mode = f'{m["width"]}x{m["height"]}@{m.get("refreshRate", 60)}'
position = f'{m.get("x", 0)}x{m.get("y", 0)}'
run("hyprctl", "eval", 'hl.monitor({output=' + lua(m["name"]) + ',mode=' + lua(mode)
+ ',position=' + lua(position) + ',transform=' + str(m.get("transform", 0))
+ ',scale=' + str(value) + '})')
set_scale(scale)
# An unattended or invisible confirmation must revert, not strand the user.
try:
result = run("fuzzel", "--dmenu", "--index", "--prompt", "Keep display scale? ",
input="Revert\nKeep\n", text=True, capture_output=True, check=False, timeout=15)
keep = result.returncode == 0 and result.stdout.strip() == "1"
except sp.TimeoutExpired:
keep = False
if not keep:
set_scale(m["scale"])
def web_search():
result = run("fuzzel", "--dmenu", "--prompt", "Search web ", input="", text=True, capture_output=True, check=False)
if result.returncode == 0 and result.stdout.strip():
run("uwsm", "app", "--", "xdg-open", "https://duckduckgo.com/?q=" + quote_plus(result.stdout.strip()))
def emoji():
entries = []
for line in Path(os.environ["DESKTOP_EMOJI_FILE"]).read_text().splitlines():
if "; fully-qualified" not in line:
continue
code, detail = line.split("#", 1)
character = "".join(chr(int(c, 16)) for c in code.split(";", 1)[0].split())
description = re.sub(r"^\S+\s+E[\d.]+\s+", "", detail.strip())
entries.append((character, character + " " + description))
index = pick("Emoji", [entry[1] for entry in entries])
if index is not None:
run("wl-copy", input=entries[index][0].encode())
notify("Emoji copied")
def touchpad():
devices = json.loads(output("hyprctl", "-j", "devices"))
pads = [device["name"] for device in devices["mice"] if re.search("touchpad|trackpad", device["name"], re.I)]
if not pads:
notify("Touchpad", "No touchpad detected")
return
marker = runtime() / "touchpad-disabled"
enabled = marker.exists()
for name in pads:
run("hyprctl", "eval", "hl.device({name=" + lua(name) + ",enabled=" + lua(enabled) + "})")
if enabled:
marker.unlink()
else:
marker.touch(mode=0o600)
notify("Touchpad", "Enabled" if enabled else "Disabled")
def airplane():
disabled = output("nmcli", "radio", "wifi") == "disabled"
if not disabled and not confirm("Disable wireless radios"):
return
run("nmcli", "radio", "wifi", "on" if disabled else "off")
run("rfkill", "unblock" if disabled else "block", "bluetooth")
notify("Airplane mode", "Off" if disabled else "On")
def battery():
last = None
while True:
for path in Path("/sys/class/power_supply").glob("*"):
if (path / "type").read_text().strip() != "Battery" or not (path / "capacity").exists():
continue
capacity = int((path / "capacity").read_text())
discharging = (path / "status").read_text().strip() == "Discharging"
level = "critical" if capacity <= 10 else "low" if capacity <= 20 else None
if discharging and level and level != last:
run("notify-send", "-u", "critical", "Battery " + level, f"{capacity}% remaining — connect power")
last = level if discharging else None
time.sleep(60)
def health():
print("WORKSTATION HEALTH\n")
for label, path in [("Running", "/run/current-system"), ("Booted", "/run/booted-system"),
("Selected for boot", "/nix/var/nix/profiles/system")]:
print(f"{label}: {Path(path).resolve()}")
if Path("/run/booted-system").resolve() != Path("/nix/var/nix/profiles/system").resolve():
print("\nA different generation is selected for the next boot. No automatic reboot.")
run("systemctl", "show", "nixos-update.service", "-p", "ActiveState", "-p", "Result", "-p", "ExecMainStatus")
run("systemctl", "list-timers", "nixos-update.timer", "--no-pager")
run("systemctl", "--failed", "--no-pager")
run("systemctl", "--user", "--failed", "--no-pager")
print("\nRecent update journal (not a package-availability count):", flush=True)
run("journalctl", "-u", "nixos-update.service", "-n", "30", "--no-pager", check=False)
def status():
while True:
active = recording()
print(json.dumps({"text": "REC" if active else "", "alt": "recording" if active else "idle"}), flush=True)
time.sleep(2)
def menu():
entries = [
("Applications / calculator", ["anyrun"]),
("Help · all shortcuts", ["desktop-help"]),
("Windows · overview", ["desktop", "windows"]),
("Notifications", ["desktop", "notifications"]),
("Do not disturb · toggle", ["desktop", "dnd"]),
("Clipboard history", ["desktop", "clipboard"]),
("Clipboard · delete an item", ["desktop", "clipboard-delete"]),
("Clipboard · clear", ["desktop", "clipboard-clear"]),
("Clipboard · pause/resume", ["desktop", "clipboard-pause"]),
("Screenshot", ["desktop", "screenshot-menu"]),
("Recording · start/stop", ["desktop", "record"]),
("Color picker · copy HEX", ["hyprpicker", "--autocopy"]),
("Emoji · copy", ["desktop", "emoji"]),
("Search web", ["desktop", "search"]),
("Audio · outputs and per-app volume", ["pavucontrol"]),
("Microphone · input devices", ["pavucontrol", "-t", "4"]),
("Network / VPN connections", ["nm-connection-editor"]),
("Bluetooth devices", ["blueman-manager"]),
("Airplane mode · toggle", ["desktop", "airplane"]),
("Touchpad · toggle", ["desktop", "touchpad"]),
("Night light · toggle", ["desktop", "night"]),
("Display scale · temporary", ["desktop", "displays"]),
("Power profile", ["desktop", "power-profile"]),
("File manager", ["thunar"]),
("System monitor", ["kitty", "-e", "btop"]),
("System / update health", ["kitty", "--hold", "-e", "desktop", "health"]),
("Session / power", ["desktop", "power"]),
]
index = pick("Workstation", [entry[0] for entry in entries])
if index is not None:
run("uwsm", "app", "--", *entries[index][1])
def main():
command = sys.argv[1] if len(sys.argv) > 1 else "menu"
if command in {"clipboard", "clipboard-store", "clipboard-clear", "clipboard-delete", "clipboard-pause"}:
clipboard({"clipboard": "pick"}.get(command, command.removeprefix("clipboard-")))
elif command == "screenshot":
screenshot(sys.argv[2] if len(sys.argv) > 2 else "area", annotate="--annotate" in sys.argv,
delay=10 if "--delay10" in sys.argv else 5 if "--delay5" in sys.argv else 0)
elif command == "record-run":
record_run(sys.argv[2:])
elif command == "health":
health()
elif command == "osd":
osd(*sys.argv[2:4])
elif command == "notifications":
run("swaync-client", "-t", "-sw")
elif command == "dnd":
run("swaync-client", "-d", "-sw")
elif command == "power-profile":
profiles = output("powerprofilesctl", "list").splitlines()
names = [p.strip().strip("* ").rstrip(":") for p in profiles if p.rstrip().endswith(":") and p.strip().strip("* ").rstrip(":") in ("balanced", "power-saver", "performance")]
index = pick("Power profile", names)
if index is not None:
run("powerprofilesctl", "set", names[index])
elif command in {"menu", "windows", "scratch", "power", "screenshot-menu", "record", "night", "displays", "search", "lock-start", "battery", "status", "emoji", "touchpad", "airplane"}:
{"menu": menu, "windows": window_picker, "scratch": scratch, "power": power,
"screenshot-menu": screenshot_menu, "record": record, "night": night,
"displays": displays, "search": web_search, "lock-start": lock_start,
"battery": battery, "status": status, "emoji": emoji,
"touchpad": touchpad, "airplane": airplane}[command]()
else:
raise ValueError("Unknown desktop action: " + command)
if __name__ == "__main__":
try:
os.umask(0o077)
main()
except (OSError, ValueError, KeyError, RuntimeError, sp.SubprocessError) as error:
print(f"desktop: {error}", file=sys.stderr)
if len(sys.argv) < 2 or sys.argv[1] not in ("clipboard-store", "status"):
notify("Desktop action failed", str(error))
sys.exit(1)
+47
View File
@@ -0,0 +1,47 @@
"""Read-only shortcut search from Hyprland's running binding registry.
Descriptions come from native Lua bindings, not a second hand-maintained cheat
sheet. Choosing a row does NOT execute its command (in particular power/close).
"""
import json
import subprocess
import sys
def rows(bindings):
modifiers = [(64, "Super"), (4, "Ctrl"), (8, "Alt"), (1, "Shift")]
result = []
for binding in bindings:
description = binding.get("description", "")
if not description:
continue
keys = [name for bit, name in modifiers if binding.get("modmask", 0) & bit]
keys.append(binding.get("key") or f"code:{binding.get('keycode', 0)}")
submap = binding.get("submap")
context = f" [{submap}]" if submap else ""
result.append(f"{description}{context} {' + '.join(keys)}")
return sorted(set(result), key=str.casefold)
def main():
try:
data = json.loads(subprocess.check_output(["hyprctl", "-j", "binds"], text=True))
entries = rows(data)
if "--print" in sys.argv:
print("\n".join(entries))
return
if not entries:
raise ValueError("No described shortcuts; reload the managed Hyprland configuration.")
result = subprocess.run(
["fuzzel", "--dmenu", "--prompt", "Shortcuts ", "--width", "68", "--lines", "12"],
input="\n".join(entries), text=True, stdout=subprocess.DEVNULL,
)
if result.returncode not in (0, 1):
raise RuntimeError(f"Shortcut picker exited with {result.returncode}")
except (OSError, ValueError, subprocess.CalledProcessError, RuntimeError) as error:
print(f"desktop-help: {error}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
+63 -7
View File
@@ -35,7 +35,8 @@ def screenshot(name):
def launch(name, command):
user("systemd-run --quiet --user --collect --unit=audit-" + name + " " + command)
# Match UWSM's application lifetime: clipboard owners can outlive main PID.
user("systemd-run --quiet --user --collect --property=ExitType=cgroup --unit=audit-" + name + " " + command)
machine.start()
@@ -45,8 +46,7 @@ try:
"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"]:
for unit in ["ashell", "awww", "hypridle", "hyprpolkitagent", "pipewire", "wireplumber", "swaync", "hyprsunset", "desktop-clipboard", "anyrun"]:
machine.wait_until_succeeds(
"runuser -u dev -- env XDG_RUNTIME_DIR=/run/user/1001 "
"systemctl --user is-active " + unit + ".service", timeout=60
@@ -78,7 +78,7 @@ launch("terminal", "kitty --title 'Workspace ready' sh -c " + shlex.quote(
"printf '\\n WORKSPACE READY\\n\\n'; "
"zsh --version; kitty --version; git --version; printf 'Pi: '; command -v pi; "
"printf '\\n Ctrl-R history | Ctrl-T files\\n'; "
"printf ' Alt-C directories | Super-Space launcher\\n'; "
"printf ' Alt-C directories | Super-D launcher\\n'; "
"printf ' Super-Enter terminal | Super-F fullscreen\\n\\n'; exec zsh -i"
))
launch("monitor", "kitty --title 'System monitor' -e btop")
@@ -88,13 +88,46 @@ screenshot("desktop-100")
launch("launcher", "anyrun")
wait_layer("anyrun")
machine.sleep(5)
machine.sleep(2)
screenshot("launcher-empty-100")
machine.send_chars("kitty", delay=0.1)
machine.sleep(5)
screenshot("launcher-100")
machine.send_key("esc")
# Exercise clipboard through the actual picker, not only mocked byte handling.
session("desktop clipboard-clear")
launch("clipboard-source", "sh -c " + shlex.quote("printf 'clipboard audit marker' | wl-copy --foreground"))
machine.wait_until_succeeds(as_user("test -s /run/user/1001/workstation/clipboard.db"), timeout=30)
launch("clipboard", "desktop clipboard")
wait_layer("launcher")
machine.send_key("ret")
machine.sleep(1)
assert session("wl-paste --no-newline") == "clipboard audit marker"
# Record only this disposable desktop; no host screen/audio is captured.
launch("record", "desktop record")
wait_layer("launcher")
machine.send_key("down") # current output, no audio
machine.send_key("ret")
machine.wait_until_succeeds(as_user("systemctl --user is-active desktop-recording.service"))
machine.sleep(3)
assert session("swaync-client -I -sw").strip() == "true"
screenshot("recording-indicator-100")
session("desktop record")
assert session("swaync-client -I -sw").strip() == "false"
video = session('cat "$XDG_RUNTIME_DIR/workstation/recording-file"').strip()
probe = json.loads(session("ffprobe -v error -show_streams -of json " + shlex.quote(video)))
assert any(stream["codec_type"] == "video" for stream in probe["streams"])
assert not any(stream["codec_type"] == "audio" for stream in probe["streams"])
session("notify-send 'Desktop ready' 'Readable text, working audio and native Wayland services.'")
assert '"mako"' in user("busctl --user call org.freedesktop.Notifications /org/freedesktop/Notifications org.freedesktop.Notifications GetServerInformation")
assert "sway" in user("busctl --user call org.freedesktop.Notifications /org/freedesktop/Notifications org.freedesktop.Notifications GetServerInformation").lower()
assert "Clipboard" in session("desktop-help --print")
session("swaync-client -t -sw")
machine.sleep(1)
screenshot("notification-center-100")
session("swaync-client -t -sw")
machine.sleep(1)
screenshot("notification-100")
@@ -105,6 +138,26 @@ session("hyprctl eval " + shlex.quote(
))
machine.sleep(4)
assert json.loads(session("hyprctl -j monitors"))[0]["scale"] == 1.5
launch("launcher150", "anyrun")
wait_layer("anyrun")
machine.sleep(2)
screenshot("launcher-empty-150")
# closeOnClick uses a transparent full-screen catcher; its IPC dimensions are
# not the visible palette's bounds. Audit the rendered six-result screenshot.
machine.send_chars("e")
machine.sleep(1)
screenshot("launcher-results-150")
session("anyrun close")
launch("help150", "desktop-help")
wait_layer("launcher")
machine.sleep(1)
screenshot("help-150")
machine.send_key("esc")
launch("actions150", "desktop menu")
wait_layer("launcher")
machine.sleep(1)
screenshot("actions-150")
machine.send_key("esc")
# At 150%, a half-screen btop is below its 80-column minimum: use Super-F.
# Closing the launcher can restore focus to either terminal; select btop explicitly.
monitor = next(c for c in json.loads(session("hyprctl -j clients")) if c["initialTitle"] == "System monitor")
@@ -127,6 +180,7 @@ 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)
assert user('test ! -e /run/user/1001/workstation/locked; test ! -e /run/user/1001/workstation/clipboard.db') == ""
# Restore scale before inspecting settings and ordinary application windows.
session("hyprctl eval " + shlex.quote(
@@ -142,7 +196,9 @@ 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")
user("grep -q -- '--password-store=gnome-libsecret' \"$(command -v element-desktop)\"")
user("grep -q -- '--password-store=gnome-libsecret' \"$(command -v element-desktop-nightly)\"")
user("test -f /etc/profiles/per-user/dev/share/applications/element-desktop-nightly.desktop")
user("test ! -e /etc/profiles/per-user/dev/share/applications/element-desktop.desktop")
user("grep -Eq 'fade_on_empty *= *false' ~/.config/hypr/hyprlock.conf")
assert "libapplications.so" in user("cat ~/.config/anyrun/config.ron")
session("hyprctl clients")
+261 -69
View File
@@ -7,9 +7,22 @@
let
c = import ./colors.nix;
wallpaper = pkgs.runCommand "quiet-orbit.png" { nativeBuildInputs = [ pkgs.resvg ]; } ''
resvg ${./wallpaper.svg} "$out"
'';
renderColors =
text:
builtins.replaceStrings (map (name: "@${name}@") (
builtins.attrNames c
)) (builtins.attrValues c) text;
rgb = color: "rgb(${lib.removePrefix "#" color})";
wallpaper = import ./wallpaper.nix { inherit pkgs; };
help = pkgs.writeShellApplication {
name = "desktop-help";
runtimeInputs = [
pkgs.python3
pkgs.hyprland
pkgs.fuzzel
];
text = ''exec python ${./desktop-help.py} "$@"'';
};
wallpaperInit = pkgs.writeShellApplication {
name = "initialize-wallpaper";
runtimeInputs = [
@@ -28,23 +41,42 @@ let
exit 1
'';
};
screenshot = pkgs.writeShellApplication {
name = "desktop-screenshot";
actions = pkgs.writeShellApplication {
name = "desktop";
runtimeInputs = with pkgs; [
python3
hyprland
uwsm
systemd
fuzzel
libnotify
cliphist
wl-clipboard
grim
slurp
satty
wl-clipboard
coreutils
wf-recorder
wireplumber
pulseaudio
brightnessctl
power-profiles-daemon
xdg-user-dirs
xdg-utils
swaynotificationcenter
hyprlock
networkmanager
util-linux
];
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"
export DESKTOP_EMOJI_FILE=${pkgs.unicode-emoji.emoji-test}/share/unicode/emoji/emoji-test.txt
exec python ${./desktop-actions.py} "$@"
'';
};
screenshot = pkgs.writeShellApplication {
name = "desktop-screenshot";
runtimeInputs = [ actions ];
text = ''exec desktop screenshot area --annotate "$@"'';
};
launcherExec = pkgs.writeShellScript "anyrun-uwsm" ''
kind=$1
shift
@@ -93,6 +125,10 @@ in
slurp
satty
screenshot
actions
help
hyprpicker
wf-recorder
pavucontrol
playerctl
brightnessctl
@@ -155,7 +191,8 @@ in
dconf.settings."org/gnome/desktop/interface" = {
color-scheme = "prefer-dark";
font-name = "Inter 11";
monospace-font-name = "JetBrainsMono Nerd Font 13";
monospace-font-name = "JetBrainsMono Nerd Font 12";
accent-color = "yellow";
};
wayland.windowManager.hyprland = {
@@ -164,7 +201,7 @@ in
portalPackage = null;
systemd.enable = false; # UWSM owns the session and environment.
configType = "lua";
extraConfig = builtins.readFile ./hyprland.lua;
extraConfig = renderColors (builtins.readFile ./hyprland.lua);
};
services.hyprpolkitagent.enable = true;
services.awww.enable = true;
@@ -180,21 +217,25 @@ in
settings = {
log_level = "warn";
position = "Top";
layer = "Top";
modules = {
left = [
[
"appLauncher"
"Workspaces"
"desktopHelp"
]
"WindowTitle"
];
center = [ "Tempo" ];
right = [
"recording"
"MediaPlayer"
"SystemInfo"
[
"Tray"
"Privacy"
"Settings"
]
"notifications"
"Tray"
"Privacy"
"Settings"
];
};
CustomModule = [
@@ -203,22 +244,74 @@ in
icon = "󰍜";
command = "uwsm app -- anyrun";
}
{
name = "desktopHelp";
icon = "󰋖";
command = "uwsm app -- desktop-help";
}
{
name = "notifications";
icon = "󰂚";
command = "uwsm app -- desktop notifications";
listen_cmd = "swaync-client -swb";
icons."dnd.*" = "󰂛";
alert = "notification";
}
{
name = "recording";
command = "uwsm app -- desktop record";
listen_cmd = "desktop status";
alert = "recording";
}
];
tempo.clock_format = "%a %d %b %H:%M";
workspaces = {
visibility_mode = "MonitorSpecific";
enable_workspace_filling = true;
enable_esc_key = true;
media_player.max_title_length = 18;
window_title = {
mode = "Title";
truncate_title_after_length = 26;
};
system_info = {
indicators = [
"Cpu"
"Memory"
"Temperature"
];
interval = 5;
};
tempo.clock_format = "%a %d %b %H:%M";
workspaces = {
visibility_mode = "MonitorSpecific";
enable_workspace_filling = false;
workspace_names = [
"01"
"02"
"03"
"04"
"05"
"06"
"07"
"08"
"09"
"10"
];
};
settings = {
lock_cmd = "loginctl lock-session";
logout_cmd = "uwsm stop";
logout_cmd = "uwsm app -- desktop power";
shutdown_cmd = "uwsm app -- desktop power";
reboot_cmd = "uwsm app -- desktop power";
CustomButton = [
{
name = "Actions";
icon = "󰒓";
command = "uwsm app -- desktop menu";
}
{
name = "Health";
icon = "󰄦";
command = "uwsm app -- kitty --hold -e desktop health";
}
];
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";
@@ -227,26 +320,24 @@ in
indicators = [
"IdleInhibitor"
"Audio"
"Microphone"
]
++ lib.optionals networkManager [
"Network"
"Vpn"
]
++ lib.optionals bluetooth [ "Bluetooth" ]
++ lib.optionals power [
"PowerProfile"
"Battery"
"Brightness"
];
++ lib.optionals power [ "Battery" ];
audio_indicator_format = "IconAndPercentage";
};
appearance = {
font_name = "Inter";
scale_factor = 1.15;
style = "Islands";
style = "Solid";
opacity = 1.0;
primary_color = c.blue;
primary_color = {
base = c.accent;
text = c.background;
};
text_color = c.text;
success_color = c.green;
danger_color = c.red;
@@ -256,11 +347,7 @@ in
strong = c.raised;
};
secondary_color.base = c.surface;
workspace_colors = [
c.blue
c.purple
c.cyan
];
workspace_colors = [ c.accent ];
};
};
};
@@ -273,45 +360,149 @@ in
"${pkgs.anyrun}/lib/libapplications.so"
"${pkgs.anyrun}/lib/librink.so"
];
width.fraction = 0.42;
y.fraction = 0.2;
width.fraction = 0.5;
y.fraction = 0.12;
hidePluginInfo = true;
closeOnClick = true;
maxEntries = 8;
showResultsImmediately = true;
maxEntries = 6;
};
extraCss = builtins.readFile ./anyrun.css;
extraCss = renderColors (builtins.readFile ./anyrun.css);
extraConfigFiles."applications.ron".text = ''
(
desktop_actions: false,
max_entries: 8,
hide_description: true,
max_entries: 6,
terminal: Some((command: "uwsm", args: "app -- kitty -e {}")),
preprocess_exec_script: Some("${launcherExec}"),
)
'';
};
services.mako = {
# This pinned launcher needs a daemon for calculator copy-to-clipboard.
systemd.user.services.anyrun = {
Unit = {
Description = "Application launcher and calculator";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
ConditionEnvironment = "WAYLAND_DISPLAY";
};
Service = {
ExecStart = "${pkgs.anyrun}/bin/anyrun daemon";
Restart = "on-failure";
};
Install.WantedBy = [ "graphical-session.target" ];
};
# Fuzzel is the shared dmenu-style picker for actions/help; Anyrun remains
# the app launcher/calculator, not a second competing autostarted shell.
programs.fuzzel = {
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;
main = {
font = "Inter:size=11";
terminal = "kitty";
"launch-prefix" = "uwsm app --";
"line-height" = 22;
width = 46;
lines = 10;
"horizontal-pad" = 18;
"vertical-pad" = 14;
};
colors = {
background = "${lib.removePrefix "#" c.background}ff";
text = "${lib.removePrefix "#" c.text}ff";
match = "${lib.removePrefix "#" c.accent}ff";
selection = "${lib.removePrefix "#" c.selection}ff";
"selection-text" = "${lib.removePrefix "#" c.text}ff";
"selection-match" = "${lib.removePrefix "#" c.accent}ff";
border = "${lib.removePrefix "#" c.border}ff";
prompt = "${lib.removePrefix "#" c.muted}ff";
};
border = {
width = 2;
radius = 6;
};
};
};
services.swaync = {
enable = true;
style = renderColors (builtins.readFile ./swaync.css);
settings = {
positionX = "right";
positionY = "top";
control-center-width = 340;
control-center-height = 500;
control-center-margin-top = 8;
control-center-margin-right = 8;
notification-window-width = 360;
fit-to-screen = false;
timeout = 6;
timeout-critical = 0;
keyboard-shortcuts = true;
hide-on-action = true;
widgets = [
"title"
"dnd"
"mpris"
"notifications"
];
widget-config = {
title = {
text = "Notifications";
clear-all-button = true;
button-text = "Clear";
};
dnd.text = "Do not disturb";
mpris = {
image-size = 64;
image-radius = 4;
};
};
};
};
services.hyprsunset = {
enable = true;
settings.profile = [
{
time = "07:00";
identity = true;
}
{
time = "21:00";
temperature = 4200;
}
];
};
systemd.user.services.desktop-clipboard = {
Unit = {
Description = "Session-local clipboard history";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
ConditionEnvironment = "WAYLAND_DISPLAY";
};
Service = {
ExecStart = "${pkgs.wl-clipboard}/bin/wl-paste --watch ${actions}/bin/desktop clipboard-store";
ExecStopPost = "${actions}/bin/desktop clipboard-clear";
Restart = "on-failure";
UMask = "0077";
};
Install.WantedBy = [ "graphical-session.target" ];
};
systemd.user.services.desktop-battery = lib.mkIf power {
Unit = {
Description = "Low/critical battery notifications";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
ConditionPathExistsGlob = "/sys/class/power_supply/BAT*";
};
Service = {
ExecStart = "${actions}/bin/desktop battery";
Restart = "on-failure";
};
Install.WantedBy = [ "graphical-session.target" ];
};
programs.hyprlock = {
enable = true;
package = null;
@@ -331,9 +522,9 @@ in
{
monitor = "";
text = "cmd[update:1000] date +'%H:%M'";
font_size = 84;
font_size = 64;
font_family = "Inter";
color = "rgb(f2f4f8)";
color = rgb c.text;
position = "0,100";
halign = "center";
valign = "center";
@@ -343,7 +534,7 @@ in
text = "$USER";
font_size = 18;
font_family = "Inter";
color = "rgb(a2a9b0)";
color = rgb c.muted;
position = "0,0";
halign = "center";
valign = "center";
@@ -360,12 +551,13 @@ in
dots_center = true;
fade_on_empty = false;
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)";
rounding = 6;
inner_color = rgb c.surface;
outer_color = rgb c.accent;
font_color = rgb c.text;
check_color = rgb c.green;
fail_color = rgb c.red;
capslock_color = rgb c.yellow;
placeholder_text = "<i>Password</i>";
fail_text = "<i>Try again ($ATTEMPTS)</i>";
}
@@ -377,7 +569,7 @@ in
package = null; # NixOS owns the service and PAM integration.
settings = {
general = {
lock_cmd = "pidof hyprlock || hyprlock";
lock_cmd = "${actions}/bin/desktop lock-start";
before_sleep_cmd = "loginctl lock-session";
after_sleep_cmd = "hyprctl dispatch 'hl.dsp.dpms({ action = \"enable\" })'";
inhibit_sleep = 3;
+83
View File
@@ -0,0 +1,83 @@
# Official Nightly binary, pinned like the other external application sources.
# Keep its matching Electron/native modules together; do not disable the sandbox.
{ pkgs }:
pkgs.stdenv.mkDerivation {
pname = "element-nightly";
version = "2026090401";
src = pkgs.fetchurl {
url = "https://packages.element.io/debian/pool/main/e/element-nightly/element-nightly_2026090401_amd64.deb";
hash = "sha256-lLIkH6MtJWhGgohDFBmdOPEI/YdLTAJ9mkgr+TgSPLc=";
};
nativeBuildInputs = with pkgs; [
dpkg
autoPatchelfHook
wrapGAppsHook3
makeWrapper
];
buildInputs = with pkgs; [
alsa-lib
at-spi2-atk
at-spi2-core
cairo
cups
dbus
expat
glib
gtk3
libgbm
libdrm
libxkbcommon
libx11
libxcb
libxcomposite
libxdamage
libxext
libxfixes
libxrandr
libxrender
libxtst
libxscrnsaver
libxshmfence
nss
nspr
pango
libsecret
stdenv.cc.cc
];
runtimeDependencies = with pkgs; [
libnotify
libGL
libsecret
libpulseaudio
systemd
];
dontUnpack = true;
dontBuild = true;
dontWrapGApps = true;
installPhase = ''
runHook preInstall
dpkg --fsys-tarfile "$src" | tar --extract --no-same-owner
mkdir -p "$out/lib" "$out/bin"
mv opt/Element-Nightly "$out/lib/element-nightly"
mv usr/share "$out/share"
substituteInPlace "$out/share/applications/element-desktop-nightly.desktop" \
--replace-fail /opt/Element-Nightly/element-desktop-nightly "$out/bin/element-desktop-nightly"
runHook postInstall
'';
preFixup = ''
makeWrapper "$out/lib/element-nightly/element-desktop-nightly" "$out/bin/element-desktop-nightly" \
"''${gappsWrapperArgs[@]}" \
--suffix PATH : ${pkgs.lib.makeBinPath [ pkgs.xdg-utils ]} \
--add-flags "--password-store=gnome-libsecret --disable-setuid-sandbox" \
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform=wayland}}"
# Compatibility command only, not a duplicate launcher or a second install.
ln -s element-desktop-nightly "$out/bin/element-desktop"
'';
meta = {
description = "Element Nightly, the upstream development build of the Matrix client";
homepage = "https://element.io/download";
license = pkgs.lib.licenses.agpl3Plus;
platforms = [ "x86_64-linux" ];
mainProgram = "element-desktop-nightly";
};
}
Generated
+9 -9
View File
@@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1787377438,
"narHash": "sha256-Sxu1NLTD/Ern6hFGLlZmtKCSct3YQXZI/lls8RE1XeM=",
"lastModified": 1788642154,
"narHash": "sha256-sPpQFVaFTDqO/4vvCAhuAhqTgqN/ygu+9eJcs5eB0js=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "65258d5c65a250189fde2e35f490d15e064c4c62",
"rev": "fd0956c99c41ae3c13a73a638f1f7e963aebc4ab",
"type": "github"
},
"original": {
@@ -41,11 +41,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1788405554,
"narHash": "sha256-r2f1oUwixlgq9zOdYLqJLfS/lWBT60/IITjhTKI59JU=",
"lastModified": 1788584326,
"narHash": "sha256-Fd3OB8J9JhgliQwOKcqx4M672CInxi1I5VnwsaXeSQo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a5cc6f2c37bf518436dc8d1c288ccd0c43c2f4c4",
"rev": "6713828a351efa628b025a1adf7f43cbf8597513",
"type": "github"
},
"original": {
@@ -57,11 +57,11 @@
},
"nixpkgs-latest": {
"locked": {
"lastModified": 1788659161,
"narHash": "sha256-rIzUUvgwhfJeWwm7y1ZF+xRo2Gep+QYkxpj9mJbG6cM=",
"lastModified": 1788664981,
"narHash": "sha256-7XcoeUSm9v1zn9NHNYNItxIbCUGBTBlTClQDG3qrLQk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "de0ba0a81b0f6eed798a97fdec6f03a754b9d966",
"rev": "94aa2762715b736cea911d96d6a2f3b448f0b296",
"type": "github"
},
"original": {
+17 -2
View File
@@ -31,7 +31,7 @@
];
};
# Physical host: adds dev/Hyprland without removing kbot/Plasma.
# Physical laptop: dev + managed Hyprland; preserve its boot/storage.
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
specialArgs = { inherit inputs; };
modules = [
@@ -41,6 +41,9 @@
};
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt;
packages.x86_64-linux.element-nightly = import ./element-nightly.nix {
pkgs = nixpkgs.legacyPackages.x86_64-linux;
};
nixosModules = {
desktop = ./desktop.nix;
@@ -56,16 +59,28 @@
in
{
desktop = import ./desktop-test.nix { inherit inputs pkgs; };
tools = import ./tools-test.nix {
inherit pkgs;
config = inputs.self.nixosConfigurations.nixos.config;
};
physical-config = import ./physical-test.nix {
inherit pkgs;
config = inputs.self.nixosConfigurations.nixos.config;
};
desktop-actions =
pkgs.runCommand "desktop-actions-check" { nativeBuildInputs = [ pkgs.python3 ]; }
''
python ${./desktop-actions-test.py} ${./desktop-actions.py}
touch "$out"
'';
desktop-config =
pkgs.runCommand "hyprland-config-check" { nativeBuildInputs = [ pkgs.hyprland ]; }
''
export HOME="$TMPDIR/home" XDG_RUNTIME_DIR="$TMPDIR/runtime"
mkdir -m 700 -p "$HOME" "$XDG_RUNTIME_DIR"
Hyprland --verify-config -c ${./hyprland.lua}
Hyprland --verify-config -c ${
inputs.self.nixosConfigurations.nixos.config.home-manager.users.dev.xdg.configFile."hypr/hyprland.lua".source
}
touch "$out"
'';
updates =
+89 -37
View File
@@ -1,18 +1,19 @@
-- Native Hyprland 0.55 configuration, deployed by Home Manager.
-- @color@ tokens are rendered from colors.nix at build time.
-- Services belong to UWSM/systemd, never a second exec-once process tree.
local function rgb(hex) return "rgb(" .. hex:sub(2) .. ")" end
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
hl.config({
general = {
gaps_in = 6, gaps_out = 12, border_size = 2,
gaps_in = 4, gaps_out = 8, 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)",
active_border = { colors = { rgb("@accent@"), rgb("@green@") }, angle = 45 },
inactive_border = rgb("@border@"),
},
},
decoration = {
rounding = 12,
rounding = 6,
active_opacity = 1.0, inactive_opacity = 1.0,
shadow = { enabled = true, range = 16, render_power = 3, color = 0x55000000 },
blur = { enabled = false },
@@ -38,47 +39,98 @@ hl.animation({ leaf = "workspaces", enabled = true, speed = 2.5, bezier = "settl
hl.animation({ leaf = "layers", enabled = true, speed = 2, bezier = "settle", style = "fade" })
hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
-- Every binding is discoverable through hyprctl -j binds / desktop-help.
local function bind(keys, action, description, options)
options = options or {}
options.description = description
hl.bind(keys, action, options)
end
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"))
bind("SUPER + Return", app("kitty"), "Apps · Terminal")
bind("SUPER + D", app("anyrun"), "Apps · Search applications / calculator")
bind("SUPER + A", app("desktop windows"), "Windows · Overview / search")
bind("SUPER + CTRL + S", app("desktop windows"), "Windows · Search all windows")
bind("SUPER + E", app("thunar"), "Apps · Graphical file manager")
bind("SUPER + CTRL + E", app("kitty --class files -e yazi"), "Apps · Yazi terminal file manager")
bind("SUPER + B", app("firefox-esr"), "Apps · Browser")
bind("SUPER + ALT + P", app("keepassxc"), "Apps · Password vault")
bind("SUPER + P", hl.dsp.window.pseudo(), "Windows · Toggle pseudotiling")
bind("SUPER + H", app("desktop-help"), "Help · Search all keyboard shortcuts")
bind("SUPER + SHIFT + K", app("desktop-help"), "Help · Search all keyboard shortcuts")
bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), "Session · Lock screen")
bind("SUPER + Escape", hl.dsp.exec_cmd("loginctl lock-session"), "Session · Lock screen (alias)")
bind("CTRL + ALT + P", app("desktop power"), "Session · Power / logout menu")
bind("SUPER + SHIFT + E", app("desktop menu"), "Desktop · Quick settings / actions")
bind("SUPER + ALT + V", app("desktop clipboard"), "Clipboard · History")
bind("SUPER + ALT + E", app("desktop emoji"), "Clipboard · Emoji picker")
bind("SUPER + ALT + C", app("anyrun"), "Apps · Calculator (enter an expression)")
bind("SUPER + S", app("desktop search"), "Apps · Search web")
bind("SUPER + SHIFT + Return", app("desktop scratch"), "Windows · Drop-down terminal")
bind("SUPER + ALT + R", app("desktop record"), "Capture · Start / stop recording")
bind("SUPER + ALT + T", app("desktop touchpad"), "Input · Toggle touchpad")
bind("SUPER + Q", hl.dsp.window.close(), "Windows · Close active window")
bind("SUPER + Space", hl.dsp.window.float({ action = "toggle" }), "Windows · Toggle floating")
bind("SUPER + F", hl.dsp.window.fullscreen(), "Windows · Toggle fullscreen (alias)")
bind("SUPER + SHIFT + F", hl.dsp.window.fullscreen(), "Windows · Toggle fullscreen")
bind("SUPER + CTRL + F", hl.dsp.window.fullscreen({mode = "maximized"}), "Windows · Toggle maximize")
bind("SUPER + SHIFT + I", hl.dsp.layout("togglesplit"), "Windows · Toggle split direction")
bind("SUPER + N", app("desktop night"), "Display · Toggle night light")
bind("SUPER + SHIFT + N", app("desktop notifications"), "Notifications · Open history / controls")
bind("SUPER + CTRL + N", app("desktop dnd"), "Notifications · Toggle do not disturb")
bind("ALT + Tab", hl.dsp.window.cycle_next(), "Windows · Cycle forward")
bind("ALT + SHIFT + Tab", hl.dsp.window.cycle_next({next = false}), "Windows · Cycle backward")
bind("SUPER + G", hl.dsp.group.toggle(), "Windows · Toggle tabbed group")
bind("SUPER + CTRL + Tab", hl.dsp.group.next(), "Windows · Next group tab")
bind("Print", app("desktop screenshot-menu"), "Capture · Screenshot menu")
bind("SUPER + Print", app("desktop screenshot output"), "Capture · Current display")
bind("SUPER + SHIFT + Print", app("desktop screenshot area"), "Capture · Region")
bind("SUPER + CTRL + Print", app("desktop screenshot output --delay5"), "Capture · Display after 5 seconds")
bind("SUPER + CTRL + SHIFT + Print", app("desktop screenshot output --delay10"), "Capture · Display after 10 seconds")
bind("ALT + Print", app("desktop screenshot window"), "Capture · Active window")
bind("SUPER + SHIFT + S", app("desktop-screenshot"), "Capture · Region and annotation")
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 }))
bind("SUPER + " .. direction, hl.dsp.focus({ direction = direction }), "Windows · Focus " .. direction)
bind("SUPER + CTRL + " .. direction, hl.dsp.window.move({ direction = direction }), "Windows · Move " .. direction)
bind("SUPER + ALT + " .. direction, hl.dsp.window.swap({ direction = direction }), "Windows · Swap " .. direction)
local dx = direction == "left" and -50 or direction == "right" and 50 or 0
local dy = direction == "up" and -50 or direction == "down" and 50 or 0
bind("SUPER + SHIFT + " .. direction, hl.dsp.window.resize({ x = dx, y = dy, relative = true }), "Windows · Resize " .. direction, {repeating = true})
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 }))
bind("SUPER + " .. key, hl.dsp.focus({ workspace = i }), "Workspaces · Focus " .. i)
bind("SUPER + SHIFT + " .. key, hl.dsp.window.move({ workspace = i, follow = true }), "Workspaces · Move and follow to " .. i)
bind("SUPER + CTRL + " .. key, hl.dsp.window.move({ workspace = i, follow = false }), "Workspaces · Move silently to " .. 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 })
bind("SUPER + U", hl.dsp.workspace.toggle_special("scratch"), "Workspaces · Toggle scratchpad")
bind("SUPER + SHIFT + U", hl.dsp.window.move({ workspace = "special:scratch" }), "Workspaces · Move window to scratchpad")
bind("SUPER + Tab", hl.dsp.focus({workspace = "m+1"}), "Workspaces · Next on this monitor")
bind("SUPER + SHIFT + Tab", hl.dsp.focus({workspace = "m-1"}), "Workspaces · Previous on this monitor")
for i, direction in ipairs({"left", "right", "up", "down"}) do
bind("SUPER + CTRL + F" .. (8 + i), hl.dsp.workspace.move({monitor = direction}), "Monitors · Move workspace " .. direction)
end
bind("SUPER + mouse_down", hl.dsp.focus({ workspace = "e+1" }), "Workspaces · Next occupied workspace")
bind("SUPER + mouse_up", hl.dsp.focus({ workspace = "e-1" }), "Workspaces · Previous occupied workspace")
bind("SUPER + mouse:272", hl.dsp.window.drag(), "Windows · Drag with left mouse", { mouse = true })
bind("SUPER + mouse:273", hl.dsp.window.resize(), "Windows · Resize with right mouse", { 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 })
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("desktop osd volume up"), "Media · Volume up", { locked = true, repeating = true })
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("desktop osd volume down"), "Media · Volume down", { locked = true, repeating = true })
bind("XF86AudioMute", hl.dsp.exec_cmd("desktop osd volume mute"), "Media · Toggle speaker mute", { locked = true })
bind("XF86AudioMicMute", hl.dsp.exec_cmd("desktop osd microphone mute"), "Media · Toggle microphone mute", { locked = true })
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("desktop osd brightness up"), "Display · Brightness up", { locked = true, repeating = true })
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("desktop osd brightness down"), "Display · Brightness down", { locked = true, repeating = true })
bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), "Media · Play / pause", { locked = true })
bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), "Media · Next track", { locked = true })
bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), "Media · Previous track", { locked = true })
bind("XF86KbdBrightnessUp", hl.dsp.exec_cmd("desktop osd keyboard up"), "Input · Keyboard backlight up", {locked = true, repeating = true})
bind("XF86KbdBrightnessDown", hl.dsp.exec_cmd("desktop osd keyboard down"), "Input · Keyboard backlight down", {locked = true, repeating = true})
bind("XF86RFKill", app("desktop airplane"), "Network · Toggle airplane mode")
bind("XF86Sleep", hl.dsp.exec_cmd("systemctl suspend"), "Session · Suspend")
hl.window_rule({name = "dropterminal", match = {class = "^dropterminal$"}, workspace = "special:terminal", float = true, center = 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 },
+21 -4
View File
@@ -1,5 +1,15 @@
{ lib, pkgs, ... }:
{
inputs,
lib,
pkgs,
...
}:
let
latest = import inputs.nixpkgs-latest {
inherit (pkgs.stdenv.hostPlatform) system;
config = pkgs.config;
};
in
{
# Keep each host's existing interface/DHCP owner. On EC2 this is dhcpcd;
# NixOS wires its resolvconf hook to resolved's compatibility interface.
@@ -13,8 +23,11 @@
};
};
programs.mtr.enable = true;
environment.systemPackages = with pkgs; [
programs.mtr = {
enable = true;
package = latest.mtr;
};
environment.systemPackages = with latest; [
wireguard-tools # wg and wg-quick; no interfaces or credentials are configured.
openvpn
iperf3
@@ -26,6 +39,10 @@
ethtool
netcat-openbsd
socat
doggo
iftop
bandwhich
wireshark-cli # tshark; no capture group/capabilities or daemon.
];
# Client tools only: no VPN services, peers, keys, routes or firewall ports.
}
+19 -18
View File
@@ -2,7 +2,6 @@
{ config, pkgs }:
let
inherit (pkgs) lib;
kbot = config.users.users.kbot;
dev = config.users.users.dev;
btrfsDevice = "/dev/disk/by-uuid/8a16015f-d6f8-4f74-8558-6261b9112216";
mounts = {
@@ -50,15 +49,11 @@ let
{
assertion =
config.users.mutableUsers
&& kbot.isNormalUser
&& kbot.uid == 1000
&& kbot.home == "/home/kbot"
&& builtins.elem "wheel" kbot.extraGroups
&& builtins.elem "networkmanager" kbot.extraGroups
&& kbot.password == null
&& kbot.hashedPassword == null
&& kbot.hashedPasswordFile == null;
message = "Keep kbot's identity, local password and administrative/network access.";
&& !(config.users.users ? kbot)
&& dev.password == null
&& dev.hashedPassword == null
&& dev.hashedPasswordFile == null;
message = "Retire kbot without overwriting dev's locally established password.";
}
{
assertion =
@@ -68,17 +63,19 @@ let
&& builtins.elem "wheel" dev.extraGroups
&& builtins.elem "networkmanager" dev.extraGroups
&& builtins.attrNames config.home-manager.users == [ "dev" ];
message = "Add dev independently; Home Manager must not manage kbot's home.";
message = "dev is the sole managed daily account, with local administration/network access.";
}
{
assertion =
config.services.displayManager.sddm.enable
&& config.services.desktopManager.plasma6.enable
&& !config.services.desktopManager.plasma6.enable
&& config.programs.hyprland.enable
&& config.programs.hyprland.withUWSM
&& !config.services.greetd.enable
&& !config.services.displayManager.autoLogin.enable;
message = "Keep the Plasma/SDDM recovery login alongside Hyprland, without autologin.";
&& !config.services.displayManager.autoLogin.enable
&& config.services.displayManager.defaultSession == "hyprland-uwsm"
&& !(builtins.elem "hyprland" config.services.displayManager.sessionData.sessionNames);
message = "Offer only the managed Hyprland desktop in SDDM, without Plasma or autologin.";
}
{
assertion =
@@ -91,11 +88,14 @@ let
}
{
assertion =
!(config.systemd.services ? nixos-update)
&& !(config.systemd.timers ? nixos-update)
config.systemd.services.nixos-update.environment.NIXOS_UPDATE_HOST == "nixos"
&& config.systemd.services.nixos-update.environment.NIXOS_CONFIG_REPO == "/etc/nix"
&& config.systemd.services.nixos-update.environment.NIXOS_UPDATE_MODE == "boot"
&& config.systemd.services.nixos-update.serviceConfig.User == "root"
&& config.systemd.timers.nixos-update.timerConfig.Persistent
&& !(config.systemd.services ? amazon-ssm-agent)
&& !(builtins.elem "Z /etc/nixos - dev users -" config.systemd.tmpfiles.rules);
message = "The physical host must not inherit EC2 services, updater or repo ownership.";
message = "Physical updates must stage the physical target, never EC2 or live-session restarts.";
}
{
assertion =
@@ -132,8 +132,9 @@ in
assert lib.all (test: lib.assertMsg test.assertion test.message) tests;
pkgs.runCommand "physical-config-check" { } ''
sessions=${config.services.displayManager.sessionData.desktops}/share
test -f "$sessions/wayland-sessions/plasma.desktop"
test ! -e "$sessions/wayland-sessions/plasma.desktop"
test -f "$sessions/wayland-sessions/hyprland-uwsm.desktop"
test ! -e "$sessions/wayland-sessions/hyprland.desktop"
for tool in wg wg-quick openvpn iperf3 nmap traceroute whois mtr dig tcpdump ethtool nc socat nm-connection-editor; do
test -x "${config.system.path}/bin/$tool"
done
+64 -21
View File
@@ -1,10 +1,28 @@
# This machine: retain the installed kbot/Plasma recovery environment and add dev.
# This machine: dev + managed Hyprland only; preserve boot/storage and old home data.
# Hardware settings were copied from /etc/nixos; those original files stay untouched.
{ pkgs, ... }:
{
config,
lib,
pkgs,
...
}:
let
managedHyprlandSession =
pkgs.runCommand "hyprland-managed-session"
{
passthru.providedSessions = [ "hyprland-uwsm" ];
}
''
mkdir -p "$out/share/wayland-sessions"
ln -s ${config.programs.hyprland.package}/share/wayland-sessions/hyprland-uwsm.desktop \
"$out/share/wayland-sessions/hyprland-uwsm.desktop"
'';
in
{
imports = [
./hardware-configuration.nix
./common.nix
./updates.nix
];
boot.loader = {
@@ -17,7 +35,7 @@
enable = true;
plugins = [ pkgs.networkmanager-openvpn ];
};
# Keep the installed DNS setup while testing the additional desktop.
# Keep NetworkManager's installed DNS policy.
services.resolved.enable = false;
time.timeZone = "America/Chicago";
@@ -34,8 +52,8 @@
LC_TIME = "en_US.UTF-8";
};
# SDDM offers both Plasma and Hyprland (uwsm-managed). Do not replace it with
# workstation.nix's greetd/ReGreet or enable autologin during this migration.
# Keep the working SDDM greeter; only the managed Hyprland session is offered.
# No autologin, second desktop environment, or competing session owner.
services.xserver = {
enable = true;
xkb = {
@@ -43,32 +61,57 @@
variant = "";
};
};
services.displayManager.sddm.enable = true;
services.desktopManager.plasma6.enable = true;
services.displayManager = {
sddm.enable = true;
defaultSession = "hyprland-uwsm";
# The upstream package also advertises a plain session that never starts
# our bar/idle/polkit services. Hide that trap, not the managed startup.
sessionPackages = lib.mkForce [ managedHyprlandSession ];
};
services.printing.enable = true;
services.pulseaudio.enable = false;
# Preserve kbot's existing password and allow `sudo passwd dev` after activation.
# No password hashes or credentials are copied into this repository/the store.
# Preserve dev's locally established password. Removing the previously
# managed kbot account does NOT request deletion of /home/kbot.
users.mutableUsers = true;
users.users.kbot = {
isNormalUser = true;
uid = 1000;
home = "/home/kbot";
description = "kbot";
extraGroups = [
"networkmanager"
"wheel"
];
packages = [ pkgs.kdePackages.kate ];
};
users.users.dev.extraGroups = [ "networkmanager" ];
environment.systemPackages = [ pkgs.networkmanagerapplet ];
services.udisks2.enable = true;
services.gvfs.enable = true;
services.fwupd.enable = true;
home-manager.users.dev.services.udiskie.enable = true;
hardware.bluetooth = {
enable = true;
powerOnBoot = false;
};
services.blueman.enable = true;
services.upower.enable = true;
services.power-profiles-daemon.enable = true;
# sysinfo's component label for this Intel CPU, not another laptop's ACPI zone.
home-manager.users.dev.programs.ashell.settings.system_info.temperature.sensor =
"coretemp Package id 0";
home-manager.users.dev.xdg.configFile."autostart/blueman.desktop".text = ''
[Desktop Entry]
Type=Application
Name=Blueman
Hidden=true
'';
# Retain the installed host's package policy.
nixpkgs.config.allowUnfree = true;
# Intentionally no updates.nix: that updater builds the EC2 dev target.
# /etc/nix is administrator-owned. Stage tested updates for the next boot,
# rather than restarting a compositor/display manager under an active user.
systemd.services.nixos-update = {
environment = {
NIXOS_CONFIG_REPO = "/etc/nix";
NIXOS_UPDATE_HOST = "nixos";
NIXOS_UPDATE_MODE = "boot";
};
serviceConfig = {
User = "root";
Group = "root";
};
};
# Neither /etc/nixos nor /home/kbot is handed over to dev/Home Manager.
}
+31
View File
@@ -0,0 +1,31 @@
* { font-family: Inter, sans-serif; font-size: 14px; color: @text@; }
.control-center {
background: @background@;
border: 1px solid @border@;
border-radius: 6px;
padding: 10px;
}
.notification {
background: @surface@;
border: 1px solid @border@;
border-radius: 6px;
margin: 6px;
}
.notification.critical { border-color: @red@; }
/* Keep keyboard focus visible without the upstream thick gray outer slab. */
.notification-row:focus, .notification-group:focus { background: transparent; }
.notification-row:focus .notification, .notification-group:focus .notification { border-color: @accent@; }
.notification-row .notification-background .notification { box-shadow: none; }
.notification-content { padding: 12px; }
.summary { font-weight: 600; }
.time, .body { color: @muted@; }
button { background: @raised@; border-radius: 4px; border: none; padding: 6px 10px; }
button:hover { background: @selection@; }
button:checked, switch:checked { background: @accent@; color: @background@; }
.widget-title { margin: 8px; }
.widget-title > label { font-size: 18px; font-weight: 600; }
.widget-dnd, .widget-volume, .widget-backlight, .widget-mpris { margin: 8px; }
.widget-mpris-player { background: @surface@; border-radius: 6px; padding: 8px; }
trough { background: @raised@; border-radius: 4px; }
highlight, progress { background: @accent@; border-radius: 4px; }
.close-button { background: @raised@; color: @text@; }
+58
View File
@@ -0,0 +1,58 @@
# Bounded, offline smoke tests. No user caches, credentials, servers or downloads.
{ config, pkgs }:
pkgs.runCommand "workstation-tools-check"
{
nativeBuildInputs = [ pkgs.python3 ];
}
''
export HOME="$TMPDIR/home"
mkdir -p "$HOME"
export PATH=${config.system.path}/bin:$PATH
for tool in pi git git-lfs gh glab cmake ninja gdb \
cargo rustc rustfmt cargo-clippy cargo-nextest go gopls dlv uv ruff pyright \
node pnpm tsc biome bun deno shellcheck shfmt just hyperfine watchexec \
nom nvd nix-tree nix-diff statix deadnix nixd \
jq yq jless mlr csvlens sqlite3 duckdb pgcli \
xh grpcurl websocat aws skopeo buildah podman podman-compose dive \
kubectl helm k9s kubectx stern kustomize tofu ansible \
age sops gpg gitleaks trivy restic rclone dust duf ncdu procs lnav \
ffmpeg magick mediainfo exiftool pdftotext pandoc yt-dlp chafa desktop-help \
valgrind heaptrack rr eu-readelf bpftrace java javac mvn gradle kotlin \
dotnet ruby bundle php composer zig zls elixir erl protoc buf \
dprint stylua taplo marksman markdownlint-cli2 sqlfluff hadolint ast-grep rga \
hurl oha step mkcert cosign syft grype mosh sshfs asciinema vhs; do
command -v "$tool" >/dev/null
done
test -x ${config.home-manager.users.dev.programs.lazygit.package}/bin/lazygit
test -x ${config.home-manager.users.dev.programs.tmux.package}/bin/tmux
pi --version
uv --version
ruff --version
cargo --version
rustc --version
go version
node --version
java -version
dotnet --version
zig version
ruby --version
php --version | head -1
printf 'select 42;\n' | sqlite3 | grep -qx 42
python - <<'PY'
import importlib.util
spec = importlib.util.spec_from_file_location("desktop_help", "${./desktop-help.py}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
rows = module.rows([
{"modmask": 65, "key": "H", "description": "Help"},
{"modmask": 0, "key": "Print", "description": "Capture"},
{"modmask": 64, "key": "Q", "description": "Close; $(touch /not-executed)"},
{"modmask": 0, "key": "", "keycode": 20, "description": "Code", "submap": "resize"},
{"key": "undocumented"},
])
assert "Help Super + Shift + H" in rows
assert "Code [resize] code:20" in rows
assert len(rows) == 4
PY
touch "$out"
''
+248 -24
View File
@@ -1,40 +1,209 @@
{ pkgs, ... }:
{ inputs, pkgs, ... }:
let
c = import ./colors.nix;
# Standalone tools from the fast-moving pin, NOT an overlay of the system's
# Python/GCC/libraries. Desktop, drivers and NixOS services remain coherent.
latest = import inputs.nixpkgs-latest {
inherit (pkgs.stdenv.hostPlatform) system;
config = pkgs.config;
};
in
{
programs.git.enable = true;
programs.git = {
enable = true;
package = latest.git;
lfs.enable = true;
lfs.package = latest.git-lfs;
};
programs.zsh.enable = true;
# Native module supplies rootless mappings/networking. No Docker daemon,
# docker-group access, public API socket, containers or images on activation.
virtualisation.podman.enable = true;
# Mason's upstream Linux executables expect a conventional dynamic loader.
programs.nix-ld.enable = true;
environment.systemPackages = with pkgs; [
nixfmt
# Overridden in common.nix to stay current beyond stable release lag.
environment.systemPackages = [
pkgs.nixfmt
]
++ (with latest; [
pi-coding-agent
# Runtime build prerequisites for the unchanged Lazy/Mason plugin workflow.
# Native builds and the unchanged Lazy/Mason runtime prerequisites.
gcc
gnumake
pkg-config
cmake
ninja
meson
ccache
python3
nodejs
lua5_1
luajitPackages.luarocks
clang-tools
gdb
lldb
valgrind
heaptrack
rr
elfutils
bpftrace
# Language toolchains, testing and dependency/security audits.
rustc
cargo
rustfmt
clippy
rust-analyzer
cargo-nextest
cargo-audit
cargo-deny
cargo-expand
cargo-edit
go
gopls
delve
golangci-lint
uv
ruff
pyright
pnpm
typescript
biome
bun
deno
# JVM, .NET, Ruby/PHP, Zig and BEAM: project versions still belong in devShells.
jdk25
maven
gradle_9
kotlin
dotnet-sdk_10
ruby
bundler
php
phpPackages.composer
zig
zls
beamPackages.elixir
beamPackages.erlang
protobuf
buf
shellcheck
shfmt
just
hyperfine
watchexec
tokei
yamllint
actionlint
pre-commit
dprint
stylua
taplo
marksman
markdownlint-cli2
sqlfluff
hadolint
ast-grep
ripgrep-all
# Version control: no invented identity, login or credentials.
gh
glab
git-absorb
git-filter-repo
difftastic
jujutsu
# Nix introspection and development; never replace the system Nix daemon.
nix-output-monitor
nvd
nix-tree
nix-diff
statix
deadnix
nixd
nixpkgs-review
# Shell, structured data, file navigation and documentation.
ripgrep
fd
eza
jq
yq-go
jless
sd
tree
file
hexyl
parallel
moreutils
tealdeer
zellij
miller
csvlens
sqlite
duckdb
pgcli
litecli
redis
# HTTP/API clients. Packet-level tools belong in network.nix.
curl
wget
file
tree
xh
grpcurl
websocat
hurl
oha
step-cli
mkcert # Installed only: no CA is created or trusted automatically.
# Cloud, containers and orchestration: clients only, no live infrastructure.
awscli2
skopeo
buildah
podman-compose
dive
kubectl
kubernetes-helm
k9s
kubectx
stern
kustomize
opentofu
ansible
# Encryption, secret scanning, backup and transfer. No automatic jobs or keys.
age
sops
gnupg
gitleaks
trivy
cosign
syft
grype
restic
rclone
rsync
mosh
sshfs
openssl
unzip
zip
p7zip
rsync
openssl
zstd
lz4
# Logs, storage, process and hardware diagnosis. No extra privileges granted.
dust
duf
ncdu
procs
sysstat
iotop
lnav
lsof
strace
psmisc
@@ -45,7 +214,19 @@ in
lm_sensors
man-pages
man-pages-posix
];
# Media/document tooling and rich Yazi previews.
ffmpeg
imagemagick
mediainfo
exiftool
poppler-utils
pandoc
yt-dlp
chafa
asciinema
vhs
]);
fonts.packages = [ pkgs.nerd-fonts.jetbrains-mono ];
fonts.fontconfig.defaultFonts.monospace = [ "JetBrainsMono Nerd Font" ];
@@ -57,6 +238,43 @@ in
settings.user.useConfigOnly = true;
};
programs.delta = {
enable = true;
package = latest.delta;
enableGitIntegration = true;
options = {
navigate = true;
line-numbers = true;
};
};
programs.lazygit = {
enable = true;
package = latest.lazygit;
settings.gui = {
nerdFontsVersion = "3";
showRandomTip = false;
};
};
programs.direnv = {
enable = true;
package = latest.direnv;
enableZshIntegration = true;
nix-direnv = {
enable = true;
package = latest.nix-direnv;
};
# Deliberately no whitelist: each project's .envrc needs `direnv allow`.
};
programs.tmux = {
enable = true;
package = latest.tmux;
terminal = "tmux-256color";
mouse = true;
keyMode = "vi";
historyLimit = 50000;
escapeTime = 10;
};
programs.zsh = {
enable = true;
enableCompletion = true;
@@ -86,6 +304,7 @@ in
programs.fzf = {
enable = true;
enableZshIntegration = true;
package = latest.fzf;
defaultCommand = "fd --type f --hidden --exclude .git";
fileWidgetCommand = "fd --type f --hidden --exclude .git";
changeDirWidgetCommand = "fd --type d --hidden --exclude .git";
@@ -103,7 +322,7 @@ in
hl = c.cyan;
"hl+" = c.cyan;
border = c.border;
prompt = c.blue;
prompt = c.accent;
pointer = c.purple;
marker = c.green;
info = c.muted;
@@ -112,11 +331,12 @@ in
programs.starship = {
enable = true;
package = latest.starship;
settings = {
add_newline = true;
format = "$username$hostname$directory$git_branch$git_status$nix_shell$cmd_duration\n$character";
directory = {
style = "bold ${c.blue}";
style = "bold ${c.accent}";
truncation_length = 4;
truncation_symbol = "/";
read_only = " [read-only]";
@@ -143,14 +363,17 @@ in
programs.zoxide = {
enable = true;
package = latest.zoxide;
enableZshIntegration = true;
};
programs.bat = {
enable = true;
package = latest.bat;
config.theme = "base16";
};
programs.btop = {
enable = true;
package = latest.btop;
settings = {
theme_background = false;
rounded_corners = true;
@@ -159,6 +382,7 @@ in
};
programs.yazi = {
enable = true;
package = latest.yazi;
enableZshIntegration = true;
settings.mgr = {
show_hidden = true;
@@ -171,10 +395,10 @@ in
enable = true;
font = {
name = "JetBrainsMono Nerd Font";
size = 13;
size = 12;
};
settings = {
window_padding_width = 14;
window_padding_width = 10;
background_opacity = "1.0";
hide_window_decorations = true;
scrollback_lines = 20000;
@@ -186,9 +410,9 @@ in
background = c.background;
cursor = c.cyan;
selection_foreground = c.text;
selection_background = "#354562";
selection_background = c.selection;
url_color = c.blue;
active_border_color = c.blue;
active_border_color = c.accent;
inactive_border_color = c.border;
color0 = c.surface;
color1 = c.red;
@@ -197,14 +421,14 @@ in
color4 = c.blue;
color5 = c.purple;
color6 = c.cyan;
color7 = "#dde1e6";
color7 = c.text;
color8 = c.muted;
color9 = "#ff99a0";
color10 = "#6fdc8c";
color11 = "#f7d75c";
color12 = "#a6c8ff";
color13 = "#d4bbff";
color14 = "#82e9de";
color9 = "#eda692";
color10 = "#b8c992";
color11 = "#e6ca91";
color12 = "#a7c2c8";
color13 = "#c9b9d1";
color14 = "#adcbb7";
color15 = c.text;
};
};
+37 -11
View File
@@ -1,5 +1,12 @@
# Run as dev; privileged activation uses the already declared scoped sudo rule.
repo=${NIXOS_CONFIG_REPO:-/etc/nixos}
# Host and activation policy come from the host's systemd unit, never inference
# from a login name. Physical builds are staged for next boot; EC2 switches live.
repo=${NIXOS_CONFIG_REPO:?Set NIXOS_CONFIG_REPO}
host=${NIXOS_UPDATE_HOST:?Set NIXOS_UPDATE_HOST}
mode=${NIXOS_UPDATE_MODE:?Set NIXOS_UPDATE_MODE}
case "$host:$mode" in
dev:switch|nixos:boot) ;;
*) echo "Refusing unsupported update target/policy: $host:$mode" >&2; exit 2 ;;
esac
state=${CACHE_DIRECTORY:-/var/cache/nixos-update}
mkdir -p "$state"
exec 9>"$state/lock"
@@ -11,7 +18,10 @@ if [ -n "$(git status --porcelain)" ]; then
exit 0
fi
baseline=$(git rev-parse HEAD)
branch=$(git symbolic-ref HEAD)
if ! branch=$(git symbolic-ref -q HEAD); then
echo 'Skipping automatic update: checkout is detached.'
exit 0
fi
work=$(mktemp -d "$state/work.XXXXXXXX")
cleanup() {
git -C "$repo" worktree remove --force "$work" >/dev/null 2>&1 || true
@@ -29,32 +39,48 @@ if git diff --quiet -- flake.lock; then
fi
nix flake check --no-build --no-update-lock-file
nix build .#checks.x86_64-linux.updates .#checks.x86_64-linux.desktop-config \
.#checks.x86_64-linux.physical-config .#checks.x86_64-linux.tools \
.#checks.x86_64-linux.desktop-actions \
--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 NixOS package inputs'
built=$(nix build .#nixosConfigurations.dev.config.system.build.toplevel \
built=$(nix build ".#nixosConfigurations.$host.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
unchanged() {
[ "$(git rev-parse HEAD)" = "$baseline" ] &&
[ "$(git symbolic-ref -q HEAD)" = "$branch" ] &&
[ -z "$(git status --porcelain)" ]
}
if ! unchanged; then
echo 'Configuration changed during the build; leaving it untouched.'
exit 0
fi
previous=$(readlink -f /run/current-system)
if [ "$mode" = boot ]; then
# Preserve an already staged generation on failure, not just the running one.
previous=$(readlink -f /nix/var/nix/profiles/system)
else
previous=$(readlink -f /run/current-system)
fi
sudo "$built/sw/bin/nixos-rebuild" dry-activate --no-reexec --store-path "$built"
if ! unchanged; then
echo 'Configuration changed during dry activation; leaving it untouched.'
exit 0
fi
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 ! sudo "$built/sw/bin/nixos-rebuild" "$mode" --no-reexec --store-path "$built"; then
echo 'Activation failed; restoring the previous system/profile. See the journal.' >&2
sudo "$previous/sw/bin/nixos-rebuild" "$mode" --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
printf '%s %s %s\n' "$(date -Is)" "$candidate" "$built" > "$state/last-success"
echo "Updated $host ($mode): $built"
# No forced reboot or garbage collection: recovery generations are retained.
+33 -15
View File
@@ -23,6 +23,9 @@ if name == "nix":
elif args[:2] == ["flake", "check"]:
if scenario == "evaluation-failure": sys.exit(42)
elif args[0] == "build":
targets = [arg for arg in args if "nixosConfigurations." in arg]
if targets:
assert targets == [f'.#nixosConfigurations.{os.environ["NIXOS_UPDATE_HOST"]}.config.system.build.toplevel']
if scenario == "build-failure": sys.exit(43)
if scenario == "concurrent-edit":
(pathlib.Path(os.environ["NIXOS_CONFIG_REPO"]) / "notes").write_text("user work\n")
@@ -32,17 +35,20 @@ if name == "nix":
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)
if scenario == "concurrent-dry-edit" and args[1] == "dry-activate":
(pathlib.Path(os.environ["NIXOS_CONFIG_REPO"]) / "notes").write_text("user work\n")
if scenario in ("activation-failure", "rollback-failure") and args[1] == os.environ["NIXOS_UPDATE_MODE"]:
if args[-1] == os.environ["BUILT"] or scenario == "rollback-failure": sys.exit(45)
elif name == "readlink":
assert args == ["-f", "/run/current-system"]
previous = "/nix/var/nix/profiles/system" if os.environ["NIXOS_UPDATE_MODE"] == "boot" else "/run/current-system"
assert args == ["-f", previous]
print(os.environ["PREVIOUS"])
else:
raise AssertionError(name)
'''
def run_case(scenario):
def run_case(scenario, host="dev", mode="switch"):
with tempfile.TemporaryDirectory(prefix="update-test-") as directory:
root = Path(directory)
repo = root / "repo with spaces"
@@ -52,7 +58,8 @@ def run_case(scenario):
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,
NIXOS_CONFIG_REPO=str(repo), NIXOS_UPDATE_HOST=host,
NIXOS_UPDATE_MODE=mode, 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")
@@ -77,37 +84,48 @@ def run_case(scenario):
baseline = git("rev-parse", "HEAD")
if scenario == "dirty":
(repo / "notes").write_text("user work\n")
if scenario == "detached":
git("checkout", "--detach", "--quiet")
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")
failed = scenario.endswith("failure") or scenario == "invalid-host"
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 [call[2] for call in sudo] == ["dry-activate", mode]
assert (cache / "last-success").is_file()
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 [call[2] for call in sudo] == ["dry-activate", mode, mode]
assert sudo[-1][-1] != env["BUILT"]
assert git("log", "-1", "--format=%s").startswith("Revert")
elif scenario == "rollback-failure":
# Keep the candidate commit for recovery, never claim rollback worked.
assert json.loads((repo / "flake.lock").read_text())["revision"] == 2
assert [call[2] for call in sudo] == ["dry-activate", mode, mode]
assert not (cache / "last-success").exists()
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 not sudo or scenario in ("dry-activation-failure", "concurrent-dry-edit")
assert (repo / "unchanged-editor-input").read_text().strip() == "380eb86778a7c53a0f1c18e84f14037456155347"
assert len(git("worktree", "list", "--porcelain").split("worktree ")) == 2
if scenario in ["dirty", "concurrent-edit"]:
if scenario in ["dirty", "concurrent-edit", "concurrent-dry-edit"]:
assert (repo / "notes").read_text() == "user work\n"
if scenario == "dirty":
if scenario in ("dirty", "detached", "invalid-host"):
assert not calls
print("PASS", scenario)
print("PASS", host, mode, scenario)
for scenario in ["dirty", "unchanged", "evaluation-failure", "build-failure", "concurrent-edit",
"dry-activation-failure", "activation-failure", "success"]:
run_case(scenario)
for host, mode in [("dev", "switch"), ("nixos", "boot")]:
for scenario in ["dirty", "detached", "unchanged", "evaluation-failure", "build-failure",
"concurrent-edit", "concurrent-dry-edit", "dry-activation-failure",
"activation-failure", "rollback-failure", "success"]:
run_case(scenario, host, mode)
run_case("invalid-host", "nixos", "switch")
+15 -6
View File
@@ -1,4 +1,9 @@
{ pkgs, ... }:
{
config,
lib,
pkgs,
...
}:
let
updater = pkgs.writeShellApplication {
name = "update-system";
@@ -13,17 +18,21 @@ let
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";
description = "Build and record host-specific NixOS/tool updates without disturbing local work";
environment = {
NIXOS_CONFIG_REPO = lib.mkDefault "/etc/nixos";
NIXOS_UPDATE_HOST = lib.mkDefault "dev";
NIXOS_UPDATE_MODE = lib.mkDefault "switch";
};
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
path = [ "/run/wrappers" ];
serviceConfig = {
Type = "oneshot";
User = "dev";
Group = "users";
WorkingDirectory = "/etc/nixos";
User = lib.mkDefault "dev";
Group = lib.mkDefault "users";
WorkingDirectory = config.systemd.services.nixos-update.environment.NIXOS_CONFIG_REPO;
CacheDirectory = "nixos-update";
UMask = "0077";
Nice = 10;
+8
View File
@@ -0,0 +1,8 @@
# The image is fetched reproducibly, not downloaded by a login/startup script.
# Source/attribution details and the original fallback are documented in DESKTOP.md.
{ pkgs }:
pkgs.fetchurl {
name = "one-ring-dark-1920x1200.jpg";
url = "https://w.wallhaven.cc/full/01/wallhaven-01e5v4.jpg";
hash = "sha256-3jkKzJ0q4MTlHygwUs3SuSiUIjUjkiTqSaM+q8EL/oc=";
}