feat: complete and visually audit the Hyprland workstation
Refresh system/tool pins, install official Element Nightly, expand development tools, and finish desktop workflows with a shared charcoal/gold design. Retain host-specific updates and NixOS recovery generations.
This commit is contained in:
+3
-1
@@ -13,7 +13,9 @@
|
|||||||
!/.env.example
|
!/.env.example
|
||||||
/secrets/
|
/secrets/
|
||||||
|
|
||||||
# Editor temporary files.
|
# Python bytecode and editor temporary files.
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|||||||
+69
-53
@@ -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`.
|
- 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.
|
||||||
- Home Manager: `65258d5c65a250189fde2e35f490d15e064c4c62`.
|
- 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.
|
||||||
- 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.
|
- 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 |
|
## What the audit repaired
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 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. |
|
|
||||||
|
|
||||||
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).
|
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.
|
||||||
- **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).
|
|
||||||
|
|
||||||
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.
|
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.
|
||||||
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/).
|
|
||||||
|
|
||||||
## 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.
|
**Account/hardware boundaries:**
|
||||||
- **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.
|
|
||||||
|
|
||||||
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.
|
[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.
|
||||||
- **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.
|
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
@@ -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 |
|
| File | Owns |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `flake.nix`, `flake.lock` | Host entry point and exact Nixpkgs/Home Manager/dotfile revisions and content hashes |
|
| `flake.nix`, `flake.lock` | Host entry points, exact Nixpkgs/Home Manager/tool/dotfile revisions and hashes |
|
||||||
| `configuration.nix` | EC2-only boot/storage/integration, updater and repo ownership |
|
| `common.nix` | Shared environment, platform, Nix features and compatibility version |
|
||||||
| `common.nix` | Shared dev 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 |
|
||||||
| `physical.nix`, `hardware-configuration.nix` | This physical host's systemd-boot/NVMe/Btrfs setup; retains `kbot`, Plasma/SDDM and NetworkManager while adding `dev` |
|
| `configuration.nix` | EC2-only boot/storage/SSM integration, updater and repo ownership |
|
||||||
| `physical-test.nix` | Regression checks for physical-host recovery access, storage, boot and VPN tools |
|
| `users.nix` | `dev`, SSH authorization, sudo, Home Manager and workspace ownership |
|
||||||
| `users.nix` | `dev`, SSH/sudo, Home Manager integration and dev workspace ownership |
|
| `tools.nix`, `network.nix` | Development toolkit, terminal/shell, rootless Podman, VPN clients and network diagnostics |
|
||||||
| `dev-authorized-keys` | Public SSH keys for `dev`—never private keys |
|
| `colors.nix`, `wallpaper.nix`, `wallpaper.svg` | Shared One Ring palette, hash-pinned wallpaper and original fallback artwork |
|
||||||
| `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`, `swaync.css`, `desktop-help.py`, `desktop-actions.py` | Session/bar, launchers, described help, capture, clipboard, notifications, lock/idle and styling |
|
||||||
| `desktop.nix`, `hyprland.lua`, `anyrun.css`, `wallpaper.svg` | Hyprland/UWSM, bar, launcher, original wallpaper, notifications, lock/idle, PipeWire and desktop styling |
|
| `apps.nix`, `element-nightly.nix` | Firefox ESR, KeePassXC, Thunderbird, Steam, pinned Element Nightly, Slack, file/media viewers and MIME defaults |
|
||||||
| `apps.nix` | Firefox ESR, KeePassXC, Thunderbird, Steam, Element, Slack and Zathura |
|
| `neovim.nix`, `neovim-test.lua` | Unmodified upstream editor deployment and opt-in native runtime audit |
|
||||||
| `workstation.nix`, `nvidia.nix` | Separately selected physical-workstation/greeter and NVIDIA integration; not enabled on EC2 |
|
| `updates.nix`, `update-system.sh`, `update-test.py` | Daily isolated-worktree updates and failure/concurrency regression tests |
|
||||||
| `updates.nix`, `update-system.sh` | Daily package-input updates in an isolated Git worktree; validated commits, no forced reboot or GC |
|
| `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`, `update-test.py` | Disposable graphical audit and updater failure/concurrency tests |
|
| `desktop-test.nix`, `desktop-test.py`, `audit-desktop.sh` | Disposable graphical/PAM/audio/scaling audit |
|
||||||
| `network.nix` | WireGuard/OpenVPN clients and network diagnostics; optional resolved, host-owned interface management |
|
| `workstation.nix`, `nvidia.nix` | Optional ReGreet workstation and NVIDIA modules; not this Intel laptop's configuration |
|
||||||
| `neovim.nix`, `neovim-test.lua` | Editor, unchanged upstream dotfile deployment and opt-in native runtime audit |
|
| [DESKTOP.md](DESKTOP.md) | Live audit, wallpaper provenance, JaKooLit comparison and explicit feature-completion plan |
|
||||||
| [DESKTOP.md](DESKTOP.md) | Historical component research; executable configuration is in the files above |
|
|
||||||
|
|
||||||
## Account
|
## Account and session
|
||||||
|
|
||||||
- Daily user: `dev`, UID 1001, home `/home/dev`, workspace `~/projects`.
|
- Daily account: **dev**, UID 1001, `/home/dev`, workspace `~/projects`, Zsh.
|
||||||
- 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` has **full passwordless sudo**, not a rebuild-only privilege. Nix daemon trusted-user access is not separately granted.
|
||||||
- `dev` is authorized by the public key in this repo. No private key or password is embedded.
|
- 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.
|
||||||
- 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.
|
- 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`.
|
||||||
- Nix daemon access stays untrusted for ordinary use. Sudo is a separate, explicit administrative capability.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- The SSH key in `dev-authorized-keys` is public. Account/cloud/browser/VPN credentials and vaults remain user-controlled.
|
||||||
- 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.
|
|
||||||
|
|
||||||
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
|
## Build and apply
|
||||||
|
|
||||||
### Physical machine: keep kbot and add dev
|
On the **physical laptop**, from an administrator shell:
|
||||||
|
|
||||||
`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:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd /etc/nix
|
cd /etc/nix
|
||||||
|
nixfmt --check ./*.nix
|
||||||
nix flake check --no-build --no-update-lock-file
|
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.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
|
--no-update-lock-file --no-link
|
||||||
nix build \
|
nix build .#nixosConfigurations.nixos.config.system.build.toplevel \
|
||||||
.#nixosConfigurations.nixos.config.system.build.toplevel \
|
|
||||||
--no-update-lock-file --out-link result-nixos
|
--no-update-lock-file --out-link result-nixos
|
||||||
built=$(readlink -f result-nixos)
|
built=$(readlink -f result-nixos)
|
||||||
sudo "$built/sw/bin/nixos-rebuild" dry-activate --no-reexec --store-path "$built"
|
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
|
```sh
|
||||||
cd /etc/nix
|
built=$(readlink -f /etc/nix/result-nixos)
|
||||||
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.
|
|
||||||
sudo "$built/sw/bin/nixos-rebuild" test --no-reexec --store-path "$built"
|
sudo "$built/sw/bin/nixos-rebuild" test --no-reexec --store-path "$built"
|
||||||
# Choose a new password locally; mutable users preserve it across later rebuilds.
|
# After testing login/session, persist exactly that closure:
|
||||||
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
|
|
||||||
sudo "$built/sw/bin/nixos-rebuild" switch --no-reexec --store-path "$built"
|
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
|
```sh
|
||||||
previous=$(readlink -f /run/booted-system)
|
previous=$(readlink -f /run/booted-system)
|
||||||
sudo "$previous/sw/bin/nixos-rebuild" test --no-reexec --store-path "$previous"
|
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
|
```sh
|
||||||
nixfmt --check ./*.nix
|
systemctl list-timers nixos-update.timer
|
||||||
nix flake check --no-build --no-update-lock-file
|
journalctl -u nixos-update
|
||||||
nix build .#checks.x86_64-linux.updates .#checks.x86_64-linux.desktop-config --no-link
|
sudo systemctl start nixos-update.service
|
||||||
nix build .#nixosConfigurations.dev.config.system.build.toplevel --no-update-lock-file
|
sudo less /var/cache/nixos-update/last-success
|
||||||
```
|
```
|
||||||
|
|
||||||
New source files must be added to Git for flakes to see them. Keep `flake.lock` in version control. A build does **not** activate changes.
|
Manual refresh: `nix flake update nixpkgs home-manager nixpkgs-latest`, review `flake.lock`, then check/build. `system.stateVersion` / `home.stateVersion` remain `26.05`: they govern compatibility, not package freshness. Channels are disabled; the Nixpkgs registry and `<nixpkgs>` lookup follow the locked system input.
|
||||||
|
|
||||||
Review and activate exactly the built closure:
|
## Editor and application data
|
||||||
|
|
||||||
|
Neovim imports [the existing neovim-dots source](https://git.cyber.ayyalasomayajula.net/marsultor/neovim-dots), pinned to `380eb86778a7c53a0f1c18e84f14037456155347`. Lua, AstroNvim, Lazy, Mason, plugins and keymaps are unchanged. No Nixvim, replacement loader or plugin-manager migration.
|
||||||
|
|
||||||
|
Home Manager links configuration files under `~/.config/nvim`. Lazy's writable lock lives under `~/.local/state/nvim/locks/<revision>.json`, seeded once per dotfile revision. An earlier manual checkout is preserved at `~/projects/neovim-dots-before-nix`; activation refuses to overwrite an existing backup. The host input is reproducible; Lazy/Mason's mutable runtime downloads are not claimed to be fully Nix-reproducible.
|
||||||
|
|
||||||
|
As `dev`, the separate runtime audit may download dependencies but does not modify managed Lua or save buffers:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
built=$(readlink -f result)
|
nvim --headless ~/.config/nvim/init.lua -c 'luafile /etc/nix/neovim-test.lua'
|
||||||
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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
**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.
|
||||||
nix --extra-experimental-features 'nix-command flakes' build \
|
|
||||||
.#nixosConfigurations.dev.config.system.build.toplevel --no-update-lock-file
|
|
||||||
```
|
|
||||||
|
|
||||||
Then review/apply the resulting closure as above. The configuration creates `dev`, sets permissions and deploys its files. **No separate Neovim clone, copy, useradd, chown or global Git-config recipe is required.** After either host configuration is active, `nix-command` and `flakes` are system defaults through `nix.settings.experimental-features`; the temporary flag is only for bootstrapping older bases that cannot read flakes yet.
|
## Validation and contributions
|
||||||
|
|
||||||
This host build does not use a mutable channel. NixOS's native flake integration pins the `nixpkgs` registry entry and login-shell `<nixpkgs>` lookup to the system input; the global `nix-path` setting keeps the same pin when `NIX_PATH` is unset. Old root channel profiles are retained for recovery, not used as build inputs. Dev-environment templates/composition remain deferred; there is no flake framework here.
|
`./audit-desktop.sh` runs the disposable graphical VM; `./audit-desktop.sh /dev/dri/renderD128` optionally uses an accessible render node. Logs/screenshots go to `~/.cache/desktop-audit/run.*`. The VM tests session services, fonts, audio, scaling and real PAM with **test-only** credentials. The native live screenshot audit covers this laptop; neither substitutes for real hardware suspend, hotplug or browser portal tests.
|
||||||
|
|
||||||
## Neovim: import, do not rewrite
|
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.
|
||||||
|
|
||||||
The input is [the existing neovim-dots repository](https://git.cyber.ayyalasomayajula.net/marsultor/neovim-dots), initially pinned to `380eb86778a7c53a0f1c18e84f14037456155347`.
|
|
||||||
|
|
||||||
Home Manager deploys its files under `~/.config/nvim`, with the **Lua, AstroNvim, Lazy, Mason, plugins and keymaps unchanged**. `programs.neovim.configure` stays empty so Neovim discovers `init.lua` normally. No Nixvim or custom Lua loader.
|
|
||||||
|
|
||||||
- `tools.nix` supplies GCC/Make, pkg-config, Python, Node, Lua 5.1/LuaRocks and `nix-ld` for the existing plugin builds and Mason's upstream Linux executables. These are runtime prerequisites, not a replacement plugin manager or project-template framework.
|
|
||||||
- Configuration files are linked from the pinned source and managed by Nix. Change the upstream repo and its input revision rather than editing generated links.
|
|
||||||
- Lazy's `lazy-lock.json` must remain writable. The config seeds a copy at `~/.local/state/nvim/locks/<dotfile-revision>.json` and links to it. A new dotfile revision gets its own original lock; repeated activation preserves runtime changes to an existing lock.
|
|
||||||
- A declared migration preserves the earlier manual checkout intact at `~/projects/neovim-dots-before-nix`. It refuses to overwrite an existing backup. On a clean home this migration does nothing.
|
|
||||||
|
|
||||||
**Reproducibility boundary:** Nix locks the host inputs and dotfile source, and reproduces their deployment. The existing Lua still bootstraps Lazy and manages plugin/Mason downloads at runtime. The supplied Lazy lock records plugin revisions, but it is writable and Mason's tool versions are not pinned by this Nix config. This is not a claim that every runtime download/cache is a Nix-reproducible build. Changing that policy requires a separate agreement; do not silently replace the user's plugin managers.
|
|
||||||
|
|
||||||
## Validation and commits
|
|
||||||
|
|
||||||
The initial deployment was tested with an empty disposable home: all upstream files were reproduced byte-for-byte, the Lazy lock remained writable across repeated activation, and the migration preserved local data and refused to overwrite an existing backup. The real plugin bootstrap is a separate runtime test, not covered by these file-deployment checks. As `dev`, run `nvim --headless ~/.config/nvim/init.lua -c 'luafile /etc/nixos/neovim-test.lua'` for a bounded runtime check of Lazy, the configured Mason tools (including executable startup), and nine parsers. This uses the existing writable plugin/Mason cache and may download dependencies; it does not edit the managed Lua or save buffers.
|
|
||||||
|
|
||||||
Run the graphical test separately with `./audit-desktop.sh`. An optional accessible render node, for example `./audit-desktop.sh /dev/dri/renderD128`, moves rendering out of QEMU's CPU emulation without touching the host display. Screenshots and logs go to `~/.cache/desktop-audit/run.*`. The test uses an isolated VM, test-only credentials and an emulated sound card with a silent backend. It checks session services, fonts, 100%/150% scaling, real PAM lock/unlock, speaker volume, microphone mute and audio controls. Hardware audio, NVIDIA, suspend and mixed-monitor behavior still need the target workstation. `nix flake check` without `--no-build` also runs the software-rendered VM and can be very slow without KVM.
|
|
||||||
|
|
||||||
Make focused changes, format/evaluate them, and commit regularly. Build and activate a reviewed commit rather than accumulating uncommitted setup. `system.configurationRevision` records the source revision in the system generation. Do not put human identity guesses in Git settings or push to an unapproved remote.
|
|
||||||
|
|
||||||
## Updates and safety
|
|
||||||
|
|
||||||
- **EC2 only:** `nixos-update.timer` checks daily with up to one hour of jitter. Nixpkgs/Home Manager release branches and the latest developer-tool package input advance; Neovim's source stays fixed. The updater skips a dirty repository, builds in a detached worktree, records a tested commit and applies it without rebooting. It attempts rollback if activation fails and retains recovery generations. Inspect `journalctl -u nixos-update` or trigger it with `sudo systemctl start nixos-update`.
|
|
||||||
- For a manual input refresh: `nix flake update nixpkgs home-manager nixpkgs-latest`, review `flake.lock`, then check/build. The exact resolved revisions and hashes remain committed.
|
|
||||||
- Keep the EC2 module on the EC2 target only. Preserve sandboxing, signature verification and each host's recovery access.
|
|
||||||
- `system.stateVersion` and `home.stateVersion` are both `26.05`; these preserve compatibility, not package versions.
|
|
||||||
- `test` activates changes too; it is not a dry run. Keep the original system generation.
|
|
||||||
- Rollbacks do not restore mutable user/application data, lockfile updates, backups, or this Git working tree.
|
|
||||||
- No private keys, plaintext secrets, build outputs or agent notes in this repo.
|
|
||||||
|
|||||||
+17
-14
@@ -1,24 +1,27 @@
|
|||||||
* { font-family: Inter, sans-serif; font-size: 17px; }
|
* { font-family: Inter, sans-serif; font-size: 16px; }
|
||||||
window { background: transparent; }
|
window { background: transparent; }
|
||||||
box.main {
|
box.main {
|
||||||
padding: 14px;
|
padding: 12px;
|
||||||
margin: 18px;
|
margin: 12px;
|
||||||
border-radius: 16px;
|
border-radius: 6px;
|
||||||
border: 2px solid #78a9ff;
|
border: 2px solid @accent@;
|
||||||
background: #161616;
|
background: @background@;
|
||||||
box-shadow: 0 8px 18px rgba(0, 0, 0, .35);
|
box-shadow: 0 8px 18px rgba(0, 0, 0, .35);
|
||||||
}
|
}
|
||||||
text {
|
text {
|
||||||
color: #f2f4f8;
|
color: @text@;
|
||||||
background: #262626;
|
background: @surface@;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 9px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.matches, list.plugin { background: transparent; }
|
.matches, list.plugin { background: transparent; }
|
||||||
box.plugin:first-child { margin-top: 10px; }
|
box.plugin:first-child { margin-top: 10px; }
|
||||||
.match { padding: 9px; border-radius: 9px; background: transparent; }
|
/* The row, boxes, icon AND labels all share .match upstream. Padding that
|
||||||
.match:selected { background: #2e3f5f; }
|
class multiplies row height until the launcher extends off the screen. */
|
||||||
label.match { color: #f2f4f8; }
|
.match { background: transparent; }
|
||||||
label.match.description { font-size: 14px; color: #a2a9b0; }
|
row.match { padding: 7px 10px; border-radius: 4px; }
|
||||||
label.plugin.info { color: #a2a9b0; }
|
row.match:selected { background: @selection@; }
|
||||||
|
label.match { color: @text@; }
|
||||||
|
label.match.description { font-size: 14px; color: @muted@; }
|
||||||
|
label.plugin.info { color: @muted@; }
|
||||||
|
|||||||
@@ -7,16 +7,7 @@
|
|||||||
|
|
||||||
let
|
let
|
||||||
c = import ./colors.nix;
|
c = import ./colors.nix;
|
||||||
# Select secure storage without rebuilding the cached Electron application.
|
elementNightly = import ./element-nightly.nix { inherit pkgs; };
|
||||||
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;
|
|
||||||
};
|
|
||||||
browserAddon = pkgs.fetchurl {
|
browserAddon = pkgs.fetchurl {
|
||||||
name = "keepassxc-browser-1.10.3.xpi";
|
name = "keepassxc-browser-1.10.3.xpi";
|
||||||
url = "https://addons.mozilla.org/firefox/downloads/file/4831838/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"
|
"nvidia-persistenced"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
programs.thunar = {
|
||||||
|
enable = true;
|
||||||
|
plugins = [
|
||||||
|
pkgs.thunar-archive-plugin
|
||||||
|
pkgs.thunar-volman
|
||||||
|
];
|
||||||
|
};
|
||||||
programs.steam = {
|
programs.steam = {
|
||||||
enable = true;
|
enable = true;
|
||||||
remotePlay.openFirewall = false;
|
remotePlay.openFirewall = false;
|
||||||
@@ -66,11 +64,31 @@ in
|
|||||||
};
|
};
|
||||||
programs.firefox = {
|
programs.firefox = {
|
||||||
enable = true;
|
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;
|
package = pkgs.firefox-esr;
|
||||||
nativeMessagingHosts.packages = [ pkgs.keepassxc ];
|
nativeMessagingHosts.packages = [ pkgs.keepassxc ];
|
||||||
policies = {
|
policies = {
|
||||||
DisableTelemetry = true;
|
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;
|
OfferToSaveLogins = false;
|
||||||
ExtensionSettings."keepassxc-browser@keepassxc.org" = {
|
ExtensionSettings."keepassxc-browser@keepassxc.org" = {
|
||||||
installation_mode = "normal_installed";
|
installation_mode = "normal_installed";
|
||||||
@@ -83,11 +101,20 @@ in
|
|||||||
home-manager.users.dev = {
|
home-manager.users.dev = {
|
||||||
home.packages = with pkgs; [
|
home.packages = with pkgs; [
|
||||||
thunderbird
|
thunderbird
|
||||||
|
file-roller
|
||||||
|
imv
|
||||||
# Electron does not reliably detect a keyring under Hyprland. Use the
|
# Electron does not reliably detect a keyring under Hyprland. Use the
|
||||||
# KeePassXC Secret Service explicitly; never fall back to basic_text.
|
# KeePassXC Secret Service explicitly; never fall back to basic_text.
|
||||||
elementWithKeyring
|
elementNightly
|
||||||
slack
|
slack
|
||||||
];
|
];
|
||||||
|
programs.mpv = {
|
||||||
|
enable = true;
|
||||||
|
config = {
|
||||||
|
hwdec = "auto-safe";
|
||||||
|
keep-open = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
programs.keepassxc = {
|
programs.keepassxc = {
|
||||||
enable = true;
|
enable = true;
|
||||||
autostart = true;
|
autostart = true;
|
||||||
@@ -113,7 +140,7 @@ in
|
|||||||
inputbar-fg = c.text;
|
inputbar-fg = c.text;
|
||||||
completion-bg = c.background;
|
completion-bg = c.background;
|
||||||
completion-fg = c.text;
|
completion-fg = c.text;
|
||||||
completion-highlight-bg = "#354562";
|
completion-highlight-bg = c.selection;
|
||||||
completion-highlight-fg = c.text;
|
completion-highlight-fg = c.text;
|
||||||
notification-bg = c.surface;
|
notification-bg = c.surface;
|
||||||
notification-fg = c.text;
|
notification-fg = c.text;
|
||||||
@@ -127,11 +154,21 @@ in
|
|||||||
enable = true;
|
enable = true;
|
||||||
defaultApplications = {
|
defaultApplications = {
|
||||||
"application/pdf" = [ "org.pwmt.zathura.desktop" ];
|
"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" ];
|
"text/html" = [ "firefox-esr.desktop" ];
|
||||||
"x-scheme-handler/http" = [ "firefox-esr.desktop" ];
|
"x-scheme-handler/http" = [ "firefox-esr.desktop" ];
|
||||||
"x-scheme-handler/https" = [ "firefox-esr.desktop" ];
|
"x-scheme-handler/https" = [ "firefox-esr.desktop" ];
|
||||||
"x-scheme-handler/mailto" = [ "thunderbird.desktop" ];
|
"x-scheme-handler/mailto" = [ "thunderbird.desktop" ];
|
||||||
"x-scheme-handler/matrix" = [ "element-desktop.desktop" ];
|
"x-scheme-handler/matrix" = [ "element-desktop-nightly.desktop" ];
|
||||||
|
"x-scheme-handler/element" = [ "element-desktop-nightly.desktop" ];
|
||||||
|
"x-scheme-handler/io.element.desktop" = [ "element-desktop-nightly.desktop" ];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+17
-14
@@ -1,16 +1,19 @@
|
|||||||
# Small shared palette, not a theme framework. Hyprland's native Lua uses the
|
# One Ring: charcoal, old gold, parchment and muted woodland accents.
|
||||||
# same accent/border colors explicitly; wallpaper.svg is original source art.
|
# 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";
|
background = "#121311";
|
||||||
surface = "#262626";
|
surface = "#1e201b";
|
||||||
raised = "#393939";
|
raised = "#2c3027";
|
||||||
border = "#525252";
|
border = "#4a5142";
|
||||||
text = "#f2f4f8";
|
selection = "#3a3d2e";
|
||||||
muted = "#a2a9b0";
|
text = "#e9e4d5";
|
||||||
blue = "#78a9ff";
|
muted = "#adb2a0";
|
||||||
cyan = "#3ddbd9";
|
accent = "#c6a664";
|
||||||
purple = "#be95ff";
|
blue = "#8faeb5";
|
||||||
green = "#42be65";
|
cyan = "#92b8a0";
|
||||||
yellow = "#f1c21b";
|
purple = "#b5a5be";
|
||||||
red = "#ff6b7a";
|
green = "#a3b878";
|
||||||
|
yellow = "#d8bb78";
|
||||||
|
red = "#df8b78";
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-12
@@ -11,18 +11,8 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
nixpkgs.hostPlatform = "x86_64-linux";
|
nixpkgs.hostPlatform = "x86_64-linux";
|
||||||
nixpkgs.overlays = [
|
# tools.nix/network.nix select current standalone CLIs without overriding the
|
||||||
(_final: prev: {
|
# system package set (or mixing unstable libraries into the desktop stack).
|
||||||
# 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;
|
|
||||||
})
|
|
||||||
];
|
|
||||||
|
|
||||||
nix = {
|
nix = {
|
||||||
channel.enable = false;
|
channel.enable = false;
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Read-only shortcut search from Hyprland's running binding registry.
|
||||||
|
|
||||||
|
Descriptions come from native Lua bindings, not a second hand-maintained cheat
|
||||||
|
sheet. Choosing a row does NOT execute its command (in particular power/close).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def rows(bindings):
|
||||||
|
modifiers = [(64, "Super"), (4, "Ctrl"), (8, "Alt"), (1, "Shift")]
|
||||||
|
result = []
|
||||||
|
for binding in bindings:
|
||||||
|
description = binding.get("description", "")
|
||||||
|
if not description:
|
||||||
|
continue
|
||||||
|
keys = [name for bit, name in modifiers if binding.get("modmask", 0) & bit]
|
||||||
|
keys.append(binding.get("key") or f"code:{binding.get('keycode', 0)}")
|
||||||
|
submap = binding.get("submap")
|
||||||
|
context = f" [{submap}]" if submap else ""
|
||||||
|
result.append(f"{description}{context} {' + '.join(keys)}")
|
||||||
|
return sorted(set(result), key=str.casefold)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
data = json.loads(subprocess.check_output(["hyprctl", "-j", "binds"], text=True))
|
||||||
|
entries = rows(data)
|
||||||
|
if "--print" in sys.argv:
|
||||||
|
print("\n".join(entries))
|
||||||
|
return
|
||||||
|
if not entries:
|
||||||
|
raise ValueError("No described shortcuts; reload the managed Hyprland configuration.")
|
||||||
|
result = subprocess.run(
|
||||||
|
["fuzzel", "--dmenu", "--prompt", "Shortcuts ", "--width", "68", "--lines", "12"],
|
||||||
|
input="\n".join(entries), text=True, stdout=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
if result.returncode not in (0, 1):
|
||||||
|
raise RuntimeError(f"Shortcut picker exited with {result.returncode}")
|
||||||
|
except (OSError, ValueError, subprocess.CalledProcessError, RuntimeError) as error:
|
||||||
|
print(f"desktop-help: {error}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+63
-7
@@ -35,7 +35,8 @@ def screenshot(name):
|
|||||||
|
|
||||||
|
|
||||||
def launch(name, command):
|
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()
|
machine.start()
|
||||||
@@ -45,8 +46,7 @@ try:
|
|||||||
"runuser -u dev -- env XDG_RUNTIME_DIR=/run/user/1001 "
|
"runuser -u dev -- env XDG_RUNTIME_DIR=/run/user/1001 "
|
||||||
"systemctl --user is-active graphical-session.target", timeout=180
|
"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", "swaync", "hyprsunset", "desktop-clipboard", "anyrun"]:
|
||||||
for unit in ["ashell", "awww", "hypridle", "hyprpolkitagent", "pipewire", "wireplumber"]:
|
|
||||||
machine.wait_until_succeeds(
|
machine.wait_until_succeeds(
|
||||||
"runuser -u dev -- env XDG_RUNTIME_DIR=/run/user/1001 "
|
"runuser -u dev -- env XDG_RUNTIME_DIR=/run/user/1001 "
|
||||||
"systemctl --user is-active " + unit + ".service", timeout=60
|
"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'; "
|
"printf '\\n WORKSPACE READY\\n\\n'; "
|
||||||
"zsh --version; kitty --version; git --version; printf 'Pi: '; command -v pi; "
|
"zsh --version; kitty --version; git --version; printf 'Pi: '; command -v pi; "
|
||||||
"printf '\\n Ctrl-R history | Ctrl-T files\\n'; "
|
"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"
|
"printf ' Super-Enter terminal | Super-F fullscreen\\n\\n'; exec zsh -i"
|
||||||
))
|
))
|
||||||
launch("monitor", "kitty --title 'System monitor' -e btop")
|
launch("monitor", "kitty --title 'System monitor' -e btop")
|
||||||
@@ -88,13 +88,46 @@ screenshot("desktop-100")
|
|||||||
|
|
||||||
launch("launcher", "anyrun")
|
launch("launcher", "anyrun")
|
||||||
wait_layer("anyrun")
|
wait_layer("anyrun")
|
||||||
machine.sleep(5)
|
machine.sleep(2)
|
||||||
|
screenshot("launcher-empty-100")
|
||||||
machine.send_chars("kitty", delay=0.1)
|
machine.send_chars("kitty", delay=0.1)
|
||||||
machine.sleep(5)
|
machine.sleep(5)
|
||||||
screenshot("launcher-100")
|
screenshot("launcher-100")
|
||||||
machine.send_key("esc")
|
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.'")
|
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)
|
machine.sleep(1)
|
||||||
screenshot("notification-100")
|
screenshot("notification-100")
|
||||||
|
|
||||||
@@ -105,6 +138,26 @@ session("hyprctl eval " + shlex.quote(
|
|||||||
))
|
))
|
||||||
machine.sleep(4)
|
machine.sleep(4)
|
||||||
assert json.loads(session("hyprctl -j monitors"))[0]["scale"] == 1.5
|
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.
|
# 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.
|
# 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")
|
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_chars("desktop-test")
|
||||||
machine.send_key("ret")
|
machine.send_key("ret")
|
||||||
machine.wait_until_fails("pgrep -u dev hyprlock", timeout=30)
|
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.
|
# Restore scale before inspecting settings and ordinary application windows.
|
||||||
session("hyprctl eval " + shlex.quote(
|
session("hyprctl eval " + shlex.quote(
|
||||||
@@ -142,7 +196,9 @@ screenshot("audio-controls")
|
|||||||
# Preferences must not be a read-only Home Manager symlink.
|
# Preferences must not be a read-only Home Manager symlink.
|
||||||
user("test -w ~/.config/keepassxc/keepassxc.ini && test ! -L ~/.config/keepassxc/keepassxc.ini")
|
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 '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")
|
user("grep -Eq 'fade_on_empty *= *false' ~/.config/hypr/hyprlock.conf")
|
||||||
assert "libapplications.so" in user("cat ~/.config/anyrun/config.ron")
|
assert "libapplications.so" in user("cat ~/.config/anyrun/config.ron")
|
||||||
session("hyprctl clients")
|
session("hyprctl clients")
|
||||||
|
|||||||
+261
-69
@@ -7,9 +7,22 @@
|
|||||||
|
|
||||||
let
|
let
|
||||||
c = import ./colors.nix;
|
c = import ./colors.nix;
|
||||||
wallpaper = pkgs.runCommand "quiet-orbit.png" { nativeBuildInputs = [ pkgs.resvg ]; } ''
|
renderColors =
|
||||||
resvg ${./wallpaper.svg} "$out"
|
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 {
|
wallpaperInit = pkgs.writeShellApplication {
|
||||||
name = "initialize-wallpaper";
|
name = "initialize-wallpaper";
|
||||||
runtimeInputs = [
|
runtimeInputs = [
|
||||||
@@ -28,23 +41,42 @@ let
|
|||||||
exit 1
|
exit 1
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
screenshot = pkgs.writeShellApplication {
|
actions = pkgs.writeShellApplication {
|
||||||
name = "desktop-screenshot";
|
name = "desktop";
|
||||||
runtimeInputs = with pkgs; [
|
runtimeInputs = with pkgs; [
|
||||||
|
python3
|
||||||
|
hyprland
|
||||||
|
uwsm
|
||||||
|
systemd
|
||||||
|
fuzzel
|
||||||
|
libnotify
|
||||||
|
cliphist
|
||||||
|
wl-clipboard
|
||||||
grim
|
grim
|
||||||
slurp
|
slurp
|
||||||
satty
|
satty
|
||||||
wl-clipboard
|
wf-recorder
|
||||||
coreutils
|
wireplumber
|
||||||
|
pulseaudio
|
||||||
|
brightnessctl
|
||||||
|
power-profiles-daemon
|
||||||
|
xdg-user-dirs
|
||||||
|
xdg-utils
|
||||||
|
swaynotificationcenter
|
||||||
|
hyprlock
|
||||||
|
networkmanager
|
||||||
|
util-linux
|
||||||
];
|
];
|
||||||
text = ''
|
text = ''
|
||||||
geometry=$(slurp) || exit 0
|
export DESKTOP_EMOJI_FILE=${pkgs.unicode-emoji.emoji-test}/share/unicode/emoji/emoji-test.txt
|
||||||
[ -n "$geometry" ] || exit 0
|
exec python ${./desktop-actions.py} "$@"
|
||||||
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"
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
screenshot = pkgs.writeShellApplication {
|
||||||
|
name = "desktop-screenshot";
|
||||||
|
runtimeInputs = [ actions ];
|
||||||
|
text = ''exec desktop screenshot area --annotate "$@"'';
|
||||||
|
};
|
||||||
launcherExec = pkgs.writeShellScript "anyrun-uwsm" ''
|
launcherExec = pkgs.writeShellScript "anyrun-uwsm" ''
|
||||||
kind=$1
|
kind=$1
|
||||||
shift
|
shift
|
||||||
@@ -93,6 +125,10 @@ in
|
|||||||
slurp
|
slurp
|
||||||
satty
|
satty
|
||||||
screenshot
|
screenshot
|
||||||
|
actions
|
||||||
|
help
|
||||||
|
hyprpicker
|
||||||
|
wf-recorder
|
||||||
pavucontrol
|
pavucontrol
|
||||||
playerctl
|
playerctl
|
||||||
brightnessctl
|
brightnessctl
|
||||||
@@ -155,7 +191,8 @@ in
|
|||||||
dconf.settings."org/gnome/desktop/interface" = {
|
dconf.settings."org/gnome/desktop/interface" = {
|
||||||
color-scheme = "prefer-dark";
|
color-scheme = "prefer-dark";
|
||||||
font-name = "Inter 11";
|
font-name = "Inter 11";
|
||||||
monospace-font-name = "JetBrainsMono Nerd Font 13";
|
monospace-font-name = "JetBrainsMono Nerd Font 12";
|
||||||
|
accent-color = "yellow";
|
||||||
};
|
};
|
||||||
|
|
||||||
wayland.windowManager.hyprland = {
|
wayland.windowManager.hyprland = {
|
||||||
@@ -164,7 +201,7 @@ in
|
|||||||
portalPackage = null;
|
portalPackage = null;
|
||||||
systemd.enable = false; # UWSM owns the session and environment.
|
systemd.enable = false; # UWSM owns the session and environment.
|
||||||
configType = "lua";
|
configType = "lua";
|
||||||
extraConfig = builtins.readFile ./hyprland.lua;
|
extraConfig = renderColors (builtins.readFile ./hyprland.lua);
|
||||||
};
|
};
|
||||||
services.hyprpolkitagent.enable = true;
|
services.hyprpolkitagent.enable = true;
|
||||||
services.awww.enable = true;
|
services.awww.enable = true;
|
||||||
@@ -180,21 +217,25 @@ in
|
|||||||
settings = {
|
settings = {
|
||||||
log_level = "warn";
|
log_level = "warn";
|
||||||
position = "Top";
|
position = "Top";
|
||||||
|
layer = "Top";
|
||||||
modules = {
|
modules = {
|
||||||
left = [
|
left = [
|
||||||
[
|
[
|
||||||
"appLauncher"
|
"appLauncher"
|
||||||
"Workspaces"
|
"Workspaces"
|
||||||
|
"desktopHelp"
|
||||||
]
|
]
|
||||||
|
"WindowTitle"
|
||||||
];
|
];
|
||||||
center = [ "Tempo" ];
|
center = [ "Tempo" ];
|
||||||
right = [
|
right = [
|
||||||
|
"recording"
|
||||||
|
"MediaPlayer"
|
||||||
"SystemInfo"
|
"SystemInfo"
|
||||||
[
|
"notifications"
|
||||||
"Tray"
|
"Tray"
|
||||||
"Privacy"
|
"Privacy"
|
||||||
"Settings"
|
"Settings"
|
||||||
]
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
CustomModule = [
|
CustomModule = [
|
||||||
@@ -203,22 +244,74 @@ in
|
|||||||
icon = "";
|
icon = "";
|
||||||
command = "uwsm app -- anyrun";
|
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";
|
enable_esc_key = true;
|
||||||
workspaces = {
|
media_player.max_title_length = 18;
|
||||||
visibility_mode = "MonitorSpecific";
|
window_title = {
|
||||||
enable_workspace_filling = true;
|
mode = "Title";
|
||||||
|
truncate_title_after_length = 26;
|
||||||
};
|
};
|
||||||
system_info = {
|
system_info = {
|
||||||
indicators = [
|
indicators = [
|
||||||
"Cpu"
|
"Cpu"
|
||||||
"Memory"
|
"Memory"
|
||||||
|
"Temperature"
|
||||||
];
|
];
|
||||||
interval = 5;
|
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 = {
|
settings = {
|
||||||
lock_cmd = "loginctl lock-session";
|
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_sinks_more_cmd = "uwsm app -- pavucontrol -t 3";
|
||||||
audio_sources_more_cmd = "uwsm app -- pavucontrol -t 4";
|
audio_sources_more_cmd = "uwsm app -- pavucontrol -t 4";
|
||||||
wifi_more_cmd = "uwsm app -- nm-connection-editor";
|
wifi_more_cmd = "uwsm app -- nm-connection-editor";
|
||||||
@@ -227,26 +320,24 @@ in
|
|||||||
indicators = [
|
indicators = [
|
||||||
"IdleInhibitor"
|
"IdleInhibitor"
|
||||||
"Audio"
|
"Audio"
|
||||||
"Microphone"
|
|
||||||
]
|
]
|
||||||
++ lib.optionals networkManager [
|
++ lib.optionals networkManager [
|
||||||
"Network"
|
"Network"
|
||||||
"Vpn"
|
"Vpn"
|
||||||
]
|
]
|
||||||
++ lib.optionals bluetooth [ "Bluetooth" ]
|
++ lib.optionals bluetooth [ "Bluetooth" ]
|
||||||
++ lib.optionals power [
|
++ lib.optionals power [ "Battery" ];
|
||||||
"PowerProfile"
|
|
||||||
"Battery"
|
|
||||||
"Brightness"
|
|
||||||
];
|
|
||||||
audio_indicator_format = "IconAndPercentage";
|
audio_indicator_format = "IconAndPercentage";
|
||||||
};
|
};
|
||||||
appearance = {
|
appearance = {
|
||||||
font_name = "Inter";
|
font_name = "Inter";
|
||||||
scale_factor = 1.15;
|
scale_factor = 1.15;
|
||||||
style = "Islands";
|
style = "Solid";
|
||||||
opacity = 1.0;
|
opacity = 1.0;
|
||||||
primary_color = c.blue;
|
primary_color = {
|
||||||
|
base = c.accent;
|
||||||
|
text = c.background;
|
||||||
|
};
|
||||||
text_color = c.text;
|
text_color = c.text;
|
||||||
success_color = c.green;
|
success_color = c.green;
|
||||||
danger_color = c.red;
|
danger_color = c.red;
|
||||||
@@ -256,11 +347,7 @@ in
|
|||||||
strong = c.raised;
|
strong = c.raised;
|
||||||
};
|
};
|
||||||
secondary_color.base = c.surface;
|
secondary_color.base = c.surface;
|
||||||
workspace_colors = [
|
workspace_colors = [ c.accent ];
|
||||||
c.blue
|
|
||||||
c.purple
|
|
||||||
c.cyan
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -273,45 +360,149 @@ in
|
|||||||
"${pkgs.anyrun}/lib/libapplications.so"
|
"${pkgs.anyrun}/lib/libapplications.so"
|
||||||
"${pkgs.anyrun}/lib/librink.so"
|
"${pkgs.anyrun}/lib/librink.so"
|
||||||
];
|
];
|
||||||
width.fraction = 0.42;
|
width.fraction = 0.5;
|
||||||
y.fraction = 0.2;
|
y.fraction = 0.12;
|
||||||
hidePluginInfo = true;
|
hidePluginInfo = true;
|
||||||
closeOnClick = true;
|
closeOnClick = true;
|
||||||
maxEntries = 8;
|
showResultsImmediately = true;
|
||||||
|
maxEntries = 6;
|
||||||
};
|
};
|
||||||
extraCss = builtins.readFile ./anyrun.css;
|
extraCss = renderColors (builtins.readFile ./anyrun.css);
|
||||||
extraConfigFiles."applications.ron".text = ''
|
extraConfigFiles."applications.ron".text = ''
|
||||||
(
|
(
|
||||||
desktop_actions: false,
|
desktop_actions: false,
|
||||||
max_entries: 8,
|
hide_description: true,
|
||||||
|
max_entries: 6,
|
||||||
terminal: Some((command: "uwsm", args: "app -- kitty -e {}")),
|
terminal: Some((command: "uwsm", args: "app -- kitty -e {}")),
|
||||||
preprocess_exec_script: Some("${launcherExec}"),
|
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;
|
enable = true;
|
||||||
settings = {
|
settings = {
|
||||||
font = "Inter 11";
|
main = {
|
||||||
width = 360;
|
font = "Inter:size=11";
|
||||||
height = 160;
|
terminal = "kitty";
|
||||||
margin = "12";
|
"launch-prefix" = "uwsm app --";
|
||||||
padding = "14";
|
"line-height" = 22;
|
||||||
border-size = 2;
|
width = 46;
|
||||||
border-radius = 12;
|
lines = 10;
|
||||||
background-color = c.background;
|
"horizontal-pad" = 18;
|
||||||
text-color = c.text;
|
"vertical-pad" = 14;
|
||||||
border-color = c.border;
|
};
|
||||||
default-timeout = 6000;
|
colors = {
|
||||||
max-visible = 4;
|
background = "${lib.removePrefix "#" c.background}ff";
|
||||||
max-icon-size = 48;
|
text = "${lib.removePrefix "#" c.text}ff";
|
||||||
"urgency=critical" = {
|
match = "${lib.removePrefix "#" c.accent}ff";
|
||||||
border-color = c.red;
|
selection = "${lib.removePrefix "#" c.selection}ff";
|
||||||
default-timeout = 0;
|
"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 = {
|
programs.hyprlock = {
|
||||||
enable = true;
|
enable = true;
|
||||||
package = null;
|
package = null;
|
||||||
@@ -331,9 +522,9 @@ in
|
|||||||
{
|
{
|
||||||
monitor = "";
|
monitor = "";
|
||||||
text = "cmd[update:1000] date +'%H:%M'";
|
text = "cmd[update:1000] date +'%H:%M'";
|
||||||
font_size = 84;
|
font_size = 64;
|
||||||
font_family = "Inter";
|
font_family = "Inter";
|
||||||
color = "rgb(f2f4f8)";
|
color = rgb c.text;
|
||||||
position = "0,100";
|
position = "0,100";
|
||||||
halign = "center";
|
halign = "center";
|
||||||
valign = "center";
|
valign = "center";
|
||||||
@@ -343,7 +534,7 @@ in
|
|||||||
text = "$USER";
|
text = "$USER";
|
||||||
font_size = 18;
|
font_size = 18;
|
||||||
font_family = "Inter";
|
font_family = "Inter";
|
||||||
color = "rgb(a2a9b0)";
|
color = rgb c.muted;
|
||||||
position = "0,0";
|
position = "0,0";
|
||||||
halign = "center";
|
halign = "center";
|
||||||
valign = "center";
|
valign = "center";
|
||||||
@@ -360,12 +551,13 @@ in
|
|||||||
dots_center = true;
|
dots_center = true;
|
||||||
fade_on_empty = false;
|
fade_on_empty = false;
|
||||||
font_family = "Inter";
|
font_family = "Inter";
|
||||||
inner_color = "rgb(262626)";
|
rounding = 6;
|
||||||
outer_color = "rgb(78a9ff)";
|
inner_color = rgb c.surface;
|
||||||
font_color = "rgb(f2f4f8)";
|
outer_color = rgb c.accent;
|
||||||
check_color = "rgb(3ddbd9)";
|
font_color = rgb c.text;
|
||||||
fail_color = "rgb(ff6b7a)";
|
check_color = rgb c.green;
|
||||||
capslock_color = "rgb(f1c21b)";
|
fail_color = rgb c.red;
|
||||||
|
capslock_color = rgb c.yellow;
|
||||||
placeholder_text = "<i>Password</i>";
|
placeholder_text = "<i>Password</i>";
|
||||||
fail_text = "<i>Try again ($ATTEMPTS)</i>";
|
fail_text = "<i>Try again ($ATTEMPTS)</i>";
|
||||||
}
|
}
|
||||||
@@ -377,7 +569,7 @@ in
|
|||||||
package = null; # NixOS owns the service and PAM integration.
|
package = null; # NixOS owns the service and PAM integration.
|
||||||
settings = {
|
settings = {
|
||||||
general = {
|
general = {
|
||||||
lock_cmd = "pidof hyprlock || hyprlock";
|
lock_cmd = "${actions}/bin/desktop lock-start";
|
||||||
before_sleep_cmd = "loginctl lock-session";
|
before_sleep_cmd = "loginctl lock-session";
|
||||||
after_sleep_cmd = "hyprctl dispatch 'hl.dsp.dpms({ action = \"enable\" })'";
|
after_sleep_cmd = "hyprctl dispatch 'hl.dsp.dpms({ action = \"enable\" })'";
|
||||||
inhibit_sleep = 3;
|
inhibit_sleep = 3;
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Official Nightly binary, pinned like the other external application sources.
|
||||||
|
# Keep its matching Electron/native modules together; do not disable the sandbox.
|
||||||
|
{ pkgs }:
|
||||||
|
pkgs.stdenv.mkDerivation {
|
||||||
|
pname = "element-nightly";
|
||||||
|
version = "2026090401";
|
||||||
|
src = pkgs.fetchurl {
|
||||||
|
url = "https://packages.element.io/debian/pool/main/e/element-nightly/element-nightly_2026090401_amd64.deb";
|
||||||
|
hash = "sha256-lLIkH6MtJWhGgohDFBmdOPEI/YdLTAJ9mkgr+TgSPLc=";
|
||||||
|
};
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
dpkg
|
||||||
|
autoPatchelfHook
|
||||||
|
wrapGAppsHook3
|
||||||
|
makeWrapper
|
||||||
|
];
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
alsa-lib
|
||||||
|
at-spi2-atk
|
||||||
|
at-spi2-core
|
||||||
|
cairo
|
||||||
|
cups
|
||||||
|
dbus
|
||||||
|
expat
|
||||||
|
glib
|
||||||
|
gtk3
|
||||||
|
libgbm
|
||||||
|
libdrm
|
||||||
|
libxkbcommon
|
||||||
|
libx11
|
||||||
|
libxcb
|
||||||
|
libxcomposite
|
||||||
|
libxdamage
|
||||||
|
libxext
|
||||||
|
libxfixes
|
||||||
|
libxrandr
|
||||||
|
libxrender
|
||||||
|
libxtst
|
||||||
|
libxscrnsaver
|
||||||
|
libxshmfence
|
||||||
|
nss
|
||||||
|
nspr
|
||||||
|
pango
|
||||||
|
libsecret
|
||||||
|
stdenv.cc.cc
|
||||||
|
];
|
||||||
|
runtimeDependencies = with pkgs; [
|
||||||
|
libnotify
|
||||||
|
libGL
|
||||||
|
libsecret
|
||||||
|
libpulseaudio
|
||||||
|
systemd
|
||||||
|
];
|
||||||
|
dontUnpack = true;
|
||||||
|
dontBuild = true;
|
||||||
|
dontWrapGApps = true;
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
dpkg --fsys-tarfile "$src" | tar --extract --no-same-owner
|
||||||
|
mkdir -p "$out/lib" "$out/bin"
|
||||||
|
mv opt/Element-Nightly "$out/lib/element-nightly"
|
||||||
|
mv usr/share "$out/share"
|
||||||
|
substituteInPlace "$out/share/applications/element-desktop-nightly.desktop" \
|
||||||
|
--replace-fail /opt/Element-Nightly/element-desktop-nightly "$out/bin/element-desktop-nightly"
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
preFixup = ''
|
||||||
|
makeWrapper "$out/lib/element-nightly/element-desktop-nightly" "$out/bin/element-desktop-nightly" \
|
||||||
|
"''${gappsWrapperArgs[@]}" \
|
||||||
|
--suffix PATH : ${pkgs.lib.makeBinPath [ pkgs.xdg-utils ]} \
|
||||||
|
--add-flags "--password-store=gnome-libsecret --disable-setuid-sandbox" \
|
||||||
|
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform=wayland}}"
|
||||||
|
# Compatibility command only, not a duplicate launcher or a second install.
|
||||||
|
ln -s element-desktop-nightly "$out/bin/element-desktop"
|
||||||
|
'';
|
||||||
|
meta = {
|
||||||
|
description = "Element Nightly, the upstream development build of the Matrix client";
|
||||||
|
homepage = "https://element.io/download";
|
||||||
|
license = pkgs.lib.licenses.agpl3Plus;
|
||||||
|
platforms = [ "x86_64-linux" ];
|
||||||
|
mainProgram = "element-desktop-nightly";
|
||||||
|
};
|
||||||
|
}
|
||||||
Generated
+9
-9
@@ -7,11 +7,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787377438,
|
"lastModified": 1788642154,
|
||||||
"narHash": "sha256-Sxu1NLTD/Ern6hFGLlZmtKCSct3YQXZI/lls8RE1XeM=",
|
"narHash": "sha256-sPpQFVaFTDqO/4vvCAhuAhqTgqN/ygu+9eJcs5eB0js=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "65258d5c65a250189fde2e35f490d15e064c4c62",
|
"rev": "fd0956c99c41ae3c13a73a638f1f7e963aebc4ab",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -41,11 +41,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788405554,
|
"lastModified": 1788584326,
|
||||||
"narHash": "sha256-r2f1oUwixlgq9zOdYLqJLfS/lWBT60/IITjhTKI59JU=",
|
"narHash": "sha256-Fd3OB8J9JhgliQwOKcqx4M672CInxi1I5VnwsaXeSQo=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "a5cc6f2c37bf518436dc8d1c288ccd0c43c2f4c4",
|
"rev": "6713828a351efa628b025a1adf7f43cbf8597513",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -57,11 +57,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-latest": {
|
"nixpkgs-latest": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788659161,
|
"lastModified": 1788664981,
|
||||||
"narHash": "sha256-rIzUUvgwhfJeWwm7y1ZF+xRo2Gep+QYkxpj9mJbG6cM=",
|
"narHash": "sha256-7XcoeUSm9v1zn9NHNYNItxIbCUGBTBlTClQDG3qrLQk=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "de0ba0a81b0f6eed798a97fdec6f03a754b9d966",
|
"rev": "94aa2762715b736cea911d96d6a2f3b448f0b296",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -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 {
|
nixosConfigurations.nixos = nixpkgs.lib.nixosSystem {
|
||||||
specialArgs = { inherit inputs; };
|
specialArgs = { inherit inputs; };
|
||||||
modules = [
|
modules = [
|
||||||
@@ -41,6 +41,9 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt;
|
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 = {
|
nixosModules = {
|
||||||
desktop = ./desktop.nix;
|
desktop = ./desktop.nix;
|
||||||
@@ -56,16 +59,28 @@
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
desktop = import ./desktop-test.nix { inherit inputs pkgs; };
|
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 {
|
physical-config = import ./physical-test.nix {
|
||||||
inherit pkgs;
|
inherit pkgs;
|
||||||
config = inputs.self.nixosConfigurations.nixos.config;
|
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 =
|
desktop-config =
|
||||||
pkgs.runCommand "hyprland-config-check" { nativeBuildInputs = [ pkgs.hyprland ]; }
|
pkgs.runCommand "hyprland-config-check" { nativeBuildInputs = [ pkgs.hyprland ]; }
|
||||||
''
|
''
|
||||||
export HOME="$TMPDIR/home" XDG_RUNTIME_DIR="$TMPDIR/runtime"
|
export HOME="$TMPDIR/home" XDG_RUNTIME_DIR="$TMPDIR/runtime"
|
||||||
mkdir -m 700 -p "$HOME" "$XDG_RUNTIME_DIR"
|
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"
|
touch "$out"
|
||||||
'';
|
'';
|
||||||
updates =
|
updates =
|
||||||
|
|||||||
+89
-37
@@ -1,18 +1,19 @@
|
|||||||
-- Native Hyprland 0.55 configuration, deployed by Home Manager.
|
-- 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.
|
-- 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.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" })
|
||||||
|
|
||||||
hl.config({
|
hl.config({
|
||||||
general = {
|
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,
|
layout = "dwindle", resize_on_border = true, allow_tearing = false,
|
||||||
col = {
|
col = {
|
||||||
active_border = { colors = { "rgb(78a9ff)", "rgb(be95ff)" }, angle = 45 },
|
active_border = { colors = { rgb("@accent@"), rgb("@green@") }, angle = 45 },
|
||||||
inactive_border = "rgb(393939)",
|
inactive_border = rgb("@border@"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
decoration = {
|
decoration = {
|
||||||
rounding = 12,
|
rounding = 6,
|
||||||
active_opacity = 1.0, inactive_opacity = 1.0,
|
active_opacity = 1.0, inactive_opacity = 1.0,
|
||||||
shadow = { enabled = true, range = 16, render_power = 3, color = 0x55000000 },
|
shadow = { enabled = true, range = 16, render_power = 3, color = 0x55000000 },
|
||||||
blur = { enabled = false },
|
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.animation({ leaf = "layers", enabled = true, speed = 2, bezier = "settle", style = "fade" })
|
||||||
hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
|
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
|
local function app(command) return hl.dsp.exec_cmd("uwsm app -- " .. command) end
|
||||||
hl.bind("SUPER + Return", app("kitty"))
|
bind("SUPER + Return", app("kitty"), "Apps · Terminal")
|
||||||
hl.bind("SUPER + Space", app("anyrun"))
|
bind("SUPER + D", app("anyrun"), "Apps · Search applications / calculator")
|
||||||
hl.bind("SUPER + E", app("kitty --class files -e yazi"))
|
bind("SUPER + A", app("desktop windows"), "Windows · Overview / search")
|
||||||
hl.bind("SUPER + B", app("firefox-esr"))
|
bind("SUPER + CTRL + S", app("desktop windows"), "Windows · Search all windows")
|
||||||
hl.bind("SUPER + P", app("keepassxc"))
|
bind("SUPER + E", app("thunar"), "Apps · Graphical file manager")
|
||||||
hl.bind("SUPER + Escape", hl.dsp.exec_cmd("loginctl lock-session"))
|
bind("SUPER + CTRL + E", app("kitty --class files -e yazi"), "Apps · Yazi terminal file manager")
|
||||||
hl.bind("SUPER + Q", hl.dsp.window.close())
|
bind("SUPER + B", app("firefox-esr"), "Apps · Browser")
|
||||||
hl.bind("SUPER + V", hl.dsp.window.float({ action = "toggle" }))
|
bind("SUPER + ALT + P", app("keepassxc"), "Apps · Password vault")
|
||||||
hl.bind("SUPER + F", hl.dsp.window.fullscreen())
|
bind("SUPER + P", hl.dsp.window.pseudo(), "Windows · Toggle pseudotiling")
|
||||||
hl.bind("SUPER + J", hl.dsp.layout("togglesplit"))
|
bind("SUPER + H", app("desktop-help"), "Help · Search all keyboard shortcuts")
|
||||||
hl.bind("SUPER + N", hl.dsp.exec_cmd("makoctl dismiss"))
|
bind("SUPER + SHIFT + K", app("desktop-help"), "Help · Search all keyboard shortcuts")
|
||||||
hl.bind("SUPER + SHIFT + N", hl.dsp.exec_cmd("makoctl restore"))
|
bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), "Session · Lock screen")
|
||||||
hl.bind("Print", hl.dsp.exec_cmd("desktop-screenshot"))
|
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
|
for _, direction in ipairs({ "left", "right", "up", "down" }) do
|
||||||
hl.bind("SUPER + " .. direction, hl.dsp.focus({ direction = direction }))
|
bind("SUPER + " .. direction, hl.dsp.focus({ direction = direction }), "Windows · Focus " .. direction)
|
||||||
hl.bind("SUPER + SHIFT + " .. direction, hl.dsp.window.move({ direction = 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
|
end
|
||||||
for i = 1, 10 do
|
for i = 1, 10 do
|
||||||
local key = i % 10
|
local key = i % 10
|
||||||
hl.bind("SUPER + " .. key, hl.dsp.focus({ workspace = i }))
|
bind("SUPER + " .. key, hl.dsp.focus({ workspace = i }), "Workspaces · Focus " .. i)
|
||||||
hl.bind("SUPER + SHIFT + " .. key, hl.dsp.window.move({ workspace = 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
|
end
|
||||||
hl.bind("SUPER + S", hl.dsp.workspace.toggle_special("scratch"))
|
bind("SUPER + U", hl.dsp.workspace.toggle_special("scratch"), "Workspaces · Toggle scratchpad")
|
||||||
hl.bind("SUPER + SHIFT + S", hl.dsp.window.move({ workspace = "special:scratch" }))
|
bind("SUPER + SHIFT + U", hl.dsp.window.move({ workspace = "special:scratch" }), "Workspaces · Move window to scratchpad")
|
||||||
hl.bind("SUPER + mouse_down", hl.dsp.focus({ workspace = "e+1" }))
|
bind("SUPER + Tab", hl.dsp.focus({workspace = "m+1"}), "Workspaces · Next on this monitor")
|
||||||
hl.bind("SUPER + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
|
bind("SUPER + SHIFT + Tab", hl.dsp.focus({workspace = "m-1"}), "Workspaces · Previous on this monitor")
|
||||||
hl.bind("SUPER + mouse:272", hl.dsp.window.drag(), { mouse = true })
|
for i, direction in ipairs({"left", "right", "up", "down"}) do
|
||||||
hl.bind("SUPER + mouse:273", hl.dsp.window.resize(), { mouse = true })
|
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 })
|
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("desktop osd volume up"), "Media · Volume up", { locked = true, repeating = true })
|
||||||
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { locked = true, repeating = true })
|
bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("desktop osd volume down"), "Media · Volume down", { locked = true, repeating = true })
|
||||||
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true })
|
bind("XF86AudioMute", hl.dsp.exec_cmd("desktop osd volume mute"), "Media · Toggle speaker mute", { locked = true })
|
||||||
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true })
|
bind("XF86AudioMicMute", hl.dsp.exec_cmd("desktop osd microphone mute"), "Media · Toggle microphone mute", { locked = true })
|
||||||
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl set +5%"), { locked = true, repeating = true })
|
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("desktop osd brightness up"), "Display · Brightness up", { locked = true, repeating = true })
|
||||||
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl --min-value=1 set 5%-"), { locked = true, repeating = true })
|
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("desktop osd brightness down"), "Display · Brightness down", { locked = true, repeating = true })
|
||||||
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
|
bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), "Media · Play / pause", { locked = true })
|
||||||
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
|
bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), "Media · Next track", { locked = true })
|
||||||
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { 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 = "ignore-maximize", match = { class = ".*" }, suppress_event = "maximize" })
|
||||||
hl.window_rule({
|
hl.window_rule({
|
||||||
name = "fix-xwayland-drag", match = { class = "^$", title = "^$", xwayland = true, float = true, fullscreen = false, pin = false },
|
name = "fix-xwayland-drag", match = { class = "^$", title = "^$", xwayland = true, float = true, fullscreen = false, pin = false },
|
||||||
|
|||||||
+21
-4
@@ -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;
|
# Keep each host's existing interface/DHCP owner. On EC2 this is dhcpcd;
|
||||||
# NixOS wires its resolvconf hook to resolved's compatibility interface.
|
# NixOS wires its resolvconf hook to resolved's compatibility interface.
|
||||||
@@ -13,8 +23,11 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
programs.mtr.enable = true;
|
programs.mtr = {
|
||||||
environment.systemPackages = with pkgs; [
|
enable = true;
|
||||||
|
package = latest.mtr;
|
||||||
|
};
|
||||||
|
environment.systemPackages = with latest; [
|
||||||
wireguard-tools # wg and wg-quick; no interfaces or credentials are configured.
|
wireguard-tools # wg and wg-quick; no interfaces or credentials are configured.
|
||||||
openvpn
|
openvpn
|
||||||
iperf3
|
iperf3
|
||||||
@@ -26,6 +39,10 @@
|
|||||||
ethtool
|
ethtool
|
||||||
netcat-openbsd
|
netcat-openbsd
|
||||||
socat
|
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.
|
# Client tools only: no VPN services, peers, keys, routes or firewall ports.
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-18
@@ -2,7 +2,6 @@
|
|||||||
{ config, pkgs }:
|
{ config, pkgs }:
|
||||||
let
|
let
|
||||||
inherit (pkgs) lib;
|
inherit (pkgs) lib;
|
||||||
kbot = config.users.users.kbot;
|
|
||||||
dev = config.users.users.dev;
|
dev = config.users.users.dev;
|
||||||
btrfsDevice = "/dev/disk/by-uuid/8a16015f-d6f8-4f74-8558-6261b9112216";
|
btrfsDevice = "/dev/disk/by-uuid/8a16015f-d6f8-4f74-8558-6261b9112216";
|
||||||
mounts = {
|
mounts = {
|
||||||
@@ -50,15 +49,11 @@ let
|
|||||||
{
|
{
|
||||||
assertion =
|
assertion =
|
||||||
config.users.mutableUsers
|
config.users.mutableUsers
|
||||||
&& kbot.isNormalUser
|
&& !(config.users.users ? kbot)
|
||||||
&& kbot.uid == 1000
|
&& dev.password == null
|
||||||
&& kbot.home == "/home/kbot"
|
&& dev.hashedPassword == null
|
||||||
&& builtins.elem "wheel" kbot.extraGroups
|
&& dev.hashedPasswordFile == null;
|
||||||
&& builtins.elem "networkmanager" kbot.extraGroups
|
message = "Retire kbot without overwriting dev's locally established password.";
|
||||||
&& kbot.password == null
|
|
||||||
&& kbot.hashedPassword == null
|
|
||||||
&& kbot.hashedPasswordFile == null;
|
|
||||||
message = "Keep kbot's identity, local password and administrative/network access.";
|
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
assertion =
|
assertion =
|
||||||
@@ -68,17 +63,19 @@ let
|
|||||||
&& builtins.elem "wheel" dev.extraGroups
|
&& builtins.elem "wheel" dev.extraGroups
|
||||||
&& builtins.elem "networkmanager" dev.extraGroups
|
&& builtins.elem "networkmanager" dev.extraGroups
|
||||||
&& builtins.attrNames config.home-manager.users == [ "dev" ];
|
&& 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 =
|
assertion =
|
||||||
config.services.displayManager.sddm.enable
|
config.services.displayManager.sddm.enable
|
||||||
&& config.services.desktopManager.plasma6.enable
|
&& !config.services.desktopManager.plasma6.enable
|
||||||
&& config.programs.hyprland.enable
|
&& config.programs.hyprland.enable
|
||||||
&& config.programs.hyprland.withUWSM
|
&& config.programs.hyprland.withUWSM
|
||||||
&& !config.services.greetd.enable
|
&& !config.services.greetd.enable
|
||||||
&& !config.services.displayManager.autoLogin.enable;
|
&& !config.services.displayManager.autoLogin.enable
|
||||||
message = "Keep the Plasma/SDDM recovery login alongside Hyprland, without autologin.";
|
&& 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 =
|
assertion =
|
||||||
@@ -91,11 +88,14 @@ let
|
|||||||
}
|
}
|
||||||
{
|
{
|
||||||
assertion =
|
assertion =
|
||||||
!(config.systemd.services ? nixos-update)
|
config.systemd.services.nixos-update.environment.NIXOS_UPDATE_HOST == "nixos"
|
||||||
&& !(config.systemd.timers ? nixos-update)
|
&& 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)
|
&& !(config.systemd.services ? amazon-ssm-agent)
|
||||||
&& !(builtins.elem "Z /etc/nixos - dev users -" config.systemd.tmpfiles.rules);
|
&& !(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 =
|
assertion =
|
||||||
@@ -132,8 +132,9 @@ in
|
|||||||
assert lib.all (test: lib.assertMsg test.assertion test.message) tests;
|
assert lib.all (test: lib.assertMsg test.assertion test.message) tests;
|
||||||
pkgs.runCommand "physical-config-check" { } ''
|
pkgs.runCommand "physical-config-check" { } ''
|
||||||
sessions=${config.services.displayManager.sessionData.desktops}/share
|
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 -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
|
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"
|
test -x "${config.system.path}/bin/$tool"
|
||||||
done
|
done
|
||||||
|
|||||||
+64
-21
@@ -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.
|
# 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 = [
|
imports = [
|
||||||
./hardware-configuration.nix
|
./hardware-configuration.nix
|
||||||
./common.nix
|
./common.nix
|
||||||
|
./updates.nix
|
||||||
];
|
];
|
||||||
|
|
||||||
boot.loader = {
|
boot.loader = {
|
||||||
@@ -17,7 +35,7 @@
|
|||||||
enable = true;
|
enable = true;
|
||||||
plugins = [ pkgs.networkmanager-openvpn ];
|
plugins = [ pkgs.networkmanager-openvpn ];
|
||||||
};
|
};
|
||||||
# Keep the installed DNS setup while testing the additional desktop.
|
# Keep NetworkManager's installed DNS policy.
|
||||||
services.resolved.enable = false;
|
services.resolved.enable = false;
|
||||||
|
|
||||||
time.timeZone = "America/Chicago";
|
time.timeZone = "America/Chicago";
|
||||||
@@ -34,8 +52,8 @@
|
|||||||
LC_TIME = "en_US.UTF-8";
|
LC_TIME = "en_US.UTF-8";
|
||||||
};
|
};
|
||||||
|
|
||||||
# SDDM offers both Plasma and Hyprland (uwsm-managed). Do not replace it with
|
# Keep the working SDDM greeter; only the managed Hyprland session is offered.
|
||||||
# workstation.nix's greetd/ReGreet or enable autologin during this migration.
|
# No autologin, second desktop environment, or competing session owner.
|
||||||
services.xserver = {
|
services.xserver = {
|
||||||
enable = true;
|
enable = true;
|
||||||
xkb = {
|
xkb = {
|
||||||
@@ -43,32 +61,57 @@
|
|||||||
variant = "";
|
variant = "";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
services.displayManager.sddm.enable = true;
|
services.displayManager = {
|
||||||
services.desktopManager.plasma6.enable = true;
|
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.printing.enable = true;
|
||||||
services.pulseaudio.enable = false;
|
services.pulseaudio.enable = false;
|
||||||
|
|
||||||
# Preserve kbot's existing password and allow `sudo passwd dev` after activation.
|
# Preserve dev's locally established password. Removing the previously
|
||||||
# No password hashes or credentials are copied into this repository/the store.
|
# managed kbot account does NOT request deletion of /home/kbot.
|
||||||
users.mutableUsers = true;
|
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" ];
|
users.users.dev.extraGroups = [ "networkmanager" ];
|
||||||
environment.systemPackages = [ pkgs.networkmanagerapplet ];
|
environment.systemPackages = [ pkgs.networkmanagerapplet ];
|
||||||
|
services.udisks2.enable = true;
|
||||||
|
services.gvfs.enable = true;
|
||||||
|
services.fwupd.enable = true;
|
||||||
home-manager.users.dev.services.udiskie.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.
|
# Retain the installed host's package policy.
|
||||||
nixpkgs.config.allowUnfree = true;
|
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.
|
# Neither /etc/nixos nor /home/kbot is handed over to dev/Home Manager.
|
||||||
}
|
}
|
||||||
|
|||||||
+31
@@ -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@; }
|
||||||
@@ -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"
|
||||||
|
''
|
||||||
@@ -1,40 +1,209 @@
|
|||||||
{ pkgs, ... }:
|
{ inputs, pkgs, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
c = import ./colors.nix;
|
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
|
in
|
||||||
{
|
{
|
||||||
programs.git.enable = true;
|
programs.git = {
|
||||||
|
enable = true;
|
||||||
|
package = latest.git;
|
||||||
|
lfs.enable = true;
|
||||||
|
lfs.package = latest.git-lfs;
|
||||||
|
};
|
||||||
programs.zsh.enable = true;
|
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.
|
# Mason's upstream Linux executables expect a conventional dynamic loader.
|
||||||
programs.nix-ld.enable = true;
|
programs.nix-ld.enable = true;
|
||||||
|
|
||||||
environment.systemPackages = with pkgs; [
|
environment.systemPackages = [
|
||||||
nixfmt
|
pkgs.nixfmt
|
||||||
# Overridden in common.nix to stay current beyond stable release lag.
|
]
|
||||||
|
++ (with latest; [
|
||||||
pi-coding-agent
|
pi-coding-agent
|
||||||
# Runtime build prerequisites for the unchanged Lazy/Mason plugin workflow.
|
|
||||||
|
# Native builds and the unchanged Lazy/Mason runtime prerequisites.
|
||||||
gcc
|
gcc
|
||||||
gnumake
|
gnumake
|
||||||
pkg-config
|
pkg-config
|
||||||
|
cmake
|
||||||
|
ninja
|
||||||
|
meson
|
||||||
|
ccache
|
||||||
python3
|
python3
|
||||||
nodejs
|
nodejs
|
||||||
lua5_1
|
lua5_1
|
||||||
luajitPackages.luarocks
|
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
|
ripgrep
|
||||||
fd
|
fd
|
||||||
eza
|
eza
|
||||||
jq
|
jq
|
||||||
yq-go
|
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
|
curl
|
||||||
wget
|
wget
|
||||||
file
|
xh
|
||||||
tree
|
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
|
unzip
|
||||||
zip
|
zip
|
||||||
p7zip
|
p7zip
|
||||||
rsync
|
zstd
|
||||||
openssl
|
lz4
|
||||||
|
|
||||||
|
# Logs, storage, process and hardware diagnosis. No extra privileges granted.
|
||||||
|
dust
|
||||||
|
duf
|
||||||
|
ncdu
|
||||||
|
procs
|
||||||
|
sysstat
|
||||||
|
iotop
|
||||||
|
lnav
|
||||||
lsof
|
lsof
|
||||||
strace
|
strace
|
||||||
psmisc
|
psmisc
|
||||||
@@ -45,7 +214,19 @@ in
|
|||||||
lm_sensors
|
lm_sensors
|
||||||
man-pages
|
man-pages
|
||||||
man-pages-posix
|
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.packages = [ pkgs.nerd-fonts.jetbrains-mono ];
|
||||||
fonts.fontconfig.defaultFonts.monospace = [ "JetBrainsMono Nerd Font" ];
|
fonts.fontconfig.defaultFonts.monospace = [ "JetBrainsMono Nerd Font" ];
|
||||||
@@ -57,6 +238,43 @@ in
|
|||||||
settings.user.useConfigOnly = true;
|
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 = {
|
programs.zsh = {
|
||||||
enable = true;
|
enable = true;
|
||||||
enableCompletion = true;
|
enableCompletion = true;
|
||||||
@@ -86,6 +304,7 @@ in
|
|||||||
programs.fzf = {
|
programs.fzf = {
|
||||||
enable = true;
|
enable = true;
|
||||||
enableZshIntegration = true;
|
enableZshIntegration = true;
|
||||||
|
package = latest.fzf;
|
||||||
defaultCommand = "fd --type f --hidden --exclude .git";
|
defaultCommand = "fd --type f --hidden --exclude .git";
|
||||||
fileWidgetCommand = "fd --type f --hidden --exclude .git";
|
fileWidgetCommand = "fd --type f --hidden --exclude .git";
|
||||||
changeDirWidgetCommand = "fd --type d --hidden --exclude .git";
|
changeDirWidgetCommand = "fd --type d --hidden --exclude .git";
|
||||||
@@ -103,7 +322,7 @@ in
|
|||||||
hl = c.cyan;
|
hl = c.cyan;
|
||||||
"hl+" = c.cyan;
|
"hl+" = c.cyan;
|
||||||
border = c.border;
|
border = c.border;
|
||||||
prompt = c.blue;
|
prompt = c.accent;
|
||||||
pointer = c.purple;
|
pointer = c.purple;
|
||||||
marker = c.green;
|
marker = c.green;
|
||||||
info = c.muted;
|
info = c.muted;
|
||||||
@@ -112,11 +331,12 @@ in
|
|||||||
|
|
||||||
programs.starship = {
|
programs.starship = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
package = latest.starship;
|
||||||
settings = {
|
settings = {
|
||||||
add_newline = true;
|
add_newline = true;
|
||||||
format = "$username$hostname$directory$git_branch$git_status$nix_shell$cmd_duration\n$character";
|
format = "$username$hostname$directory$git_branch$git_status$nix_shell$cmd_duration\n$character";
|
||||||
directory = {
|
directory = {
|
||||||
style = "bold ${c.blue}";
|
style = "bold ${c.accent}";
|
||||||
truncation_length = 4;
|
truncation_length = 4;
|
||||||
truncation_symbol = "…/";
|
truncation_symbol = "…/";
|
||||||
read_only = " [read-only]";
|
read_only = " [read-only]";
|
||||||
@@ -143,14 +363,17 @@ in
|
|||||||
|
|
||||||
programs.zoxide = {
|
programs.zoxide = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
package = latest.zoxide;
|
||||||
enableZshIntegration = true;
|
enableZshIntegration = true;
|
||||||
};
|
};
|
||||||
programs.bat = {
|
programs.bat = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
package = latest.bat;
|
||||||
config.theme = "base16";
|
config.theme = "base16";
|
||||||
};
|
};
|
||||||
programs.btop = {
|
programs.btop = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
package = latest.btop;
|
||||||
settings = {
|
settings = {
|
||||||
theme_background = false;
|
theme_background = false;
|
||||||
rounded_corners = true;
|
rounded_corners = true;
|
||||||
@@ -159,6 +382,7 @@ in
|
|||||||
};
|
};
|
||||||
programs.yazi = {
|
programs.yazi = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
package = latest.yazi;
|
||||||
enableZshIntegration = true;
|
enableZshIntegration = true;
|
||||||
settings.mgr = {
|
settings.mgr = {
|
||||||
show_hidden = true;
|
show_hidden = true;
|
||||||
@@ -171,10 +395,10 @@ in
|
|||||||
enable = true;
|
enable = true;
|
||||||
font = {
|
font = {
|
||||||
name = "JetBrainsMono Nerd Font";
|
name = "JetBrainsMono Nerd Font";
|
||||||
size = 13;
|
size = 12;
|
||||||
};
|
};
|
||||||
settings = {
|
settings = {
|
||||||
window_padding_width = 14;
|
window_padding_width = 10;
|
||||||
background_opacity = "1.0";
|
background_opacity = "1.0";
|
||||||
hide_window_decorations = true;
|
hide_window_decorations = true;
|
||||||
scrollback_lines = 20000;
|
scrollback_lines = 20000;
|
||||||
@@ -186,9 +410,9 @@ in
|
|||||||
background = c.background;
|
background = c.background;
|
||||||
cursor = c.cyan;
|
cursor = c.cyan;
|
||||||
selection_foreground = c.text;
|
selection_foreground = c.text;
|
||||||
selection_background = "#354562";
|
selection_background = c.selection;
|
||||||
url_color = c.blue;
|
url_color = c.blue;
|
||||||
active_border_color = c.blue;
|
active_border_color = c.accent;
|
||||||
inactive_border_color = c.border;
|
inactive_border_color = c.border;
|
||||||
color0 = c.surface;
|
color0 = c.surface;
|
||||||
color1 = c.red;
|
color1 = c.red;
|
||||||
@@ -197,14 +421,14 @@ in
|
|||||||
color4 = c.blue;
|
color4 = c.blue;
|
||||||
color5 = c.purple;
|
color5 = c.purple;
|
||||||
color6 = c.cyan;
|
color6 = c.cyan;
|
||||||
color7 = "#dde1e6";
|
color7 = c.text;
|
||||||
color8 = c.muted;
|
color8 = c.muted;
|
||||||
color9 = "#ff99a0";
|
color9 = "#eda692";
|
||||||
color10 = "#6fdc8c";
|
color10 = "#b8c992";
|
||||||
color11 = "#f7d75c";
|
color11 = "#e6ca91";
|
||||||
color12 = "#a6c8ff";
|
color12 = "#a7c2c8";
|
||||||
color13 = "#d4bbff";
|
color13 = "#c9b9d1";
|
||||||
color14 = "#82e9de";
|
color14 = "#adcbb7";
|
||||||
color15 = c.text;
|
color15 = c.text;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+37
-11
@@ -1,5 +1,12 @@
|
|||||||
# Run as dev; privileged activation uses the already declared scoped sudo rule.
|
# Host and activation policy come from the host's systemd unit, never inference
|
||||||
repo=${NIXOS_CONFIG_REPO:-/etc/nixos}
|
# 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}
|
state=${CACHE_DIRECTORY:-/var/cache/nixos-update}
|
||||||
mkdir -p "$state"
|
mkdir -p "$state"
|
||||||
exec 9>"$state/lock"
|
exec 9>"$state/lock"
|
||||||
@@ -11,7 +18,10 @@ if [ -n "$(git status --porcelain)" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
baseline=$(git rev-parse HEAD)
|
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")
|
work=$(mktemp -d "$state/work.XXXXXXXX")
|
||||||
cleanup() {
|
cleanup() {
|
||||||
git -C "$repo" worktree remove --force "$work" >/dev/null 2>&1 || true
|
git -C "$repo" worktree remove --force "$work" >/dev/null 2>&1 || true
|
||||||
@@ -29,32 +39,48 @@ if git diff --quiet -- flake.lock; then
|
|||||||
fi
|
fi
|
||||||
nix flake check --no-build --no-update-lock-file
|
nix flake check --no-build --no-update-lock-file
|
||||||
nix build .#checks.x86_64-linux.updates .#checks.x86_64-linux.desktop-config \
|
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
|
--no-update-lock-file --no-link
|
||||||
git add flake.lock
|
git add flake.lock
|
||||||
git -c user.name='NixOS Updater' -c user.email='nixos-updater@localhost' \
|
git -c user.name='NixOS Updater' -c user.email='nixos-updater@localhost' \
|
||||||
commit -m 'chore: update NixOS package inputs'
|
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)
|
--no-update-lock-file --no-link --print-out-paths)
|
||||||
candidate=$(git rev-parse HEAD)
|
candidate=$(git rev-parse HEAD)
|
||||||
|
|
||||||
# Never overwrite work started while the candidate was building.
|
# Never overwrite work started while the candidate was building.
|
||||||
cd "$repo"
|
cd "$repo"
|
||||||
if [ "$(git rev-parse HEAD)" != "$baseline" ] || \
|
unchanged() {
|
||||||
[ "$(git symbolic-ref HEAD)" != "$branch" ] || \
|
[ "$(git rev-parse HEAD)" = "$baseline" ] &&
|
||||||
[ -n "$(git status --porcelain)" ]; then
|
[ "$(git symbolic-ref -q HEAD)" = "$branch" ] &&
|
||||||
|
[ -z "$(git status --porcelain)" ]
|
||||||
|
}
|
||||||
|
if ! unchanged; then
|
||||||
echo 'Configuration changed during the build; leaving it untouched.'
|
echo 'Configuration changed during the build; leaving it untouched.'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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"
|
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"
|
git merge --ff-only "$candidate"
|
||||||
if ! sudo "$built/sw/bin/nixos-rebuild" switch --no-reexec --store-path "$built"; then
|
if ! sudo "$built/sw/bin/nixos-rebuild" "$mode" --no-reexec --store-path "$built"; then
|
||||||
echo 'Activation failed; restoring the previous system. See the journal.' >&2
|
echo 'Activation failed; restoring the previous system/profile. See the journal.' >&2
|
||||||
sudo "$previous/sw/bin/nixos-rebuild" switch --no-reexec --store-path "$previous"
|
sudo "$previous/sw/bin/nixos-rebuild" "$mode" --no-reexec --store-path "$previous"
|
||||||
if [ "$(git rev-parse HEAD)" = "$candidate" ] && [ -z "$(git status --porcelain)" ]; then
|
if [ "$(git rev-parse HEAD)" = "$candidate" ] && [ -z "$(git status --porcelain)" ]; then
|
||||||
git -c user.name='NixOS Updater' -c user.email='nixos-updater@localhost' \
|
git -c user.name='NixOS Updater' -c user.email='nixos-updater@localhost' \
|
||||||
revert --no-edit "$candidate"
|
revert --no-edit "$candidate"
|
||||||
fi
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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.
|
# No forced reboot or garbage collection: recovery generations are retained.
|
||||||
|
|||||||
+33
-15
@@ -23,6 +23,9 @@ if name == "nix":
|
|||||||
elif args[:2] == ["flake", "check"]:
|
elif args[:2] == ["flake", "check"]:
|
||||||
if scenario == "evaluation-failure": sys.exit(42)
|
if scenario == "evaluation-failure": sys.exit(42)
|
||||||
elif args[0] == "build":
|
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 == "build-failure": sys.exit(43)
|
||||||
if scenario == "concurrent-edit":
|
if scenario == "concurrent-edit":
|
||||||
(pathlib.Path(os.environ["NIXOS_CONFIG_REPO"]) / "notes").write_text("user work\n")
|
(pathlib.Path(os.environ["NIXOS_CONFIG_REPO"]) / "notes").write_text("user work\n")
|
||||||
@@ -32,17 +35,20 @@ if name == "nix":
|
|||||||
elif name == "sudo":
|
elif name == "sudo":
|
||||||
assert "--no-reexec" in args and "--store-path" in args
|
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 == "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"]:
|
if scenario == "concurrent-dry-edit" and args[1] == "dry-activate":
|
||||||
sys.exit(45)
|
(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":
|
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"])
|
print(os.environ["PREVIOUS"])
|
||||||
else:
|
else:
|
||||||
raise AssertionError(name)
|
raise AssertionError(name)
|
||||||
'''
|
'''
|
||||||
|
|
||||||
|
|
||||||
def run_case(scenario):
|
def run_case(scenario, host="dev", mode="switch"):
|
||||||
with tempfile.TemporaryDirectory(prefix="update-test-") as directory:
|
with tempfile.TemporaryDirectory(prefix="update-test-") as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
repo = root / "repo with spaces"
|
repo = root / "repo with spaces"
|
||||||
@@ -52,7 +58,8 @@ def run_case(scenario):
|
|||||||
mocks.mkdir()
|
mocks.mkdir()
|
||||||
calls_path = root / "calls.jsonl"
|
calls_path = root / "calls.jsonl"
|
||||||
env = dict(os.environ, HOME=str(root / "home"), CACHE_DIRECTORY=str(cache),
|
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"),
|
CALLS=str(calls_path), BUILT=str(root / "candidate-system"),
|
||||||
PREVIOUS=str(root / "previous-system"),
|
PREVIOUS=str(root / "previous-system"),
|
||||||
GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null")
|
GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null")
|
||||||
@@ -77,37 +84,48 @@ def run_case(scenario):
|
|||||||
baseline = git("rev-parse", "HEAD")
|
baseline = git("rev-parse", "HEAD")
|
||||||
if scenario == "dirty":
|
if scenario == "dirty":
|
||||||
(repo / "notes").write_text("user work\n")
|
(repo / "notes").write_text("user work\n")
|
||||||
|
if scenario == "detached":
|
||||||
|
git("checkout", "--detach", "--quiet")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[shutil.which("bash"), "-euo", "pipefail", str(script)],
|
[shutil.which("bash"), "-euo", "pipefail", str(script)],
|
||||||
env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
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 []
|
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"]
|
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)
|
assert (result.returncode != 0) == failed, (scenario, result.stdout)
|
||||||
if scenario == "success":
|
if scenario == "success":
|
||||||
assert json.loads((repo / "flake.lock").read_text())["revision"] == 2
|
assert json.loads((repo / "flake.lock").read_text())["revision"] == 2
|
||||||
assert git("log", "-1", "--format=%an <%ae>") == "NixOS Updater <nixos-updater@localhost>"
|
assert git("log", "-1", "--format=%an <%ae>") == "NixOS Updater <nixos-updater@localhost>"
|
||||||
assert [call[2] for call in sudo] == ["dry-activate", "switch"]
|
assert [call[2] for call in sudo] == ["dry-activate", mode]
|
||||||
|
assert (cache / "last-success").is_file()
|
||||||
assert git("status", "--porcelain") == ""
|
assert git("status", "--porcelain") == ""
|
||||||
elif scenario == "activation-failure":
|
elif scenario == "activation-failure":
|
||||||
assert json.loads((repo / "flake.lock").read_text())["revision"] == 1
|
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 sudo[-1][-1] != env["BUILT"]
|
||||||
assert git("log", "-1", "--format=%s").startswith("Revert")
|
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:
|
else:
|
||||||
assert git("rev-parse", "HEAD") == baseline, (scenario, result.stdout)
|
assert git("rev-parse", "HEAD") == baseline, (scenario, result.stdout)
|
||||||
assert json.loads((repo / "flake.lock").read_text())["revision"] == 1
|
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 (repo / "unchanged-editor-input").read_text().strip() == "380eb86778a7c53a0f1c18e84f14037456155347"
|
||||||
assert len(git("worktree", "list", "--porcelain").split("worktree ")) == 2
|
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"
|
assert (repo / "notes").read_text() == "user work\n"
|
||||||
if scenario == "dirty":
|
if scenario in ("dirty", "detached", "invalid-host"):
|
||||||
assert not calls
|
assert not calls
|
||||||
print("PASS", scenario)
|
print("PASS", host, mode, scenario)
|
||||||
|
|
||||||
|
|
||||||
for scenario in ["dirty", "unchanged", "evaluation-failure", "build-failure", "concurrent-edit",
|
for host, mode in [("dev", "switch"), ("nixos", "boot")]:
|
||||||
"dry-activation-failure", "activation-failure", "success"]:
|
for scenario in ["dirty", "detached", "unchanged", "evaluation-failure", "build-failure",
|
||||||
run_case(scenario)
|
"concurrent-edit", "concurrent-dry-edit", "dry-activation-failure",
|
||||||
|
"activation-failure", "rollback-failure", "success"]:
|
||||||
|
run_case(scenario, host, mode)
|
||||||
|
run_case("invalid-host", "nixos", "switch")
|
||||||
|
|||||||
+15
-6
@@ -1,4 +1,9 @@
|
|||||||
{ pkgs, ... }:
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
let
|
let
|
||||||
updater = pkgs.writeShellApplication {
|
updater = pkgs.writeShellApplication {
|
||||||
name = "update-system";
|
name = "update-system";
|
||||||
@@ -13,17 +18,21 @@ let
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
environment.systemPackages = [ updater ];
|
environment.systemPackages = [ updater ];
|
||||||
systemd.tmpfiles.rules = [ "d /var/cache/nixos-update 0700 dev users -" ];
|
|
||||||
systemd.services.nixos-update = {
|
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" ];
|
wants = [ "network-online.target" ];
|
||||||
after = [ "network-online.target" ];
|
after = [ "network-online.target" ];
|
||||||
path = [ "/run/wrappers" ];
|
path = [ "/run/wrappers" ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
User = "dev";
|
User = lib.mkDefault "dev";
|
||||||
Group = "users";
|
Group = lib.mkDefault "users";
|
||||||
WorkingDirectory = "/etc/nixos";
|
WorkingDirectory = config.systemd.services.nixos-update.environment.NIXOS_CONFIG_REPO;
|
||||||
CacheDirectory = "nixos-update";
|
CacheDirectory = "nixos-update";
|
||||||
UMask = "0077";
|
UMask = "0077";
|
||||||
Nice = 10;
|
Nice = 10;
|
||||||
|
|||||||
@@ -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=";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user