diff --git a/.gitignore b/.gitignore index 6990134..18d9072 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,9 @@ !/.env.example /secrets/ -# Editor temporary files. +# Python bytecode and editor temporary files. +__pycache__/ +*.pyc *.swp *.swo *~ diff --git a/DESKTOP.md b/DESKTOP.md index 6f57fa1..827d3ec 100644 --- a/DESKTOP.md +++ b/DESKTOP.md @@ -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 130V–140V**, 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. diff --git a/README.md b/README.md index b3aac2c..fc379b2 100644 --- a/README.md +++ b/README.md @@ -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 `` 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/.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 `` 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/.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. diff --git a/anyrun.css b/anyrun.css index ef776c7..ae3e44c 100644 --- a/anyrun.css +++ b/anyrun.css @@ -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@; } diff --git a/apps.nix b/apps.nix index a6ca4d9..b03b040 100644 --- a/apps.nix +++ b/apps.nix @@ -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" ]; }; }; }; diff --git a/colors.nix b/colors.nix index 6d67e31..4aa4ce7 100644 --- a/colors.nix +++ b/colors.nix @@ -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"; } diff --git a/common.nix b/common.nix index 38772ba..2e4f9b7 100644 --- a/common.nix +++ b/common.nix @@ -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; diff --git a/desktop-actions-test.py b/desktop-actions-test.py new file mode 100644 index 0000000..cc43b8a --- /dev/null +++ b/desktop-actions-test.py @@ -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() diff --git a/desktop-actions.py b/desktop-actions.py new file mode 100644 index 0000000..926a595 --- /dev/null +++ b/desktop-actions.py @@ -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) diff --git a/desktop-help.py b/desktop-help.py new file mode 100644 index 0000000..73813a0 --- /dev/null +++ b/desktop-help.py @@ -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() diff --git a/desktop-test.py b/desktop-test.py index f363d7f..cfe8146 100644 --- a/desktop-test.py +++ b/desktop-test.py @@ -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") diff --git a/desktop.nix b/desktop.nix index 686bbc5..5165932 100644 --- a/desktop.nix +++ b/desktop.nix @@ -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 = "Password"; fail_text = "Try again ($ATTEMPTS)"; } @@ -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; diff --git a/element-nightly.nix b/element-nightly.nix new file mode 100644 index 0000000..8c816bc --- /dev/null +++ b/element-nightly.nix @@ -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"; + }; +} diff --git a/flake.lock b/flake.lock index 44cebcf..a746b26 100644 --- a/flake.lock +++ b/flake.lock @@ -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": { diff --git a/flake.nix b/flake.nix index 9b2108d..593aa79 100644 --- a/flake.nix +++ b/flake.nix @@ -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 = diff --git a/hyprland.lua b/hyprland.lua index 9996db8..cd1aa3b 100644 --- a/hyprland.lua +++ b/hyprland.lua @@ -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 }, diff --git a/network.nix b/network.nix index 69a1941..d112a2d 100644 --- a/network.nix +++ b/network.nix @@ -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. } diff --git a/physical-test.nix b/physical-test.nix index a5ee393..965993b 100644 --- a/physical-test.nix +++ b/physical-test.nix @@ -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 diff --git a/physical.nix b/physical.nix index 24eaff0..a72479f 100644 --- a/physical.nix +++ b/physical.nix @@ -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. } diff --git a/swaync.css b/swaync.css new file mode 100644 index 0000000..34613af --- /dev/null +++ b/swaync.css @@ -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@; } diff --git a/tools-test.nix b/tools-test.nix new file mode 100644 index 0000000..0ea2cd6 --- /dev/null +++ b/tools-test.nix @@ -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" + '' diff --git a/tools.nix b/tools.nix index 1a9827c..0a2359e 100644 --- a/tools.nix +++ b/tools.nix @@ -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; }; }; diff --git a/update-system.sh b/update-system.sh index b14a5f1..f2ec581 100644 --- a/update-system.sh +++ b/update-system.sh @@ -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. diff --git a/update-test.py b/update-test.py index 2f0ea6f..cf7301c 100644 --- a/update-test.py +++ b/update-test.py @@ -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 " - 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") diff --git a/updates.nix b/updates.nix index e1bb6ff..13710c0 100644 --- a/updates.nix +++ b/updates.nix @@ -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; diff --git a/wallpaper.nix b/wallpaper.nix new file mode 100644 index 0000000..876820d --- /dev/null +++ b/wallpaper.nix @@ -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="; +}