Compare commits
12
Commits
d1eb2fb6ed
...
2311330dbf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2311330dbf | ||
|
|
35ca88d517 | ||
|
|
221e0b33cc | ||
|
|
83c29a083b | ||
|
|
14e7e19eec | ||
|
|
000a177607 | ||
|
|
4893fcfec0 | ||
|
|
209f4d8bda | ||
|
|
f509079c33 | ||
|
|
527d989a01 | ||
|
|
35ac95651d | ||
|
|
ad39155705 |
+7
-1
@@ -3,13 +3,19 @@
|
|||||||
/result-*
|
/result-*
|
||||||
/.direnv/
|
/.direnv/
|
||||||
|
|
||||||
|
# NixOS-generated files when the checkout lives at /etc/nix.
|
||||||
|
/nix.conf
|
||||||
|
/registry.json
|
||||||
|
|
||||||
# Local secrets must never be imported into Nix or committed.
|
# Local secrets must never be imported into Nix or committed.
|
||||||
/.env
|
/.env
|
||||||
/.env.*
|
/.env.*
|
||||||
!/.env.example
|
!/.env.example
|
||||||
/secrets/
|
/secrets/
|
||||||
|
|
||||||
# Editor temporary files.
|
# Python bytecode and editor temporary files.
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|||||||
+308
@@ -0,0 +1,308 @@
|
|||||||
|
# DeepSeek Harness project VMs
|
||||||
|
|
||||||
|
Official **`deepseek-ai/deepseek-harness` (`dsh`)**, not OpenCode. A reusable NixOS
|
||||||
|
module + flake function + shell launcher. No custom Python control plane, host
|
||||||
|
service, sudo launcher, or host rebuild is needed.
|
||||||
|
|
||||||
|
## Start a project
|
||||||
|
|
||||||
|
As your normal user, on **x86_64 Linux** with accessible `/dev/kvm` and enabled
|
||||||
|
unprivileged user namespaces (for Bubblewrap):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p ~/projects/example && cd ~/projects/example
|
||||||
|
nix flake init -t path:/etc/nix#agent
|
||||||
|
# Edit project.nix (tools/env) and flake.nix (RAM/CPU/network).
|
||||||
|
git init
|
||||||
|
git add flake.nix project.nix README.md .gitignore
|
||||||
|
nix flake lock
|
||||||
|
git add flake.lock
|
||||||
|
nix develop # ordinary host project shell
|
||||||
|
nix run .#agent # boots VM; waits and prints a private browser URL
|
||||||
|
```
|
||||||
|
|
||||||
|
Run commands **from the project directory**, including in the second terminal:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run .#agent -- url # print the current token-bearing browser login URL
|
||||||
|
nix run .#agent -- ssh # root shell, starting in the SAME project cwd
|
||||||
|
nix run .#agent -- ssh 'id; pwd; rg --version'
|
||||||
|
nix run .#agent -- ssh 'journalctl -u agent -b --no-pager'
|
||||||
|
nix run .#agent -- stop # graceful poweroff; Ctrl-C in the launcher also stops it
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the printed URL. In a fresh DSH profile, **Choose workspace → add/select the
|
||||||
|
project's original absolute path**, which exists in the guest. Then
|
||||||
|
**Settings → Models** to configure DeepSeek, if you haven't already. Existing
|
||||||
|
credentials/settings are reused. No API key is required merely to boot the UI.
|
||||||
|
No model inference runs locally: the VM's RAM/CPUs are for tools/builds, not model weights.
|
||||||
|
|
||||||
|
The template input `path:/etc/nix/agent-vm` is a local bootstrap. For collaboration
|
||||||
|
or guest-side evaluation of the flake, replace it with your accessible Git remote,
|
||||||
|
e.g. `git+https://git.cyber.ayyalasomayajula.net/marsultor/nixconfig.git?dir=agent-vm&ref=main`
|
||||||
|
**after these files have actually been committed and published there**. No push is
|
||||||
|
performed by this setup. Input changes require `nix flake update agent-vm`.
|
||||||
|
|
||||||
|
## Exactly what is shared
|
||||||
|
|
||||||
|
All of these mounts are **read-write**, as requested:
|
||||||
|
|
||||||
|
| Host source, resolved when you launch | Guest path | Includes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Current working directory, not the flake's store copy | Same absolute cwd; `/workspace` is also an alias | Live source, uncommitted work, `.git`, project `.dsh/skills` and `.agents/skills` |
|
||||||
|
| `$DSH_HOME`, otherwise `~/.dsh` | `/root/.dsh` | `.credentials.yaml`, `settings.yaml`, `.env`, profiles, plugins, skills, sessions and other DSH state |
|
||||||
|
| `${DSH_AGENTS_HOME:-~/.agents}/skills` | `/root/.agents/skills` | Shared cross-agent skills only, not the rest of `~/.agents` |
|
||||||
|
|
||||||
|
**Upstream's standard directory is `~/.dsh`, not `~/.config/dsh`.** If you have
|
||||||
|
chosen an XDG-style location, use the upstream override, consistently for all commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export DSH_HOME="$HOME/.config/dsh"
|
||||||
|
nix run .#agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Missing directories are created. Existing DSH home must be user-owned and private:
|
||||||
|
`chmod 700 ~/.dsh` (or your actual `DSH_HOME`); existing credentials must be mode
|
||||||
|
600 as DSH requires. The launcher never reads the credentials into Nix, copies
|
||||||
|
them to the store, or replaces your settings/skills with generated content.
|
||||||
|
|
||||||
|
Root inside the guest is **not host root**. QEMU/9p writes as the launching host
|
||||||
|
user, so newly created project files belong to you. The whole DSH directory is
|
||||||
|
mounted instead of individual credential files so atomic rename-based saves work.
|
||||||
|
DSH retains each project's real cwd identity rather than confusing every project's
|
||||||
|
sessions with a single `/workspace` identity. Do not run host DSH and several
|
||||||
|
VMs concurrently against the same mutable profile: package-fallback links and
|
||||||
|
profile changes can race. Separate `DSH_HOME` values provide independent profiles.
|
||||||
|
|
||||||
|
The runner retains a private per-cwd directory under
|
||||||
|
`${XDG_STATE_HOME:-~/.local/state}/agent-vm/`: dedicated SSH keys, console log,
|
||||||
|
control socket, and a 4 GiB sparse npm-cache disk. Guest root, guest Nix writes,
|
||||||
|
processes and other unshared state are otherwise ephemeral. This is **not** a
|
||||||
|
push/pull/snapshot workflow; edits immediately affect the mounted host files.
|
||||||
|
|
||||||
|
## Resources and Nix composition
|
||||||
|
|
||||||
|
The template uses one ordinary Nix attrset:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
project = import ./project.nix { inherit pkgs; };
|
||||||
|
devShells.${system}.default = pkgs.mkShell {
|
||||||
|
inherit (project) packages env;
|
||||||
|
};
|
||||||
|
agent = agent-vm.lib.mkAgentVM {
|
||||||
|
inherit system project;
|
||||||
|
modules = [ {
|
||||||
|
microvm.mem = 8192; # MiB
|
||||||
|
microvm.vcpu = 6;
|
||||||
|
agentVM.packages = [ pkgs.strace ]; # additional guest-only tool
|
||||||
|
} ];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The function imports the microvm.nix and agent NixOS modules and merges your
|
||||||
|
modules with them. Normal `imports`, `mkDefault`, `mkForce`, and list merging work.
|
||||||
|
`apps.${system}.agent = agent.app` exposes `nix run .#agent`; `agent.nixos` exposes
|
||||||
|
the NixOS configuration for inspection. RAM/CPU/network changes apply on **stop
|
||||||
|
and restart**, not live. vCPUs are not a host CPU-time quota, and QEMU has some
|
||||||
|
memory overhead beyond guest RAM.
|
||||||
|
|
||||||
|
The standard guest toolkit includes Git/LFS, ripgrep, fd, Python, Node/npm/pnpm,
|
||||||
|
jq/yq, curl/wget, common archive tools, Make, pkg-config, ShellCheck, and ordinary
|
||||||
|
Unix inspection tools. Project versions/tools belong in `project.nix`.
|
||||||
|
|
||||||
|
This deliberately shares **packages and non-secret environment variables**, not
|
||||||
|
an arbitrary shell's internals. Existing `shellHook`, `inputsFrom`, cross-compilation
|
||||||
|
setup hooks, library search paths and background dev services are not magically
|
||||||
|
converted into NixOS configuration. Factor tools/env into the shared attrset;
|
||||||
|
configure required guest services through `modules`. Do not put credentials in
|
||||||
|
`project.env`. Build outputs/package definitions can use the same `pkgs` input.
|
||||||
|
|
||||||
|
## Networking: bind address versus VM interface
|
||||||
|
|
||||||
|
DSH intentionally rejects `dsh web --host 0.0.0.0`. We don't patch around its
|
||||||
|
browser protections: it listens on **guest `127.0.0.1:3080`**, and the shell launcher
|
||||||
|
publishes an **SSH local forward** on your chosen **host** IPv4 address/port.
|
||||||
|
DSH's random launch-token → signed-cookie authentication and Host/Origin checks
|
||||||
|
remain in use. Only SSH is forwarded by QEMU, always on host loopback.
|
||||||
|
|
||||||
|
```nix
|
||||||
|
agentVM.network = {
|
||||||
|
hostAddress = "127.0.0.1"; # default: this computer only
|
||||||
|
sshPort = 2222;
|
||||||
|
webPort = 3080;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- **One LAN/VPN interface:** set `hostAddress = "192.168.1.20"` (an IP actually
|
||||||
|
assigned to this host). Its browser authority is automatically trusted.
|
||||||
|
- **All IPv4 interfaces:** set `hostAddress = "0.0.0.0"` and
|
||||||
|
`trustedHosts = [ "192.168.1.20:3080" "laptop.example:3080" ];`.
|
||||||
|
Use a real address, not `0.0.0.0`, in your browser. The printed local URL can
|
||||||
|
have its host replaced with one of those authorities. This includes public
|
||||||
|
interfaces too; it is not shorthand for “LAN only.”
|
||||||
|
- Multiple project VMs need distinct host SSH/Web ports.
|
||||||
|
- Binding a host IP controls the **incoming listener**, not outgoing routing or
|
||||||
|
which NIC reaches DeepSeek. Outgoing traffic follows host routes/VPN policy.
|
||||||
|
- No host firewall is changed. For LAN access, explicitly allow only the Web port
|
||||||
|
on the intended host interface in your firewall. Do not open the SSH forward.
|
||||||
|
|
||||||
|
**Use a VPN or a TLS reverse proxy for off-host access.** The forward is encrypted
|
||||||
|
between host and guest, but browser → host remains HTTP. A token/cookie on an
|
||||||
|
untrusted network can be stolen. Static assets and third-party plugin routes
|
||||||
|
may not share DSH's RPC authentication. Never expose this developer-preview
|
||||||
|
service directly to the public Internet.
|
||||||
|
|
||||||
|
### Optional dedicated TAP interface
|
||||||
|
|
||||||
|
Default user networking needs no administrator setup. If you need your own guest
|
||||||
|
IP/interface rather than NAT, add:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
agentVM.network = {
|
||||||
|
mode = "tap";
|
||||||
|
tapName = "agent0";
|
||||||
|
mac = "02:00:00:00:00:10";
|
||||||
|
guestAddress = "192.168.77.2";
|
||||||
|
prefixLength = 24;
|
||||||
|
gateway = "192.168.77.1";
|
||||||
|
dns = [ "YOUR_REACHABLE_DNS_IP" ];
|
||||||
|
hostAddress = "127.0.0.1"; # Web publication is still independently configurable.
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
An administrator must first provision `agent0`, owned by the launching user,
|
||||||
|
with `multi_queue` when using multiple vCPUs, and arrange the host's address,
|
||||||
|
routing/NAT/DNS/firewall. For example, the interface creation portion is
|
||||||
|
`sudo ip tuntap add agent0 mode tap user "$USER" multi_queue`. That command alone
|
||||||
|
**does not** establish routing. See microvm.nix's linked routed-network docs for
|
||||||
|
persistent NixOS setup. Do not substitute a physical Wi-Fi/Ethernet interface for
|
||||||
|
`tapName`, or run QEMU as root. TAP joins a less isolated network; the host's
|
||||||
|
loopback publication default does not hide guest SSH from other routed peers.
|
||||||
|
|
||||||
|
## What “latest” means
|
||||||
|
|
||||||
|
- **Nix:** moving `nixos-unstable` (latest tested rolling channel), microvm.nix's
|
||||||
|
default branch, and `inputs.*.follows` to keep the project's package set coherent.
|
||||||
|
`nix flake update` refreshes the resolved snapshots; restart to use them.
|
||||||
|
`flake.lock` is retained because that is how flakes work, not a manually selected
|
||||||
|
old package version. The helper's own lock and each consuming project's lock
|
||||||
|
are independent.
|
||||||
|
- **DSH:** `npm exec --yes --package=@deepseek-ai/dsh@latest -- dsh ...` runs
|
||||||
|
**inside the VM**. Each launch resolves the current npm `latest` dist-tag,
|
||||||
|
using a persistent download cache. Network/registry availability is required;
|
||||||
|
failed installs are visible in `journalctl -u agent`. There is no fixed DSH
|
||||||
|
version, handwritten dependency hash, host `npm -g`, or claim of reproducibility
|
||||||
|
for this mutable part. `latest` can itself be a release candidate. A two-line
|
||||||
|
Node shim supplies `--expose-internals`, required by the current Cordis HMR
|
||||||
|
dependency but missing from the published CLI's shebang.
|
||||||
|
- **Plugins:** explicitly opt in, then update with DSH/pnpm. No unreviewed community
|
||||||
|
plugins are automatically installed or upgraded by this repository.
|
||||||
|
- **Workstation:** the root `/etc/nix` flake now also follows `nixos-unstable` and
|
||||||
|
Home Manager `master`; CLI tools still use Nixpkgs `master`. Its existing daily
|
||||||
|
updater refreshes those inputs and stages the tested result for next boot.
|
||||||
|
`stateVersion = "26.05"` remains a compatibility setting, **not a package pin**.
|
||||||
|
The existing separate Neovim-dotfile revision and manually packaged Element
|
||||||
|
binary are unchanged; their documented update boundaries still apply.
|
||||||
|
|
||||||
|
## Plugins worth considering
|
||||||
|
|
||||||
|
DSH is still a developer preview. Popularity is not a security audit or a promise
|
||||||
|
of compatibility with tomorrow's `latest`. These are actual DSH plugins, not
|
||||||
|
OpenCode plugins relabeled as DeepSeek plugins. GitHub stars checked 2026-09-06:
|
||||||
|
|
||||||
|
| Plugin | Why consider it | Approx. repository stars |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [DSH better sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) | File explorer/editor, Git and terminal alongside the conversation | 3.4k |
|
||||||
|
| [dsh-market](https://github.com/dsh-market/dsh-market) | Discover/manage plugins in the DSH settings UI | 3.3k |
|
||||||
|
| [dsh-agent-teams](https://github.com/NanmiCoder/dsh-agent-teams) | Multi-agent delegation; add only when you need it, as it can multiply API cost | 1.4k |
|
||||||
|
| [dsh-context](https://github.com/bowenliang123/dsh-context) | Inspect context/token use and manage context | 1.3k |
|
||||||
|
|
||||||
|
Start with **dsh-context**, then optionally the sidebar. A marketplace and a
|
||||||
|
multi-agent orchestrator are not necessary to make the core harness useful.
|
||||||
|
Review their source first; native-addon build prerequisites vary. Example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run .#agent -- ssh
|
||||||
|
# Now INSIDE the VM, with your real DSH home mounted RW:
|
||||||
|
dsh plugin --profile web add dsh-context@latest
|
||||||
|
# Optional, separately reviewed:
|
||||||
|
dsh plugin --profile web add dshmarket@latest
|
||||||
|
systemctl restart agent
|
||||||
|
exit
|
||||||
|
nix run .#agent -- url # restart creates a fresh browser launch token
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `dsh plugin --profile web update --latest` when you explicitly want to update
|
||||||
|
installed plugins. Review pnpm's build-script approval requests rather than
|
||||||
|
blanket-approving everything. The built-in MCP bridge can also connect to popular
|
||||||
|
projects such as [Context7](https://github.com/upstash/context7) and
|
||||||
|
[Playwright MCP](https://github.com/microsoft/playwright-mcp); those are separate
|
||||||
|
MCP services, not evidence that a particular DSH adapter is widely deployed.
|
||||||
|
Playwright needs its browsers/dependencies inside the guest. No browser sessions,
|
||||||
|
external MCP credentials or arbitrary host skill symlink targets are imported.
|
||||||
|
|
||||||
|
## Sandbox boundary and limitations
|
||||||
|
|
||||||
|
- Trust the project's flake and launcher before `nix run`: an arbitrary flake app
|
||||||
|
is host code and can choose not to launch this sandbox at all.
|
||||||
|
- Separate KVM guest kernel; agent is root only there. Rootless QEMU is additionally
|
||||||
|
confined by Bubblewrap: restricted filesystem, private process/IPC/UTS/user
|
||||||
|
namespaces, dropped capabilities, no inherited host environment.
|
||||||
|
- No host home mount, Nix daemon/socket, SSH-agent forwarding, Docker socket,
|
||||||
|
desktop session sockets, or full host Nix-store share **inside the guest**.
|
||||||
|
QEMU itself needs read-only access to `/nix/store` to run its host binaries.
|
||||||
|
- Writable **cwd + DSH home + shared skills are intentional holes in the boundary**.
|
||||||
|
A bad agent/plugin can delete those files, steal tokens, corrupt Git metadata,
|
||||||
|
and plant malicious skills/plugins for future host runs. Back them up and use
|
||||||
|
scoped/revocable API keys. Never execute the shared plugin state on the host
|
||||||
|
without trusting changes made in the VM.
|
||||||
|
- Symlinks outside the shares do not grant those extra host directories. This
|
||||||
|
also means external Git-worktree metadata, symlinked skills, local path flake
|
||||||
|
inputs and host-built `node_modules` may not work. Use self-contained checkouts
|
||||||
|
and Linux-compatible dependencies, not wider home mounts to make errors vanish.
|
||||||
|
- Outgoing networking is **not filtered**: API access, exfiltration, host/LAN
|
||||||
|
services and cloud metadata can be reachable. This is filesystem/process
|
||||||
|
isolation, not an egress-security appliance. Use a separate filtered network
|
||||||
|
or machine for hostile code and don't give it real shared credentials.
|
||||||
|
- 9p favors a small rootless setup over maximum filesystem throughput. Remote
|
||||||
|
host edits may require a DSH refresh/restart for watchers to notice them.
|
||||||
|
- Latest code, kernels, QEMU and mounts can contain vulnerabilities. This is
|
||||||
|
defense in depth, not a claim of an audited or escape-proof sandbox.
|
||||||
|
|
||||||
|
## Checks and sources
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix flake check path:/etc/nix/agent-vm --no-build
|
||||||
|
nix build path:/etc/nix/agent-vm#checks.x86_64-linux.config \
|
||||||
|
path:/etc/nix/agent-vm#checks.x86_64-linux.shell --no-link
|
||||||
|
# Actual offline microVM boot/mount/SSH test, with a fake harness (no API calls):
|
||||||
|
nix build path:/etc/nix/agent-vm#checks.x86_64-linux.boot --no-link
|
||||||
|
```
|
||||||
|
|
||||||
|
The boot test requires KVM and nested user namespaces in the Nix build sandbox.
|
||||||
|
A real `dsh@latest` Web startup additionally requires network access and is not
|
||||||
|
silently replaced by the fake harness in normal usage.
|
||||||
|
|
||||||
|
Validated here: template/host/helper flake evaluation, ShellCheck, the real
|
||||||
|
microVM offline boot/mount/ownership/isolation test, and a separate live official
|
||||||
|
DSH Web launch with disposable config. The live check returned **401** without
|
||||||
|
a cookie, **200** after the token exchange, and **403** for an untrusted Host;
|
||||||
|
DSH stayed running without restarts. No real API credentials, model requests,
|
||||||
|
community plugins, TAP network provisioning, or host activation were involved.
|
||||||
|
|
||||||
|
Research used the **new official wiki**, plus upstream sources:
|
||||||
|
|
||||||
|
- [NixOS wiki: Flakes](https://wiki.nixos.org/wiki/Flakes) — lockfiles, pure inputs,
|
||||||
|
dev shells, apps, and Git-tracked source.
|
||||||
|
- [NixOS wiki: Virtualization](https://wiki.nixos.org/wiki/Virtualization) — points
|
||||||
|
to [microvm.nix](https://microvm-nix.github.io/microvm.nix/declaring.html).
|
||||||
|
- [microvm interfaces](https://microvm-nix.github.io/microvm.nix/interfaces.html),
|
||||||
|
[shares](https://microvm-nix.github.io/microvm.nix/shares.html), and
|
||||||
|
[routed networking](https://microvm-nix.github.io/microvm.nix/routed-network.html).
|
||||||
|
- [DSH CLI reference](https://github.com/deepseek-ai/deepseek-harness/blob/master/apps/cli/reference/README.md),
|
||||||
|
[Web guide](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/user/guide/index.md),
|
||||||
|
[skill paths](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/skill/skill-filesystem/README.md),
|
||||||
|
[browser auth](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/connection/README.md),
|
||||||
|
[safety notice](https://github.com/deepseek-ai/deepseek-harness/blob/master/SAFETY.md).
|
||||||
|
- [Awesome DSH plugins](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)
|
||||||
|
plus the individual repositories above; no third-party plugin was installed.
|
||||||
+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 now at 133⅓%** (1440×900 logical pixels; reduced from the original 150% audit at the user's request). 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. Both hosts now share dev-owned checkouts and the same updater, staging checked generations for the next boot without logging out the user or rebooting.
|
||||||
|
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 using the same SDDM/UWSM workstation module as the laptop: 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/AWS/shared-policy assertions, 125 CLI executable smoke checks, 14 desktop-action tests, 23 updater regressions, 22 manual-switch regressions (including lock retention through sudo), both host system builds, and the full SDDM/UWSM 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,102 +1,229 @@
|
|||||||
# Development host
|
# 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.
|
||||||
|
|
||||||
|
**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 base, platform, Nix features, compatibility version, imports |
|
| `common.nix` | Shared environment, locale/timezone, Nix features, update policy and compatibility version |
|
||||||
| `users.nix` | `dev`, SSH/sudo, Home Manager integration, workspace/repo ownership |
|
| `physical.nix`, `hardware-configuration.nix` | Laptop boot/storage, panel scale, CPU sensor and checkout/target identity |
|
||||||
| `dev-authorized-keys` | Public SSH keys for `dev`—never private keys |
|
| `configuration.nix` | AWS boot/storage/network/recovery integration and checkout/target identity |
|
||||||
| `tools.nix`, `colors.nix` | Zsh, Kitty, Pi, Starship, fzf, Yazi, btop, Git policy and common CLI tools; shared readable palette |
|
| `users.nix` | `dev`, SSH authorization, sudo, Home Manager and workspace ownership |
|
||||||
| `desktop.nix`, `hyprland.lua`, `anyrun.css`, `wallpaper.svg` | Hyprland/UWSM, bar, launcher, original wallpaper, notifications, lock/idle, PipeWire and desktop styling |
|
| `tools.nix`, `network.nix` | Development toolkit, terminal/shell, rootless Podman, VPN/proxy clients, local Tor service and network diagnostics |
|
||||||
| `apps.nix` | Firefox ESR, KeePassXC, Thunderbird, Steam, Element, Slack and Zathura |
|
| `colors.nix`, `wallpaper.nix`, `wallpaper.svg` | Shared One Ring palette, hash-pinned wallpaper and original fallback artwork |
|
||||||
| `workstation.nix`, `nvidia.nix` | Separately selected physical-workstation/greeter and NVIDIA integration; not enabled on EC2 |
|
| `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 |
|
||||||
| `updates.nix`, `update-system.sh` | Daily stable-input updates in an isolated Git worktree; validated commits, no forced reboot or GC |
|
| `apps.nix`, `element-nightly.nix` | Firefox ESR, Tor Browser, KeePassXC, Thunderbird, Steam, pinned Element Nightly, Slack, file/media viewers and MIME defaults |
|
||||||
| `desktop-test.nix`, `desktop-test.py`, `audit-desktop.sh`, `update-test.py` | Disposable graphical audit and updater failure/concurrency tests |
|
| `neovim.nix`, `neovim-test.lua` | Unmodified upstream editor deployment and opt-in native runtime audit |
|
||||||
| `network.nix` | systemd-resolved and network/WireGuard diagnostics; leaves interface management with the host |
|
| `updates.nix`, `update-system.sh`, `update-test.py` | Shared dev-owned checkouts, daily boot-staged updates and failure/concurrency regression tests |
|
||||||
| `neovim.nix`, `neovim-test.lua` | Editor, unchanged upstream dotfile deployment and opt-in native runtime audit |
|
| `switch-system.sh`, `switch-test.py` | Same installed manual apply/preview command on both hosts, with host identity supplied by Nix |
|
||||||
| [DESKTOP.md](DESKTOP.md) | Historical component research; executable configuration is in the files above |
|
| `physical-test.nix`, `tools-test.nix` | Physical/AWS safety, shared-policy assertions and bounded offline tool/help smoke tests |
|
||||||
|
| `desktop-test.nix`, `desktop-test.py`, `audit-desktop.sh` | Disposable graphical/PAM/audio/scaling audit |
|
||||||
|
| `workstation.nix`, `nvidia.nix` | Shared local hardware/SDDM integration for laptop and VM; separate opt-in NVIDIA support |
|
||||||
|
| [DESKTOP.md](DESKTOP.md) | Live audit, wallpaper provenance, JaKooLit comparison and explicit feature-completion plan |
|
||||||
|
| `agent-vm/`, `templates/agent/`, [AGENT-VM.md](AGENT-VM.md) | Reusable rootless DSH microVM, shared project toolchain, live RW cwd/config/credentials/skills and access/networking guide |
|
||||||
|
|
||||||
## 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`; existing SSM-user administration and root SSH recovery remain intact.
|
- `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.
|
||||||
- `/etc/nixos` is writable by `dev` through a native tmpfiles ownership rule, which does not follow store symlinks.
|
- 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.
|
- Both checkouts are **dev-owned**, including Git metadata: `/etc/nix` on the laptop and `/etc/nixos` on EC2. Existing paths are retained to avoid moving deployed repositories. NixOS maintains ownership without following symlinks into the Nix store; the laptop's original `/etc/nixos` files stay 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.
|
- On EC2, AWS boot/storage/networking and SSM/SSH recovery remain intact; no physical greeter, Wi-Fi/Bluetooth or battery services are added. User settings, locale/timezone, packages and update policy come from the same modules. The graphical VM now uses the same SDDM/UWSM integration as the laptop.
|
||||||
- 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.
|
- The SSH key in `dev-authorized-keys` is public. Account/cloud/browser/VPN credentials and vaults remain user-controlled.
|
||||||
- KeePassXC preferences are seeded once and remain writable. Open/create your own vault and pair its browser extension; no vault, account or VPN credentials are embedded. The physical-workstation module requires a securely provisioned `hashedPasswordFile`; EC2 gets no greeter or autologin.
|
|
||||||
- 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` and `wg-quick` are installed without any tunnels, peers, keys or added firewall ports. `resolvectl` is backed by resolved; DHCP remains under the existing host network manager.
|
|
||||||
|
|
||||||
Enter from an administrator session with `sudo -iu dev`.
|
### 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 (including ncat/nping), tcpdump/tshark, doggo/dig, ldns/drill, fping, iftop/bandwhich, traceroute, whois, ethtool, netcat, socat, torsocks and proxychains-ng. Tor runs as a local client service (below). 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.
|
||||||
|
|
||||||
|
### Per-project DeepSeek Harness
|
||||||
|
|
||||||
|
[AGENT-VM.md](AGENT-VM.md) documents the project template and `nix run .#agent`.
|
||||||
|
The official `dsh@latest` runs as root in a rootless microVM, with the current
|
||||||
|
project and your standard DSH home/shared skills mounted **read-write**. Its
|
||||||
|
host Web listener defaults to localhost; RAM, vCPUs, IPs and optional TAP
|
||||||
|
networking are configured through Nix modules. No host service is activated.
|
||||||
|
|
||||||
|
### Local Tor client
|
||||||
|
|
||||||
|
On both hosts, `network.nix` installs Tor from the **system pin** and enables `tor.service` at boot. It runs as the dedicated `tor` user with the NixOS module's sandbox and private persistent state in `/var/lib/tor`. Systemd restarts an exited daemon after five seconds without a retry limit; an explicit `systemctl stop tor` still stops it normally. A running process does not guarantee network connectivity—check for `Bootstrapped 100%` in the journal.
|
||||||
|
|
||||||
|
The SOCKS listener is **`127.0.0.1:9050` only**, with destination and SOCKS-auth isolation. There is no relay/exit, onion service, control listener, transparent proxy, system DNS change or global proxy environment. Applications must opt in; installing Tor does **not** anonymize the whole machine. Prefer native SOCKS5 support with proxy-side hostname resolution (`socks5h`), for example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl status tor.service
|
||||||
|
journalctl -u tor.service -b --no-pager
|
||||||
|
curl --fail --show-error --max-time 60 --proxy socks5h://127.0.0.1:9050 \
|
||||||
|
https://check.torproject.org/api/ip
|
||||||
|
# For compatible dynamically linked applications, explicitly wrap one command:
|
||||||
|
torsocks curl --fail --show-error --max-time 60 https://check.torproject.org/api/ip
|
||||||
|
```
|
||||||
|
|
||||||
|
`torsocks` and `proxychains4` are opt-in wrappers, not sandboxes: static binaries and applications that bypass their hooks are not reliably covered. Tor carries TCP, not arbitrary UDP/ICMP; do not assume tools such as fping, raw-packet nmap or traceroute run through it. Keep ordinary DNS lookups out of workflows that require Tor-side resolution. **Tor Browser is installed by `apps.nix`**: launch `tor-browser` or select **Tor Browser** in the application launcher after applying the configuration. It retains its separate profile and upstream privacy defaults; Firefox remains the default browser. Tor Browser is not simply a generic browser pointed at the system SOCKS port, and it is not auto-started.
|
||||||
|
|
||||||
## Build and apply
|
## Build and apply
|
||||||
|
|
||||||
As `dev`, from `/etc/nixos`:
|
### One-command sync on either host
|
||||||
|
|
||||||
|
Save your work, then run the installed command from any directory:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nixfmt --check ./*.nix
|
switch-system # explicitly apply now and make it the boot default
|
||||||
nix flake check --no-build --no-update-lock-file
|
switch-system dry-activate # build and preview changes without applying
|
||||||
nix build .#checks.x86_64-linux.updates .#checks.x86_64-linux.desktop-config --no-link
|
switch-system boot # stage for the next boot instead
|
||||||
nix build .#nixosConfigurations.dev.config.system.build.toplevel --no-update-lock-file
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Nix supplies the correct checkout/target to the **same script** on each host. Builds run as `dev`; only activation uses sudo. It applies the exact built output, including `dev`'s Home Manager configuration, shares the automatic updater's lock, stops on build failure, keeps recovery generations and never reboots. It does **not** pull Git or update `flake.lock`: “latest” means the current checkout. Add new source files to Git for flakes to include them. Open a new terminal afterward for shell environment changes; some desktop changes require a fresh login. `--help` lists the modes, including temporary `test` activation.
|
||||||
|
|
||||||
Review and activate exactly the built closure:
|
Direct `/etc/nix/switch-system.sh` execution still defaults to the laptop. When running the source script on EC2 before the packaged command is installed, explicitly set `NIXOS_CONFIG_REPO=/etc/nixos NIXOS_UPDATE_HOST=dev` and choose `boot`. Automatic updates, unlike an explicit manual switch, **always stage for the next boot on both hosts**.
|
||||||
|
|
||||||
|
### Detailed validation and activation
|
||||||
|
|
||||||
|
On the **physical laptop**, from an administrator shell:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
built=$(readlink -f result)
|
cd /etc/nix
|
||||||
|
nixfmt --check ./*.nix
|
||||||
|
nix flake check --no-build --no-update-lock-file
|
||||||
|
nix build .#checks.x86_64-linux.updates \
|
||||||
|
.#checks.x86_64-linux.desktop-config \
|
||||||
|
.#checks.x86_64-linux.physical-config \
|
||||||
|
.#checks.x86_64-linux.tools .#checks.x86_64-linux.desktop-actions \
|
||||||
|
.#checks.x86_64-linux.switch-system --no-update-lock-file --no-link
|
||||||
|
nix build .#nixosConfigurations.nixos.config.system.build.toplevel \
|
||||||
|
--no-update-lock-file --out-link result-nixos
|
||||||
|
built=$(readlink -f result-nixos)
|
||||||
sudo "$built/sw/bin/nixos-rebuild" dry-activate --no-reexec --store-path "$built"
|
sudo "$built/sw/bin/nixos-rebuild" dry-activate --no-reexec --store-path "$built"
|
||||||
|
```
|
||||||
|
|
||||||
|
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 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
|
||||||
|
built=$(readlink -f /etc/nix/result-nixos)
|
||||||
|
sudo "$built/sw/bin/nixos-rebuild" test --no-reexec --store-path "$built"
|
||||||
|
# After testing login/session, persist exactly that closure:
|
||||||
sudo "$built/sw/bin/nixos-rebuild" switch --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.
|
`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`. Both hosts' daily updaters use that policy.
|
||||||
|
|
||||||
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:
|
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
|
```sh
|
||||||
nix --extra-experimental-features 'nix-command flakes' build \
|
readlink -f /run/current-system /run/booted-system /nix/var/nix/profiles/system
|
||||||
.#nixosConfigurations.dev.config.system.build.toplevel --no-update-lock-file
|
sudo nix-env --profile /nix/var/nix/profiles/system --list-generations
|
||||||
```
|
```
|
||||||
|
|
||||||
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.**
|
To restore the booted closure temporarily from the retained console:
|
||||||
|
|
||||||
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.
|
```sh
|
||||||
|
previous=$(readlink -f /run/booted-system)
|
||||||
|
sudo "$previous/sw/bin/nixos-rebuild" test --no-reexec --store-path "$previous"
|
||||||
|
```
|
||||||
|
|
||||||
## Neovim: import, do not rewrite
|
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.
|
||||||
|
|
||||||
The input is [the existing neovim-dots repository](https://git.cyber.ayyalasomayajula.net/marsultor/neovim-dots), initially pinned to `380eb86778a7c53a0f1c18e84f14037456155347`.
|
## Automatic freshness, precisely
|
||||||
|
|
||||||
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.
|
- **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 now follow rolling **`nixos-unstable`** (the latest tested channel) and Home Manager **`master`**, rather than a fixed release branch. Most standalone CLI tools use **Nixpkgs master** for faster updates. They are imported separately, **not** overlaid onto the desktop's GCC/Python/libraries. Flake locks record each resolved snapshot; input updates, not deleting locks, advance Nix-owned packages.
|
||||||
|
- **Same policy on both hosts:** run as `dev`, build/check/record and **stage for next boot**. There is no automatic live switch, logout or reboot. Only checkout path and explicit flake target differ: laptop `/etc/nix#nixos`, EC2 `/etc/nixos#dev`. Installed versions change when that generation is booted, or explicitly switched by the user.
|
||||||
|
- Both skip dirty/detached repositories, serialize runs with manual switching, use an isolated worktree, check for edits again after dry activation and commit only the tested lock. On staging failure, the updater restores the previously selected **boot generation**, including a generation that was already staged but not running. A failed rollback is reported as failure; recovery generations are retained.
|
||||||
|
- **“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.
|
||||||
|
|
||||||
- `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.
|
Inspect or trigger:
|
||||||
- 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.
|
```sh
|
||||||
|
systemctl list-timers nixos-update.timer
|
||||||
|
journalctl -u nixos-update
|
||||||
|
sudo systemctl start nixos-update.service
|
||||||
|
sudo less /var/cache/nixos-update/last-success
|
||||||
|
```
|
||||||
|
|
||||||
## Validation and commits
|
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.
|
||||||
|
|
||||||
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.
|
## Editor and application data
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Updates and safety
|
As `dev`, the separate runtime audit may download dependencies but does not modify managed Lua or save buffers:
|
||||||
|
|
||||||
- `nixos-update.timer` checks daily with up to one hour of jitter. Only the stable Nixpkgs/Home Manager branches advance; Neovim's source stays fixed. The updater skips a dirty repository, builds in a detached worktree, records a tested commit and applies it without rebooting. It attempts rollback if activation fails and retains recovery generations. Inspect `journalctl -u nixos-update` or trigger it with `sudo systemctl start nixos-update`.
|
```sh
|
||||||
- For a manual input refresh: `nix flake update nixpkgs home-manager`, review `flake.lock`, then check/build. The exact resolved revisions and hashes remain committed.
|
nvim --headless ~/.config/nvim/init.lua -c 'luafile /etc/nix/neovim-test.lua'
|
||||||
- Keep the EC2 module, sandboxing, signature verification and recovery access intact.
|
```
|
||||||
- `system.stateVersion` and `home.stateVersion` are both `26.05`; these preserve compatibility, not package versions.
|
|
||||||
- `test` activates changes too; it is not a dry run. Keep the original system generation.
|
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.
|
||||||
- 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.
|
`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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
## Validation and contributions
|
||||||
|
|
||||||
|
`./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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Offline test of the real microvm.nix runner, mounts, SSH and host-side sandbox.
|
||||||
|
set -euo pipefail
|
||||||
|
launcher=$1
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
export HOME=$tmp/home DSH_HOME=$tmp/dsh DSH_AGENTS_HOME=$tmp/agents XDG_STATE_HOME=$tmp/state
|
||||||
|
mkdir -p "$HOME" "$tmp/project" "$DSH_HOME/skills" "$DSH_AGENTS_HOME/skills"
|
||||||
|
chmod 700 "$DSH_HOME"
|
||||||
|
printf 'not shared\n' > "$HOME/host-only-secret"
|
||||||
|
ln -s "$HOME/host-only-secret" "$tmp/project/escape"
|
||||||
|
cd "$tmp/project"
|
||||||
|
"$launcher" run > "$tmp/launcher.log" 2>&1 &
|
||||||
|
pid=$!
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if (( status )); then
|
||||||
|
grep -h . "$tmp/launcher.log" "$XDG_STATE_HOME"/agent-vm/*/console.log | tail -80 || true
|
||||||
|
fi
|
||||||
|
"$launcher" stop >/dev/null 2>&1 || true
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
wait "$pid" 2>/dev/null || true
|
||||||
|
# Never delete real project/config data; everything here is a test fixture.
|
||||||
|
rm -rf "$tmp"
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
ready=false
|
||||||
|
for ((i=0; i<120; i++)); do
|
||||||
|
if "$launcher" ssh true 2>/dev/null; then ready=true; break; fi
|
||||||
|
if ! kill -0 "$pid" 2>/dev/null; then break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if ! $ready; then
|
||||||
|
grep -h . "$tmp/launcher.log" "$XDG_STATE_HOME"/agent-vm/*/console.log || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ $("$launcher" ssh 'id -u') == 0 ]]
|
||||||
|
[[ $("$launcher" ssh pwd) == "$tmp/project" ]]
|
||||||
|
[[ $("$launcher" ssh nproc) == 4 ]]
|
||||||
|
[[ $("$launcher" ssh 'printenv AGENT_PROJECT_TEST') == shared ]]
|
||||||
|
[[ $("$launcher" ssh hello) == 'Hello, world!' ]]
|
||||||
|
"$launcher" ssh 'test ! -e /workspace/escape; test ! -e /run/host; test -d /nix/.rw-store'
|
||||||
|
"$launcher" ssh 'printf edited > /workspace/changed; printf config > /root/.dsh/config-test; printf creds > /root/.dsh/credentials-test; printf skill > /root/.dsh/skills/test.md; printf shared > /root/.agents/skills/test.md'
|
||||||
|
[[ $(< changed) == edited && $(< "$DSH_HOME/config-test") == config ]]
|
||||||
|
[[ $(< "$DSH_HOME/credentials-test") == creds && $(< "$DSH_HOME/skills/test.md") == skill ]]
|
||||||
|
[[ $(< "$DSH_AGENTS_HOME/skills/test.md") == shared ]]
|
||||||
|
[[ $(stat -c %u changed) == "$(id -u)" ]]
|
||||||
|
"$launcher" ssh 'command -v rg python3 git; findmnt /workspace; findmnt /root/.dsh'
|
||||||
|
# A second start must fail without disrupting the existing VM.
|
||||||
|
if "$launcher" run >/dev/null 2>&1; then echo 'Duplicate launch succeeded' >&2; exit 1; fi
|
||||||
|
"$launcher" stop
|
||||||
|
wait "$pid"
|
||||||
|
trap - EXIT
|
||||||
|
rm -rf "$tmp"
|
||||||
|
echo 'PASS: microVM boot, root SSH, shared toolchain, RW cwd/config/creds/skills, host ownership, symlink isolation, duplicate lock, shutdown'
|
||||||
Generated
+65
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"microvm": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"spectrum": "spectrum"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1788636433,
|
||||||
|
"narHash": "sha256-iCLUJO5V2ZlEAlYxlkCZ5YLkyQrpkyXOMUTkPXjsdPk=",
|
||||||
|
"owner": "microvm-nix",
|
||||||
|
"repo": "microvm.nix",
|
||||||
|
"rev": "804cbac7a462aa0fa8bb60c3d2fc4ead0a62060f",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "microvm-nix",
|
||||||
|
"repo": "microvm.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1788614874,
|
||||||
|
"narHash": "sha256-7QYjT2vHLuX9Z1pdxHXDKCbh1CR3D/2rywB9Tx0MPRg=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "c043004d1c6985732bcc1cbc5a9c9aecbbb4e0f0",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"microvm": "microvm",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"spectrum": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785761586,
|
||||||
|
"narHash": "sha256-MWOMVqJJERwjQGgRIv8d6rlEBqbLM3VZWxHkNKIIZNw=",
|
||||||
|
"ref": "refs/heads/main",
|
||||||
|
"rev": "a7762d6f54b40560dd5255ce902e6e6a5d980fe9",
|
||||||
|
"revCount": 1416,
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://spectrum-os.org/git/spectrum"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://spectrum-os.org/git/spectrum"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
description = "Project-composable, rootless microVMs for a DeepSeek coding agent";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
microvm = {
|
||||||
|
url = "github:microvm-nix/microvm.nix";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
inputs@{
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
microvm,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
system = "x86_64-linux";
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
example = self.lib.mkAgentVM {
|
||||||
|
inherit system;
|
||||||
|
project = {
|
||||||
|
packages = [ pkgs.hello ];
|
||||||
|
env.AGENT_PROJECT_TEST = "shared";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
lib.mkAgentVM = import ./lib.nix { inherit nixpkgs microvm; };
|
||||||
|
nixosModules.agent = ./module.nix;
|
||||||
|
nixosConfigurations.agent = example.nixos;
|
||||||
|
packages.${system}.default = example.package;
|
||||||
|
apps.${system}.default = example.app;
|
||||||
|
formatter.${system} = pkgs.nixfmt;
|
||||||
|
checks.${system} = import ./tests.nix { inherit inputs pkgs example; };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Included by writeShellApplication: bash and PATH are supplied by Nix.
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ ${1:-} == --help ]]; then
|
||||||
|
echo 'Usage: nix run .#agent -- [run | ssh [command ...] | url | stop]'
|
||||||
|
echo 'Workspace = cwd. RW config/credentials/skills = DSH_HOME (default ~/.dsh)'
|
||||||
|
echo 'Also shares DSH_AGENTS_HOME/skills (default ~/.agents/skills). RAM/CPU/network: flake.'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
[[ $EUID != 0 ]] || { echo 'Run as your normal host user, not sudo/root.' >&2; exit 1; }
|
||||||
|
umask 077
|
||||||
|
project=$(pwd -P)
|
||||||
|
dsh=$(realpath -m "${DSH_HOME:-$HOME/.dsh}")
|
||||||
|
skills=$(realpath -m "${DSH_AGENTS_HOME:-$HOME/.agents}/skills")
|
||||||
|
state=$(realpath -m "${XDG_STATE_HOME:-$HOME/.local/state}/agent-vm/$(printf %s "$project" | sha256sum | cut -c1-16)")
|
||||||
|
for path in "$project" "$dsh" "$skills"; do
|
||||||
|
case "$path" in /|/home|/etc|/nix|/nix/*|/proc|/proc/*|/sys|/sys/*|/dev|/dev/*|/run|/run/*|"$HOME"|*$'\n'*) echo "Refusing broad/system share: $path" >&2; exit 1;; esac
|
||||||
|
[[ $state != "$path" && $state != "$path/"* ]] || { echo 'State must be outside shared directories.' >&2; exit 1; }
|
||||||
|
done
|
||||||
|
# Prevent a broad workspace/config mount from accidentally including other mounts.
|
||||||
|
disjoint() {
|
||||||
|
[[ $1 != "$2" && $1 != "$2/"* && $2 != "$1/"* ]] || { echo 'Writable shares must not overlap.' >&2; exit 1; }
|
||||||
|
}
|
||||||
|
disjoint "$project" "$dsh"; disjoint "$project" "$skills"; disjoint "$dsh" "$skills"
|
||||||
|
ssh_cmd=(ssh -F /dev/null -i "$state/client-key" -p "$AGENT_SSH_PORT"
|
||||||
|
-o IdentitiesOnly=yes -o IdentityAgent=none -o ForwardAgent=no -o BatchMode=yes
|
||||||
|
-o StrictHostKeyChecking=yes -o HostKeyAlias=agent-vm -o ConnectTimeout=3
|
||||||
|
-o "UserKnownHostsFile=$state/known_hosts" -o GlobalKnownHostsFile=/dev/null)
|
||||||
|
remote="root@$AGENT_SSH_HOST"
|
||||||
|
url() {
|
||||||
|
local found address=$AGENT_WEB_BIND
|
||||||
|
[[ $address != 0.0.0.0 ]] || address=127.0.0.1
|
||||||
|
found=$("${ssh_cmd[@]}" "$remote" 'journalctl -u agent -b -o cat --no-pager' |
|
||||||
|
grep -oE 'http://127\.0\.0\.1:3080/\?token=[a-zA-Z0-9_%.-]+' | tail -1) || return 1
|
||||||
|
[[ -n $found ]] || return 1
|
||||||
|
printf '%s\n' "${found/http:\/\/127.0.0.1:3080/http:\/\/$address:$AGENT_WEB_PORT}"
|
||||||
|
}
|
||||||
|
# Expand cwd inside the guest, not on the host.
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
case ${1:-run} in
|
||||||
|
ssh) shift; if (( $# )); then exec "${ssh_cmd[@]}" "$remote" 'cd -- "$(cat /run/agent-vm/workdir)" || exit; '"$*"; else exec "${ssh_cmd[@]}" -t "$remote" 'cd -- "$(cat /run/agent-vm/workdir)" || exit; exec bash -l'; fi;;
|
||||||
|
url) url || { echo "DSH not ready; inspect: nix run .#agent -- ssh 'journalctl -u agent -b'" >&2; exit 1; }; exit;;
|
||||||
|
stop) cd "$state"; exec "$AGENT_RUNNER/microvm-shutdown";;
|
||||||
|
run) [[ $# -le 1 ]] || { echo 'Unexpected run arguments; use --help.' >&2; exit 1; };;
|
||||||
|
*) echo 'Unknown command; use --help.' >&2; exit 1;;
|
||||||
|
esac
|
||||||
|
[[ -r /dev/kvm && -w /dev/kvm ]] || { echo 'Need read/write access to /dev/kvm.' >&2; exit 1; }
|
||||||
|
mkdir -p "$state" "$dsh" "$skills"
|
||||||
|
for dir in "$state" "$dsh"; do
|
||||||
|
[[ $(stat -c %u "$dir") == "$(id -u)" && $(stat -c %a "$dir") == 700 ]] || {
|
||||||
|
echo "Make this directory private and user-owned first: $dir (chmod 700)" >&2; exit 1;
|
||||||
|
}
|
||||||
|
done
|
||||||
|
exec 9>"$state/run.lock"
|
||||||
|
flock -n 9 || { echo 'This project VM is already running.' >&2; exit 1; }
|
||||||
|
for key in client-key ssh-host-key; do
|
||||||
|
[[ -f $state/$key ]] || ssh-keygen -q -t ed25519 -N '' -C agent-vm -f "$state/$key"
|
||||||
|
done
|
||||||
|
printf '%s\n' "$project" > "$state/workdir"
|
||||||
|
cp "$state/client-key.pub" "$state/ssh-authorized-key"
|
||||||
|
printf 'agent-vm %s\n' "$(cut -d' ' -f1,2 "$state/ssh-host-key.pub")" > "$state/known_hosts"
|
||||||
|
printf 'RW workspace: %s -> /workspace\nRW DSH home: %s\nRW shared skills: %s\nConsole log: %s/console.log\n' "$project" "$dsh" "$skills" "$state"
|
||||||
|
if [[ $AGENT_WEB_BIND != 127.0.0.1 ]]; then
|
||||||
|
echo 'WARNING: off-host Web access is plaintext HTTP. Use a VPN/TLS; never expose directly to the Internet.' >&2
|
||||||
|
fi
|
||||||
|
vm_pid=''
|
||||||
|
tunnel_pid=''
|
||||||
|
cleanup() {
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
if [[ -n $vm_pid ]] && kill -0 "$vm_pid" 2>/dev/null; then
|
||||||
|
(cd "$state"; timeout 30 "$AGENT_RUNNER/microvm-shutdown") >/dev/null 2>&1 || true
|
||||||
|
kill "$vm_pid" 2>/dev/null || true
|
||||||
|
wait "$vm_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ -n $tunnel_pid ]]; then kill "$tunnel_pid" 2>/dev/null || true; wait "$tunnel_pid" 2>/dev/null || true; fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
# Host-side defense in depth around QEMU. No host home/session sockets or other
|
||||||
|
# processes; only the three explicit shares and private VM control state are RW.
|
||||||
|
# Network is intentionally inherited for API access (not an egress firewall).
|
||||||
|
devices=()
|
||||||
|
[[ $AGENT_NETWORK != tap ]] || devices=(--dev-bind /dev/net/tun /dev/net/tun)
|
||||||
|
bwrap "${devices[@]}" --die-with-parent --new-session --unshare-user --unshare-pid --unshare-ipc \
|
||||||
|
--unshare-uts --unshare-cgroup-try --cap-drop ALL --clearenv \
|
||||||
|
--setenv HOME /tmp --setenv PATH /no-host-path --setenv LANG C.UTF-8 \
|
||||||
|
--ro-bind /nix/store /nix/store --proc /proc --dev /dev --dev-bind /dev/kvm /dev/kvm \
|
||||||
|
--tmpfs /tmp --bind "$state" /state --bind "$project" /workspace \
|
||||||
|
--bind "$dsh" /dsh-home --bind "$skills" /skills \
|
||||||
|
--ro-bind-try /etc/resolv.conf /etc/resolv.conf --ro-bind-try /etc/hosts /etc/hosts \
|
||||||
|
--chdir /state "$AGENT_RUNNER/microvm-run" >"$state/console.log" 2>&1 &
|
||||||
|
vm_pid=$!
|
||||||
|
ready=false
|
||||||
|
for ((i=0; i<90; i++)); do
|
||||||
|
kill -0 "$vm_pid" 2>/dev/null || { echo "VM exited; see $state/console.log" >&2; exit 1; }
|
||||||
|
if "${ssh_cmd[@]}" "$remote" true 2>/dev/null; then ready=true; break; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
$ready || { echo "SSH boot timeout; see $state/console.log" >&2; exit 1; }
|
||||||
|
# DSH deliberately refuses --host 0.0.0.0. Keep its own authenticated browser
|
||||||
|
# endpoint on guest loopback and publish an SSH forward on the chosen host IP.
|
||||||
|
"${ssh_cmd[@]}" -N -g -o ExitOnForwardFailure=yes -o ServerAliveInterval=10 \
|
||||||
|
-o ServerAliveCountMax=3 -L "$AGENT_WEB_BIND:$AGENT_WEB_PORT:127.0.0.1:3080" \
|
||||||
|
"$remote" >>"$state/console.log" 2>&1 &
|
||||||
|
tunnel_pid=$!
|
||||||
|
echo "Booted. DSH resolves npm @latest on startup; first launch may take a few minutes."
|
||||||
|
echo 'Use another terminal: nix run .#agent -- url (or: ssh / stop)'
|
||||||
|
printed=false
|
||||||
|
while kill -0 "$vm_pid" 2>/dev/null; do
|
||||||
|
# Normal guest poweroff can close SSH slightly before QEMU exits.
|
||||||
|
if ! kill -0 "$tunnel_pid" 2>/dev/null; then
|
||||||
|
timeout 30 tail --pid="$vm_pid" -f /dev/null || true
|
||||||
|
if kill -0 "$vm_pid" 2>/dev/null; then echo "Web tunnel exited; see $state/console.log" >&2; exit 1; fi
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if ! $printed; then
|
||||||
|
if login_url=$(url 2>/dev/null); then printf 'Private login URL: %s\n' "$login_url"; printed=true; fi
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
wait "$vm_pid"
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{ nixpkgs, microvm }:
|
||||||
|
{
|
||||||
|
system,
|
||||||
|
project,
|
||||||
|
modules ? [ ],
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
inherit (pkgs) lib;
|
||||||
|
nixos = nixpkgs.lib.nixosSystem {
|
||||||
|
inherit system;
|
||||||
|
modules = [
|
||||||
|
microvm.nixosModules.microvm
|
||||||
|
./module.nix
|
||||||
|
{
|
||||||
|
agentVM.packages = project.packages or [ ];
|
||||||
|
environment.variables = project.env or { };
|
||||||
|
}
|
||||||
|
]
|
||||||
|
++ modules;
|
||||||
|
};
|
||||||
|
net = nixos.config.agentVM.network;
|
||||||
|
package = pkgs.writeShellApplication {
|
||||||
|
name = "agent-vm";
|
||||||
|
runtimeInputs = with pkgs; [
|
||||||
|
coreutils
|
||||||
|
util-linux
|
||||||
|
openssh
|
||||||
|
bubblewrap
|
||||||
|
gnugrep
|
||||||
|
gnused
|
||||||
|
];
|
||||||
|
runtimeEnv = {
|
||||||
|
AGENT_RUNNER = "${nixos.config.microvm.declaredRunner}/bin";
|
||||||
|
AGENT_NETWORK = net.mode;
|
||||||
|
AGENT_WEB_BIND = net.hostAddress;
|
||||||
|
AGENT_WEB_PORT = toString net.webPort;
|
||||||
|
AGENT_SSH_HOST = if net.mode == "user" then "127.0.0.1" else net.guestAddress;
|
||||||
|
AGENT_SSH_PORT = toString (if net.mode == "user" then net.sshPort else 22);
|
||||||
|
};
|
||||||
|
text = builtins.readFile ./launch.sh;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
assert lib.assertMsg (
|
||||||
|
system == "x86_64-linux"
|
||||||
|
) "agent-vm currently supports x86_64-linux hosts/guests";
|
||||||
|
{
|
||||||
|
inherit nixos package;
|
||||||
|
app = {
|
||||||
|
type = "app";
|
||||||
|
program = lib.getExe package;
|
||||||
|
meta.description = "Run DSH with this project's toolchain and live workspace";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
inherit (lib) mkOption types;
|
||||||
|
cfg = config.agentVM;
|
||||||
|
net = cfg.network;
|
||||||
|
ipv4 = types.strMatching "[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+";
|
||||||
|
dshNode = pkgs.writeShellScript "dsh-node" ''
|
||||||
|
# Cordis HMR requires this Node flag; npm's published dsh shebang omits it.
|
||||||
|
exec node --expose-internals "$(command -v dsh)" "$@"
|
||||||
|
'';
|
||||||
|
dshLatest = pkgs.writeShellApplication {
|
||||||
|
name = "dsh";
|
||||||
|
runtimeInputs = [
|
||||||
|
pkgs.nodejs
|
||||||
|
pkgs.pnpm
|
||||||
|
];
|
||||||
|
text = ''
|
||||||
|
export npm_config_cache=/var/cache/dsh/npm
|
||||||
|
# Explicitly rolling upstream, not a pretend-reproducible Nix derivation.
|
||||||
|
exec npm exec --yes --package=@deepseek-ai/dsh@latest -- ${dshNode} "$@"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
guestLaunch = pkgs.writeShellScript "dsh-project" ''
|
||||||
|
cd -- "$(cat /run/agent-vm/workdir)"
|
||||||
|
exec "$@"
|
||||||
|
'';
|
||||||
|
share = source: mountPoint: tag: {
|
||||||
|
inherit source mountPoint tag;
|
||||||
|
proto = "9p";
|
||||||
|
securityModel = "none"; # QEMU writes as its unprivileged host uid, not guest root.
|
||||||
|
readOnly = false;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.agentVM = {
|
||||||
|
packages = mkOption {
|
||||||
|
type = types.listOf types.package;
|
||||||
|
default = [ ];
|
||||||
|
description = "The same project package list used by the development shell.";
|
||||||
|
};
|
||||||
|
package = mkOption {
|
||||||
|
type = types.package;
|
||||||
|
default = dshLatest;
|
||||||
|
description = "Official DSH launcher; resolves the npm latest tag inside the guest at launch.";
|
||||||
|
};
|
||||||
|
network = {
|
||||||
|
mode = mkOption {
|
||||||
|
type = types.enum [
|
||||||
|
"user"
|
||||||
|
"tap"
|
||||||
|
];
|
||||||
|
default = "user";
|
||||||
|
description = "Rootless QEMU NAT, or an administrator-prepared TAP interface.";
|
||||||
|
};
|
||||||
|
hostAddress = mkOption {
|
||||||
|
type = ipv4;
|
||||||
|
default = "127.0.0.1";
|
||||||
|
description = "Host IPv4 bind address for the SSH-forwarded Web UI.";
|
||||||
|
};
|
||||||
|
sshPort = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 2222;
|
||||||
|
description = "Host SSH port in user mode; SSH is always bound to host loopback.";
|
||||||
|
};
|
||||||
|
webPort = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 3080;
|
||||||
|
description = "Host Web UI port; the guest DSH listener stays on 127.0.0.1:3080.";
|
||||||
|
};
|
||||||
|
trustedHosts = mkOption {
|
||||||
|
type = types.listOf (types.strMatching "[a-zA-Z0-9.:-]+");
|
||||||
|
default = [ ];
|
||||||
|
description = "Additional exact browser authorities for DSH's Host/Origin protection. Required for wildcard publication.";
|
||||||
|
};
|
||||||
|
tapName = mkOption {
|
||||||
|
type = types.strMatching "[a-zA-Z0-9_-]{1,15}";
|
||||||
|
default = "agent0";
|
||||||
|
description = "Pre-created host TAP interface, not a physical NIC.";
|
||||||
|
};
|
||||||
|
mac = mkOption {
|
||||||
|
type = types.strMatching "[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}";
|
||||||
|
default = "02:00:00:00:00:01";
|
||||||
|
description = "Unique locally administered guest MAC; change for each TAP guest.";
|
||||||
|
};
|
||||||
|
guestAddress = mkOption {
|
||||||
|
type = types.nullOr ipv4;
|
||||||
|
default = null;
|
||||||
|
description = "Static guest IPv4 address in TAP mode.";
|
||||||
|
};
|
||||||
|
prefixLength = mkOption {
|
||||||
|
type = types.ints.between 1 32;
|
||||||
|
default = 24;
|
||||||
|
description = "Guest IPv4 prefix length in TAP mode.";
|
||||||
|
};
|
||||||
|
gateway = mkOption {
|
||||||
|
type = types.nullOr ipv4;
|
||||||
|
default = null;
|
||||||
|
description = "Guest default router in TAP mode; routing/NAT is configured separately.";
|
||||||
|
};
|
||||||
|
dns = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [ ];
|
||||||
|
description = "DNS servers in TAP mode. User mode uses QEMU DHCP/DNS.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = {
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
net.mode != "tap" || (net.guestAddress != null && net.gateway != null && net.dns != [ ]);
|
||||||
|
message = "agentVM TAP mode requires network.guestAddress, gateway and dns.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = net.webPort >= 1024 && net.sshPort >= 1024 && net.webPort != net.sshPort;
|
||||||
|
message = "Rootless Web/SSH listeners need distinct unprivileged ports (>=1024).";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = net.hostAddress != "0.0.0.0" || net.trustedHosts != [ ];
|
||||||
|
message = "When publishing on 0.0.0.0, list the actual browser IP:port/hostname:port in network.trustedHosts.";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
networking.hostName = lib.mkDefault "project-agent";
|
||||||
|
system.stateVersion = "26.05";
|
||||||
|
microvm = {
|
||||||
|
hypervisor = "qemu";
|
||||||
|
mem = lib.mkDefault 4096;
|
||||||
|
vcpu = lib.mkDefault 4;
|
||||||
|
socket = "control.sock";
|
||||||
|
storeOnDisk = true;
|
||||||
|
# Ephemeral guest-only Nix writes; never share the host store or daemon.
|
||||||
|
writableStoreOverlay = "/nix/.rw-store";
|
||||||
|
volumes = [
|
||||||
|
{
|
||||||
|
image = "cache.img";
|
||||||
|
mountPoint = "/var/cache/dsh";
|
||||||
|
size = 4096;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
# Paths are in the launcher's restricted mount namespace, not Nix paths.
|
||||||
|
# No credential/project contents enter the Nix store.
|
||||||
|
shares = [
|
||||||
|
(share "/workspace" "/workspace" "project")
|
||||||
|
(share "/dsh-home" "/root/.dsh" "dsh-home")
|
||||||
|
(share "/skills" "/root/.agents/skills" "agent-skills")
|
||||||
|
];
|
||||||
|
interfaces = [
|
||||||
|
{
|
||||||
|
type = net.mode;
|
||||||
|
id = if net.mode == "user" then "agentnet" else net.tapName;
|
||||||
|
inherit (net) mac;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
forwardPorts = lib.optionals (net.mode == "user") [
|
||||||
|
{
|
||||||
|
from = "host";
|
||||||
|
host.address = "127.0.0.1";
|
||||||
|
host.port = net.sshPort;
|
||||||
|
guest.port = 22;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
# Firmware credentials carry only dedicated VM SSH keys, not DSH secrets.
|
||||||
|
# Relative runtime filenames avoid embedding user paths in derivations.
|
||||||
|
qemu.extraArgs =
|
||||||
|
lib.concatMap
|
||||||
|
(name: [
|
||||||
|
"-fw_cfg"
|
||||||
|
"name=opt/io.systemd.credentials/${name},file=${name}"
|
||||||
|
])
|
||||||
|
[
|
||||||
|
"ssh-authorized-key"
|
||||||
|
"ssh-host-key"
|
||||||
|
"workdir"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
fileSystems."/workspace".options = [
|
||||||
|
"nodev"
|
||||||
|
"nosuid"
|
||||||
|
"cache=none"
|
||||||
|
];
|
||||||
|
fileSystems."/root/.dsh".options = [
|
||||||
|
"nodev"
|
||||||
|
"nosuid"
|
||||||
|
"cache=none"
|
||||||
|
];
|
||||||
|
fileSystems."/root/.agents/skills".options = [
|
||||||
|
"nodev"
|
||||||
|
"nosuid"
|
||||||
|
"cache=none"
|
||||||
|
];
|
||||||
|
|
||||||
|
networking.useDHCP = false;
|
||||||
|
systemd.network.enable = true;
|
||||||
|
systemd.network.networks."20-agent" = {
|
||||||
|
matchConfig.MACAddress = net.mac;
|
||||||
|
networkConfig =
|
||||||
|
if net.mode == "user" then
|
||||||
|
{ DHCP = "ipv4"; }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Address = [ "${net.guestAddress}/${toString net.prefixLength}" ];
|
||||||
|
Gateway = net.gateway;
|
||||||
|
DNS = net.dns;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
networking.firewall.allowedTCPPorts = [ 22 ]; # Web stays on guest loopback.
|
||||||
|
nix.settings.experimental-features = [
|
||||||
|
"nix-command"
|
||||||
|
"flakes"
|
||||||
|
];
|
||||||
|
nix.settings.auto-optimise-store = false;
|
||||||
|
nix.channel.enable = false;
|
||||||
|
|
||||||
|
users.users.root.hashedPassword = "!";
|
||||||
|
services.openssh = {
|
||||||
|
enable = true;
|
||||||
|
hostKeys = [
|
||||||
|
{
|
||||||
|
path = "/run/agent-vm/ssh-host-key";
|
||||||
|
type = "ed25519";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
authorizedKeysFiles = lib.mkForce [ "/run/agent-vm/ssh-authorized-key" ];
|
||||||
|
settings = {
|
||||||
|
PermitRootLogin = "prohibit-password";
|
||||||
|
PasswordAuthentication = false;
|
||||||
|
KbdInteractiveAuthentication = false;
|
||||||
|
AllowAgentForwarding = false;
|
||||||
|
X11Forwarding = false;
|
||||||
|
AllowTcpForwarding = "local";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
systemd.services.agent-vm-credentials = {
|
||||||
|
before = [
|
||||||
|
"sshd.service"
|
||||||
|
"sshd-keygen.service"
|
||||||
|
"agent.service"
|
||||||
|
];
|
||||||
|
requiredBy = [
|
||||||
|
"sshd.service"
|
||||||
|
"sshd-keygen.service"
|
||||||
|
"agent.service"
|
||||||
|
];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
RuntimeDirectory = "agent-vm";
|
||||||
|
RuntimeDirectoryMode = "0700";
|
||||||
|
ImportCredential = [
|
||||||
|
"ssh-authorized-key"
|
||||||
|
"ssh-host-key"
|
||||||
|
"workdir"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
for name in ssh-authorized-key ssh-host-key workdir; do
|
||||||
|
install -m 600 "$CREDENTIALS_DIRECTORY/$name" "/run/agent-vm/$name"
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
environment.variables = {
|
||||||
|
DSH_HOME = "/root/.dsh";
|
||||||
|
DSH_AGENTS_HOME = "/root/.agents";
|
||||||
|
DSH_TELEMETRY_DISABLED = "1";
|
||||||
|
};
|
||||||
|
programs.git.config.safe.directory = "/workspace"; # 9p files retain host ownership.
|
||||||
|
programs.nix-ld.enable = true; # Upstream npm native executables, guest only.
|
||||||
|
environment.systemPackages = [
|
||||||
|
cfg.package
|
||||||
|
]
|
||||||
|
++ cfg.packages
|
||||||
|
++ (with pkgs; [
|
||||||
|
bashInteractive
|
||||||
|
coreutils
|
||||||
|
findutils
|
||||||
|
gnugrep
|
||||||
|
gnused
|
||||||
|
gawk
|
||||||
|
diffutils
|
||||||
|
git
|
||||||
|
git-lfs
|
||||||
|
openssh
|
||||||
|
ripgrep
|
||||||
|
fd
|
||||||
|
jq
|
||||||
|
yq-go
|
||||||
|
tree
|
||||||
|
file
|
||||||
|
less
|
||||||
|
python3
|
||||||
|
nodejs
|
||||||
|
pnpm
|
||||||
|
curl
|
||||||
|
wget
|
||||||
|
cacert
|
||||||
|
unzip
|
||||||
|
zip
|
||||||
|
gnutar
|
||||||
|
gzip
|
||||||
|
xz
|
||||||
|
zstd
|
||||||
|
procps
|
||||||
|
util-linux
|
||||||
|
which
|
||||||
|
patch
|
||||||
|
gnumake
|
||||||
|
pkg-config
|
||||||
|
shellcheck
|
||||||
|
bubblewrap
|
||||||
|
]);
|
||||||
|
systemd.services.agent = {
|
||||||
|
description = "Official DeepSeek Harness (root inside the guest)";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network.target" ];
|
||||||
|
unitConfig.RequiresMountsFor = [
|
||||||
|
"/workspace"
|
||||||
|
"/root/.dsh"
|
||||||
|
"/root/.agents/skills"
|
||||||
|
"/var/cache/dsh"
|
||||||
|
];
|
||||||
|
path = [ "/run/current-system/sw" ];
|
||||||
|
environment = config.environment.variables // {
|
||||||
|
HOME = "/root";
|
||||||
|
};
|
||||||
|
# Preserve the real cwd path in DSH's workspace/session identity. Mapping
|
||||||
|
# every project to /workspace alone would conflate their shared sessions.
|
||||||
|
preStart = ''
|
||||||
|
workdir=$(cat /run/agent-vm/workdir)
|
||||||
|
mkdir -p -- "$workdir"
|
||||||
|
mountpoint -q -- "$workdir" || mount --bind /workspace "$workdir"
|
||||||
|
git config --global --replace-all safe.directory "$workdir"
|
||||||
|
'';
|
||||||
|
serviceConfig = {
|
||||||
|
User = "root";
|
||||||
|
WorkingDirectory = "/workspace";
|
||||||
|
ExecStart = lib.escapeShellArgs (
|
||||||
|
[
|
||||||
|
"${guestLaunch}"
|
||||||
|
"${cfg.package}/bin/dsh"
|
||||||
|
"web"
|
||||||
|
"--no-open"
|
||||||
|
"--host"
|
||||||
|
"127.0.0.1"
|
||||||
|
"--port"
|
||||||
|
"3080"
|
||||||
|
]
|
||||||
|
++
|
||||||
|
lib.concatMap
|
||||||
|
(host: [
|
||||||
|
"--trusted-host"
|
||||||
|
host
|
||||||
|
])
|
||||||
|
(
|
||||||
|
net.trustedHosts
|
||||||
|
++ lib.optional (
|
||||||
|
!builtins.elem net.hostAddress [
|
||||||
|
"127.0.0.1"
|
||||||
|
"0.0.0.0"
|
||||||
|
]
|
||||||
|
) "${net.hostAddress}:${toString net.webPort}"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 3;
|
||||||
|
UMask = "0077";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
inputs,
|
||||||
|
pkgs,
|
||||||
|
example,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
c = example.nixos.config;
|
||||||
|
testVM = inputs.self.lib.mkAgentVM {
|
||||||
|
system = pkgs.stdenv.hostPlatform.system;
|
||||||
|
project = {
|
||||||
|
packages = [ pkgs.hello ];
|
||||||
|
env.AGENT_PROJECT_TEST = "shared";
|
||||||
|
};
|
||||||
|
modules = [
|
||||||
|
{
|
||||||
|
# Offline infrastructure test. A real DSH startup is tested separately;
|
||||||
|
# @latest needs the network and is intentionally outside Nix reproducibility.
|
||||||
|
agentVM.package = pkgs.writeShellScriptBin "dsh" ''
|
||||||
|
echo 'http://127.0.0.1:3080/?token=offline-test'
|
||||||
|
exec ${pkgs.coreutils}/bin/sleep infinity
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
tap = inputs.self.lib.mkAgentVM {
|
||||||
|
system = pkgs.stdenv.hostPlatform.system;
|
||||||
|
project.packages = [ ];
|
||||||
|
modules = [
|
||||||
|
{
|
||||||
|
microvm.mem = 8192;
|
||||||
|
microvm.vcpu = 6;
|
||||||
|
agentVM.network = {
|
||||||
|
mode = "tap";
|
||||||
|
tapName = "agent-test";
|
||||||
|
guestAddress = "192.168.77.2";
|
||||||
|
gateway = "192.168.77.1";
|
||||||
|
dns = [ "192.168.77.1" ];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
config =
|
||||||
|
assert c.microvm.mem == 4096;
|
||||||
|
assert c.microvm.vcpu == 4;
|
||||||
|
assert builtins.length c.microvm.shares == 3;
|
||||||
|
assert builtins.all (s: !s.readOnly && s.securityModel == "none") c.microvm.shares;
|
||||||
|
assert c.microvm.storeOnDisk;
|
||||||
|
assert c.systemd.services.agent.serviceConfig.User == "root";
|
||||||
|
assert c.systemd.services.agent.serviceConfig.WorkingDirectory == "/workspace";
|
||||||
|
assert c.services.openssh.settings.PasswordAuthentication == false;
|
||||||
|
assert c.services.openssh.settings.AllowAgentForwarding == false;
|
||||||
|
assert builtins.length c.microvm.forwardPorts == 1;
|
||||||
|
assert (builtins.head c.microvm.forwardPorts).host.address == "127.0.0.1";
|
||||||
|
assert builtins.elem pkgs.hello c.environment.systemPackages;
|
||||||
|
assert c.environment.variables.AGENT_PROJECT_TEST == "shared";
|
||||||
|
assert tap.nixos.config.microvm.mem == 8192;
|
||||||
|
assert tap.nixos.config.microvm.vcpu == 6;
|
||||||
|
assert tap.nixos.config.microvm.forwardPorts == [ ];
|
||||||
|
assert (builtins.head tap.nixos.config.microvm.interfaces).id == "agent-test";
|
||||||
|
pkgs.runCommand "agent-vm-config-check" { } ''touch "$out"'';
|
||||||
|
|
||||||
|
shell =
|
||||||
|
pkgs.runCommand "agent-vm-shell-check"
|
||||||
|
{
|
||||||
|
nativeBuildInputs = [
|
||||||
|
pkgs.shellcheck
|
||||||
|
pkgs.bash
|
||||||
|
];
|
||||||
|
}
|
||||||
|
''
|
||||||
|
shellcheck -s bash ${./launch.sh} ${./boot-test.sh}
|
||||||
|
bash -n ${./launch.sh}
|
||||||
|
touch "$out"
|
||||||
|
'';
|
||||||
|
|
||||||
|
boot =
|
||||||
|
pkgs.runCommand "agent-vm-boot-check"
|
||||||
|
{
|
||||||
|
requiredSystemFeatures = [ "kvm" ];
|
||||||
|
nativeBuildInputs = [
|
||||||
|
pkgs.bash
|
||||||
|
pkgs.coreutils
|
||||||
|
pkgs.gnugrep
|
||||||
|
];
|
||||||
|
}
|
||||||
|
''
|
||||||
|
bash ${./boot-test.sh} ${testVM.package}/bin/agent-vm
|
||||||
|
touch "$out"
|
||||||
|
'';
|
||||||
|
}
|
||||||
+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";
|
||||||
@@ -82,12 +100,24 @@ in
|
|||||||
|
|
||||||
home-manager.users.dev = {
|
home-manager.users.dev = {
|
||||||
home.packages = with pkgs; [
|
home.packages = with pkgs; [
|
||||||
|
# Keep Tor Browser's separate profile and upstream privacy defaults.
|
||||||
|
# This is not Firefox pointed at the system Tor SOCKS port.
|
||||||
|
tor-browser
|
||||||
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 +143,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 +157,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";
|
||||||
}
|
}
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
# Shared user environment and update policy; only machine integration differs.
|
||||||
|
{ config, inputs, ... }:
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
./users.nix
|
||||||
|
./tools.nix
|
||||||
|
./network.nix
|
||||||
|
./desktop.nix
|
||||||
|
./apps.nix
|
||||||
|
./neovim.nix
|
||||||
|
./updates.nix
|
||||||
|
];
|
||||||
|
|
||||||
|
time.timeZone = "America/Chicago";
|
||||||
|
i18n.defaultLocale = "en_US.UTF-8";
|
||||||
|
services.xserver.xkb = {
|
||||||
|
layout = "us";
|
||||||
|
variant = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
nixpkgs.hostPlatform = "x86_64-linux";
|
||||||
|
# tools.nix/network.nix select current standalone CLIs without overriding the
|
||||||
|
# system package set (or mixing unstable libraries into the desktop stack).
|
||||||
|
|
||||||
|
nix = {
|
||||||
|
channel.enable = false;
|
||||||
|
# Keep non-login environments on the same pin when NIX_PATH is unset.
|
||||||
|
settings.nix-path = config.nix.nixPath;
|
||||||
|
settings.experimental-features = [
|
||||||
|
"nix-command"
|
||||||
|
"flakes"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
system.configurationRevision = inputs.self.rev or inputs.self.dirtyRev or null;
|
||||||
|
|
||||||
|
# Initial data/default compatibility, not the desired package release.
|
||||||
|
system.stateVersion = "26.05";
|
||||||
|
}
|
||||||
+7
-29
@@ -1,37 +1,15 @@
|
|||||||
{
|
# EC2 only. Physical machines must use the separate nixos host target.
|
||||||
config,
|
{ modulesPath, ... }:
|
||||||
inputs,
|
|
||||||
modulesPath,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
|
|
||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
# Keep the image's EC2 boot, storage, metadata, SSH and SSM integration.
|
# Keep the image's EC2 boot, storage, metadata, SSH and SSM integration.
|
||||||
"${modulesPath}/virtualisation/amazon-image.nix"
|
"${modulesPath}/virtualisation/amazon-image.nix"
|
||||||
./users.nix
|
./common.nix
|
||||||
./tools.nix
|
|
||||||
./network.nix
|
|
||||||
./desktop.nix
|
|
||||||
./apps.nix
|
|
||||||
./updates.nix
|
|
||||||
./neovim.nix
|
|
||||||
];
|
];
|
||||||
|
|
||||||
nixpkgs.hostPlatform = "x86_64-linux";
|
# Keep the existing deployment path; ownership/update policy is shared.
|
||||||
|
systemd.services.nixos-update.environment = {
|
||||||
nix = {
|
NIXOS_CONFIG_REPO = "/etc/nixos";
|
||||||
channel.enable = false;
|
NIXOS_UPDATE_HOST = "dev";
|
||||||
# Keep non-login environments on the same pin when NIX_PATH is unset.
|
|
||||||
settings.nix-path = config.nix.nixPath;
|
|
||||||
settings.experimental-features = [
|
|
||||||
"nix-command"
|
|
||||||
"flakes"
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
system.configurationRevision = inputs.self.rev or inputs.self.dirtyRev or null;
|
|
||||||
|
|
||||||
# Initial data/default compatibility, not the desired package release.
|
|
||||||
system.stateVersion = "26.05";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
+10
-9
@@ -15,15 +15,15 @@ pkgs.testers.runNixOSTest {
|
|||||||
nodes.machine = { pkgs, ... }: {
|
nodes.machine = { pkgs, ... }: {
|
||||||
imports = [
|
imports = [
|
||||||
inputs.home-manager.nixosModules.home-manager
|
inputs.home-manager.nixosModules.home-manager
|
||||||
./users.nix
|
./common.nix
|
||||||
./tools.nix
|
|
||||||
./network.nix
|
|
||||||
./neovim.nix
|
|
||||||
./desktop.nix
|
|
||||||
./apps.nix
|
|
||||||
./workstation.nix
|
./workstation.nix
|
||||||
];
|
];
|
||||||
system.stateVersion = "26.05";
|
# Exercise the shared host policy, but never run automatic updates in a VM.
|
||||||
|
systemd.services.nixos-update.environment = {
|
||||||
|
NIXOS_CONFIG_REPO = "/etc/nix";
|
||||||
|
NIXOS_UPDATE_HOST = "nixos";
|
||||||
|
};
|
||||||
|
systemd.timers.nixos-update.enable = false;
|
||||||
boot.blacklistedKernelModules = [ "floppy" ];
|
boot.blacklistedKernelModules = [ "floppy" ];
|
||||||
virtualisation = {
|
virtualisation = {
|
||||||
memorySize = 6144;
|
memorySize = 6144;
|
||||||
@@ -58,9 +58,10 @@ pkgs.testers.runNixOSTest {
|
|||||||
mkpasswd --method=sha-512 --salt=nixostest desktop-test > "$out"
|
mkpasswd --method=sha-512 --salt=nixostest desktop-test > "$out"
|
||||||
''
|
''
|
||||||
);
|
);
|
||||||
services.greetd.settings.initial_session = {
|
# The same SDDM/UWSM session as the laptop; autologin is test-only.
|
||||||
|
services.displayManager.autoLogin = {
|
||||||
|
enable = true;
|
||||||
user = "dev";
|
user = "dev";
|
||||||
command = "${pkgs.uwsm}/bin/uwsm start -e -D Hyprland hyprland.desktop";
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
testScript = builtins.readFile ./desktop-test.py;
|
testScript = builtins.readFile ./desktop-test.py;
|
||||||
|
|||||||
+66
-8
@@ -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
|
||||||
@@ -72,13 +72,15 @@ assert "0.42" in session("wpctl get-volume @DEFAULT_AUDIO_SINK@")
|
|||||||
session("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ 1")
|
session("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ 1")
|
||||||
assert "MUTED" in session("wpctl get-volume @DEFAULT_AUDIO_SOURCE@")
|
assert "MUTED" in session("wpctl get-volume @DEFAULT_AUDIO_SOURCE@")
|
||||||
session("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ 0")
|
session("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ 0")
|
||||||
machine.succeed("systemctl is-active systemd-resolved")
|
machine.succeed("systemctl is-active NetworkManager display-manager")
|
||||||
|
machine.succeed("test $(stat -c %U /etc/nix) = dev")
|
||||||
|
machine.succeed("test $(stat -c %U /var/cache/nixos-update) = dev")
|
||||||
|
|
||||||
launch("terminal", "kitty --title 'Workspace ready' sh -c " + shlex.quote(
|
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 +90,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 +140,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 +182,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 +198,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")
|
||||||
|
|||||||
+262
-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
|
||||||
@@ -123,6 +159,7 @@ in
|
|||||||
};
|
};
|
||||||
xdg.autostart.enable = true;
|
xdg.autostart.enable = true;
|
||||||
home.pointerCursor = {
|
home.pointerCursor = {
|
||||||
|
enable = true;
|
||||||
package = pkgs.bibata-cursors;
|
package = pkgs.bibata-cursors;
|
||||||
name = "Bibata-Modern-Ice";
|
name = "Bibata-Modern-Ice";
|
||||||
size = 24;
|
size = 24;
|
||||||
@@ -155,7 +192,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 +202,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 +218,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 +245,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 +321,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 +348,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 +361,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 +523,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 +535,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 +552,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 +570,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
+26
-9
@@ -7,16 +7,16 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1787377438,
|
"lastModified": 1788651960,
|
||||||
"narHash": "sha256-Sxu1NLTD/Ern6hFGLlZmtKCSct3YQXZI/lls8RE1XeM=",
|
"narHash": "sha256-v9wJd32eZ2bvhBzVOd7TIjLQd011P7nwOhjKtWlci5I=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "65258d5c65a250189fde2e35f490d15e064c4c62",
|
"rev": "2c0350c759688177331b8f5242311fae8877bdb3",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"ref": "release-26.05",
|
"ref": "master",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
@@ -41,16 +41,32 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1788405554,
|
"lastModified": 1788614874,
|
||||||
"narHash": "sha256-r2f1oUwixlgq9zOdYLqJLfS/lWBT60/IITjhTKI59JU=",
|
"narHash": "sha256-7QYjT2vHLuX9Z1pdxHXDKCbh1CR3D/2rywB9Tx0MPRg=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "a5cc6f2c37bf518436dc8d1c288ccd0c43c2f4c4",
|
"rev": "c043004d1c6985732bcc1cbc5a9c9aecbbb4e0f0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"ref": "nixos-26.05",
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-latest": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1788714682,
|
||||||
|
"narHash": "sha256-2HwAxLzDRJgLN7rsnU54alC898C1YLrHX5paHAiOFpk=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "3e6ba0d1cf48c2f84922fd44b104b1ebaba6ec6b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "master",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
@@ -59,7 +75,8 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"home-manager": "home-manager",
|
"home-manager": "home-manager",
|
||||||
"neovim-dots": "neovim-dots",
|
"neovim-dots": "neovim-dots",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs",
|
||||||
|
"nixpkgs-latest": "nixpkgs-latest"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,14 +2,18 @@
|
|||||||
description = "Development host";
|
description = "Development host";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
# Release branches may advance; flake.lock records every exact snapshot.
|
# Rolling, latest-tested NixOS; flake.lock records the resolved snapshot.
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
home-manager = {
|
home-manager = {
|
||||||
url = "github:nix-community/home-manager/release-26.05";
|
url = "github:nix-community/home-manager/master";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Fast-moving developer tools can advance ahead of the tested channel.
|
||||||
|
# flake.lock still records the exact resolved snapshot.
|
||||||
|
nixpkgs-latest.url = "github:NixOS/nixpkgs/master";
|
||||||
|
|
||||||
neovim-dots = {
|
neovim-dots = {
|
||||||
url = "git+https://git.cyber.ayyalasomayajula.net/marsultor/neovim-dots?ref=main&rev=380eb86778a7c53a0f1c18e84f14037456155347";
|
url = "git+https://git.cyber.ayyalasomayajula.net/marsultor/neovim-dots?ref=main&rev=380eb86778a7c53a0f1c18e84f14037456155347";
|
||||||
flake = false;
|
flake = false;
|
||||||
@@ -18,16 +22,32 @@
|
|||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
inputs@{ nixpkgs, home-manager, ... }:
|
inputs@{ nixpkgs, home-manager, ... }:
|
||||||
|
let
|
||||||
|
mkHost =
|
||||||
|
module:
|
||||||
|
nixpkgs.lib.nixosSystem {
|
||||||
|
specialArgs = { inherit inputs; };
|
||||||
|
modules = [
|
||||||
|
module
|
||||||
|
home-manager.nixosModules.home-manager
|
||||||
|
];
|
||||||
|
};
|
||||||
|
in
|
||||||
{
|
{
|
||||||
nixosConfigurations.dev = nixpkgs.lib.nixosSystem {
|
nixosConfigurations = {
|
||||||
specialArgs = { inherit inputs; };
|
dev = mkHost ./configuration.nix; # EC2 integration
|
||||||
modules = [
|
nixos = mkHost ./physical.nix; # This laptop's boot/storage/hardware
|
||||||
./configuration.nix
|
};
|
||||||
home-manager.nixosModules.home-manager
|
|
||||||
];
|
templates.agent = {
|
||||||
|
path = ./templates/agent;
|
||||||
|
description = "Project toolchain + live-workspace DeepSeek Harness microVM";
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
@@ -43,12 +63,45 @@
|
|||||||
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 {
|
||||||
|
inherit pkgs;
|
||||||
|
config = inputs.self.nixosConfigurations.nixos.config;
|
||||||
|
ec2Config = inputs.self.nixosConfigurations.dev.config;
|
||||||
|
};
|
||||||
|
switch-system =
|
||||||
|
pkgs.runCommand "switch-system-check"
|
||||||
|
{
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
python3
|
||||||
|
bash
|
||||||
|
coreutils
|
||||||
|
util-linux
|
||||||
|
shellcheck
|
||||||
|
];
|
||||||
|
}
|
||||||
|
''
|
||||||
|
shellcheck ${./switch-system.sh}
|
||||||
|
python ${./switch-test.py} ${./switch-system.sh}
|
||||||
|
touch "$out"
|
||||||
|
'';
|
||||||
|
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 =
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Do not modify this file! It was generated by ‘nixos-generate-config’
|
||||||
|
# and may be overwritten by future invocations. Please make changes
|
||||||
|
# to /etc/nixos/configuration.nix instead.
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
modulesPath,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
(modulesPath + "/installer/scan/not-detected.nix")
|
||||||
|
];
|
||||||
|
|
||||||
|
boot.initrd.availableKernelModules = [
|
||||||
|
"xhci_pci"
|
||||||
|
"thunderbolt"
|
||||||
|
"nvme"
|
||||||
|
"usb_storage"
|
||||||
|
"sd_mod"
|
||||||
|
"sdhci_pci"
|
||||||
|
];
|
||||||
|
boot.initrd.kernelModules = [ ];
|
||||||
|
boot.kernelModules = [ "kvm-intel" ];
|
||||||
|
boot.extraModulePackages = [ ];
|
||||||
|
|
||||||
|
fileSystems."/" = {
|
||||||
|
device = "/dev/disk/by-uuid/8a16015f-d6f8-4f74-8558-6261b9112216";
|
||||||
|
fsType = "btrfs";
|
||||||
|
};
|
||||||
|
|
||||||
|
fileSystems."/home" = {
|
||||||
|
device = "/dev/disk/by-uuid/8a16015f-d6f8-4f74-8558-6261b9112216";
|
||||||
|
fsType = "btrfs";
|
||||||
|
options = [ "subvol=home" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
fileSystems."/nix" = {
|
||||||
|
device = "/dev/disk/by-uuid/8a16015f-d6f8-4f74-8558-6261b9112216";
|
||||||
|
fsType = "btrfs";
|
||||||
|
options = [ "subvol=nix" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
fileSystems."/boot" = {
|
||||||
|
device = "/dev/disk/by-uuid/DEC5-51CB";
|
||||||
|
fsType = "vfat";
|
||||||
|
options = [
|
||||||
|
"fmask=0077"
|
||||||
|
"dmask=0077"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
swapDevices = [ ];
|
||||||
|
|
||||||
|
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
|
||||||
|
hardware.cpu.intel.npu.enable = true;
|
||||||
|
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
|
||||||
|
}
|
||||||
+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 },
|
||||||
|
|||||||
+63
-9
@@ -1,10 +1,21 @@
|
|||||||
{ pkgs, ... }:
|
|
||||||
|
|
||||||
{
|
{
|
||||||
# Keep each host's existing interface/DHCP owner. On EC2 this is dhcpcd;
|
config,
|
||||||
# NixOS wires its resolvconf hook to resolved's compatibility interface.
|
inputs,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
latest = import inputs.nixpkgs-latest {
|
||||||
|
inherit (pkgs.stdenv.hostPlatform) system;
|
||||||
|
config = pkgs.config;
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
# NetworkManager owns local DNS on workstations. With dhcpcd (EC2),
|
||||||
|
# NixOS wires resolvconf to resolved. Preserve both hosts' existing behavior.
|
||||||
services.resolved = {
|
services.resolved = {
|
||||||
enable = true;
|
enable = lib.mkDefault (!config.networking.networkmanager.enable);
|
||||||
settings.Resolve = {
|
settings.Resolve = {
|
||||||
LLMNR = false;
|
LLMNR = false;
|
||||||
MulticastDNS = false;
|
MulticastDNS = false;
|
||||||
@@ -12,14 +23,57 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
programs.mtr.enable = true;
|
# Local, opt-in SOCKS client only. Keep the daemon on the system package pin.
|
||||||
environment.systemPackages = with pkgs; [
|
# No relay/exit, control listener, transparent proxy or host DNS changes.
|
||||||
wireguard-tools
|
services.tor = {
|
||||||
|
enable = true;
|
||||||
|
openFirewall = false;
|
||||||
|
relay.enable = false;
|
||||||
|
client = {
|
||||||
|
enable = true;
|
||||||
|
socksListenAddress = {
|
||||||
|
addr = "127.0.0.1";
|
||||||
|
port = 9050;
|
||||||
|
IsolateDestAddr = true;
|
||||||
|
IsolateSOCKSAuth = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
settings.ClientOnly = true;
|
||||||
|
};
|
||||||
|
# The NixOS module enables tor.service at boot and supplies its sandbox/user.
|
||||||
|
# Retry even after a clean daemon exit; never exhaust systemd's start limit.
|
||||||
|
systemd.services.tor = {
|
||||||
|
unitConfig.StartLimitIntervalSec = 0;
|
||||||
|
serviceConfig = {
|
||||||
|
Restart = lib.mkForce "always";
|
||||||
|
RestartSec = "5s";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
programs.mtr = {
|
||||||
|
enable = true;
|
||||||
|
package = latest.mtr;
|
||||||
|
};
|
||||||
|
environment.systemPackages = with latest; [
|
||||||
|
wireguard-tools # wg and wg-quick; no interfaces or credentials are configured.
|
||||||
|
openvpn
|
||||||
|
iperf3
|
||||||
|
nmap
|
||||||
|
traceroute
|
||||||
|
whois
|
||||||
dnsutils
|
dnsutils
|
||||||
tcpdump
|
tcpdump
|
||||||
ethtool
|
ethtool
|
||||||
netcat-openbsd
|
netcat-openbsd
|
||||||
socat
|
socat
|
||||||
|
fping
|
||||||
|
ldns # drill and DNS/DNSSEC inspection utilities.
|
||||||
|
torsocks
|
||||||
|
proxychains-ng # Opt-in wrappers; no global proxy environment is set.
|
||||||
|
doggo
|
||||||
|
iftop
|
||||||
|
bandwhich
|
||||||
|
wireshark-cli # tshark; no capture group/capabilities or daemon.
|
||||||
];
|
];
|
||||||
# wg/wg-quick are available, but no invented peers, keys, routes or ports.
|
# No VPN services, peers, keys, routes or opened firewall ports.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# Physical safety, shared host policy and built files; no activation/connections.
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
ec2Config,
|
||||||
|
pkgs,
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
inherit (pkgs) lib;
|
||||||
|
dev = config.users.users.dev;
|
||||||
|
btrfsDevice = "/dev/disk/by-uuid/8a16015f-d6f8-4f74-8558-6261b9112216";
|
||||||
|
mounts = {
|
||||||
|
"/" = {
|
||||||
|
device = btrfsDevice;
|
||||||
|
fsType = "btrfs";
|
||||||
|
};
|
||||||
|
"/home" = {
|
||||||
|
device = btrfsDevice;
|
||||||
|
fsType = "btrfs";
|
||||||
|
options = [ "subvol=home" ];
|
||||||
|
};
|
||||||
|
"/nix" = {
|
||||||
|
device = btrfsDevice;
|
||||||
|
fsType = "btrfs";
|
||||||
|
options = [ "subvol=nix" ];
|
||||||
|
};
|
||||||
|
"/boot" = {
|
||||||
|
device = "/dev/disk/by-uuid/DEC5-51CB";
|
||||||
|
fsType = "vfat";
|
||||||
|
options = [
|
||||||
|
"fmask=0077"
|
||||||
|
"dmask=0077"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
tests = [
|
||||||
|
{
|
||||||
|
assertion = config.boot.loader.systemd-boot.enable && !config.boot.loader.grub.enable;
|
||||||
|
message = "The physical host must use systemd-boot, never EC2's GRUB disk.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = lib.all (
|
||||||
|
mount:
|
||||||
|
let
|
||||||
|
actual = config.fileSystems.${mount};
|
||||||
|
expected = mounts.${mount};
|
||||||
|
in
|
||||||
|
actual.device == expected.device
|
||||||
|
&& actual.fsType == expected.fsType
|
||||||
|
&& lib.all (option: builtins.elem option actual.options) (expected.options or [ ])
|
||||||
|
) (builtins.attrNames mounts);
|
||||||
|
message = "Preserve the installed root/home/nix/EFI filesystems and Btrfs subvolumes.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
config.users.mutableUsers
|
||||||
|
&& !(config.users.users ? kbot)
|
||||||
|
&& dev.password == null
|
||||||
|
&& dev.hashedPassword == null
|
||||||
|
&& dev.hashedPasswordFile == null;
|
||||||
|
message = "Retire kbot without overwriting dev's locally established password.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
dev.isNormalUser
|
||||||
|
&& dev.uid == 1001
|
||||||
|
&& dev.home == "/home/dev"
|
||||||
|
&& builtins.elem "wheel" dev.extraGroups
|
||||||
|
&& builtins.elem "networkmanager" dev.extraGroups
|
||||||
|
&& builtins.attrNames config.home-manager.users == [ "dev" ];
|
||||||
|
message = "dev is the sole managed daily account, with local administration/network access.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
config.services.displayManager.sddm.enable
|
||||||
|
&& !config.services.desktopManager.plasma6.enable
|
||||||
|
&& config.programs.hyprland.enable
|
||||||
|
&& config.programs.hyprland.withUWSM
|
||||||
|
&& !config.services.greetd.enable
|
||||||
|
&& !config.services.displayManager.autoLogin.enable
|
||||||
|
&& config.services.displayManager.defaultSession == "hyprland-uwsm"
|
||||||
|
&& !(builtins.elem "hyprland" config.services.displayManager.sessionData.sessionNames);
|
||||||
|
message = "Offer only the managed Hyprland desktop in SDDM, without Plasma or autologin.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
config.networking.networkmanager.enable
|
||||||
|
&& !config.services.resolved.enable
|
||||||
|
&& builtins.elem (lib.getName pkgs.networkmanager-openvpn) (
|
||||||
|
map lib.getName config.networking.networkmanager.plugins
|
||||||
|
);
|
||||||
|
message = "Keep NetworkManager/DNS and provide its OpenVPN integration.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
config.systemd.services.nixos-update.environment.NIXOS_UPDATE_HOST == "nixos"
|
||||||
|
&& config.systemd.services.nixos-update.environment.NIXOS_CONFIG_REPO == "/etc/nix"
|
||||||
|
&& config.systemd.services.nixos-update.serviceConfig.User == "dev"
|
||||||
|
&& builtins.elem "Z /etc/nix - dev users -" config.systemd.tmpfiles.rules
|
||||||
|
&& config.systemd.timers.nixos-update.timerConfig.Persistent
|
||||||
|
&& !(config.systemd.services ? amazon-ssm-agent)
|
||||||
|
&& !(builtins.elem "Z /etc/nixos - dev users -" config.systemd.tmpfiles.rules);
|
||||||
|
message = "The laptop must keep its own checkout/target, not EC2's deployment settings.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
lib.all
|
||||||
|
(
|
||||||
|
c:
|
||||||
|
let
|
||||||
|
update = c.systemd.services.nixos-update;
|
||||||
|
in
|
||||||
|
update.serviceConfig.User == "dev"
|
||||||
|
&& update.serviceConfig.Group == "users"
|
||||||
|
&& update.serviceConfig.ExecStart == config.systemd.services.nixos-update.serviceConfig.ExecStart
|
||||||
|
&& c.systemd.timers.nixos-update.timerConfig == config.systemd.timers.nixos-update.timerConfig
|
||||||
|
&& !(update.environment ? NIXOS_UPDATE_MODE)
|
||||||
|
&& builtins.elem "Z ${update.environment.NIXOS_CONFIG_REPO} - dev users -" c.systemd.tmpfiles.rules
|
||||||
|
&& builtins.elem "Z /var/cache/nixos-update - dev users -" c.systemd.tmpfiles.rules
|
||||||
|
&& c.users.mutableUsers
|
||||||
|
&& c.time.timeZone == "America/Chicago"
|
||||||
|
&& c.i18n.defaultLocale == "en_US.UTF-8"
|
||||||
|
)
|
||||||
|
[
|
||||||
|
config
|
||||||
|
ec2Config
|
||||||
|
];
|
||||||
|
message = "Both hosts must share the dev-owned checkout/updater and user preferences; no per-host activation policy.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
ec2Config.systemd.services.nixos-update.environment.NIXOS_UPDATE_HOST == "dev"
|
||||||
|
&& ec2Config.systemd.services.nixos-update.environment.NIXOS_CONFIG_REPO == "/etc/nixos"
|
||||||
|
&& ec2Config.boot.loader.grub.enable
|
||||||
|
&& ec2Config.boot.loader.grub.device == "/dev/xvda"
|
||||||
|
&& !ec2Config.boot.loader.systemd-boot.enable
|
||||||
|
&& ec2Config.fileSystems."/".device == "/dev/disk/by-label/nixos"
|
||||||
|
&& ec2Config.fileSystems."/".fsType == "ext4"
|
||||||
|
&& ec2Config.services.openssh.enable
|
||||||
|
&& ec2Config.services.amazon-ssm-agent.enable
|
||||||
|
&& ec2Config.networking.dhcpcd.enable
|
||||||
|
&& ec2Config.services.resolved.enable
|
||||||
|
&& !ec2Config.networking.networkmanager.enable
|
||||||
|
&& !ec2Config.services.displayManager.sddm.enable;
|
||||||
|
message = "EC2 must retain its AWS boot, disks, networking and remote recovery, not laptop hardware services.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
lib.all
|
||||||
|
(package: builtins.elem (lib.getName package) (map lib.getName config.environment.systemPackages))
|
||||||
|
(
|
||||||
|
with pkgs;
|
||||||
|
[
|
||||||
|
wireguard-tools
|
||||||
|
openvpn
|
||||||
|
iperf3
|
||||||
|
nmap
|
||||||
|
traceroute
|
||||||
|
whois
|
||||||
|
dnsutils
|
||||||
|
tcpdump
|
||||||
|
ethtool
|
||||||
|
netcat-openbsd
|
||||||
|
socat
|
||||||
|
fping
|
||||||
|
ldns
|
||||||
|
torsocks
|
||||||
|
proxychains-ng
|
||||||
|
tor
|
||||||
|
]
|
||||||
|
)
|
||||||
|
&& config.programs.mtr.enable;
|
||||||
|
message = "The VPN/proxy clients and network diagnostics must remain installed.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
lib.all
|
||||||
|
(
|
||||||
|
c:
|
||||||
|
let
|
||||||
|
tor = c.services.tor;
|
||||||
|
unit = c.systemd.services.tor;
|
||||||
|
in
|
||||||
|
tor.enable
|
||||||
|
&& tor.client.enable
|
||||||
|
&& !tor.relay.enable
|
||||||
|
&& !tor.openFirewall
|
||||||
|
&& tor.settings.ClientOnly
|
||||||
|
&& tor.settings.ORPort == [ ]
|
||||||
|
&& tor.settings.DirPort == [ ]
|
||||||
|
&& tor.settings.ExitPolicy == [ "reject *:*" ]
|
||||||
|
&& tor.relay.onionServices == { }
|
||||||
|
&& !tor.controlSocket.enable
|
||||||
|
&& tor.settings.ControlPort == [ ]
|
||||||
|
&& !tor.client.dns.enable
|
||||||
|
&& !tor.client.transparentProxy.enable
|
||||||
|
&& tor.settings.DNSPort == [ ]
|
||||||
|
&& tor.settings.TransPort == [ ]
|
||||||
|
&& builtins.length tor.settings.SOCKSPort == 1
|
||||||
|
&& lib.all (
|
||||||
|
listener:
|
||||||
|
listener.addr == "127.0.0.1"
|
||||||
|
&& listener.port == 9050
|
||||||
|
&& listener.IsolateDestAddr
|
||||||
|
&& listener.IsolateSOCKSAuth
|
||||||
|
) tor.settings.SOCKSPort
|
||||||
|
&& builtins.elem "multi-user.target" unit.wantedBy
|
||||||
|
&& unit.serviceConfig.Restart == "always"
|
||||||
|
&& unit.serviceConfig.RestartSec == "5s"
|
||||||
|
&& unit.unitConfig.StartLimitIntervalSec == 0
|
||||||
|
&& unit.serviceConfig.User == "tor"
|
||||||
|
&& unit.serviceConfig.NoNewPrivileges
|
||||||
|
)
|
||||||
|
[
|
||||||
|
config
|
||||||
|
ec2Config
|
||||||
|
];
|
||||||
|
message = "Both hosts need a boot-enabled, restarting, loopback-only Tor client, without relay, control, DNS or transparent-proxy listeners.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
config.networking.wireguard.interfaces == { }
|
||||||
|
&& config.networking.wg-quick.interfaces == { }
|
||||||
|
&& config.services.openvpn.servers == { };
|
||||||
|
message = "Installing VPN tools must not invent tunnels, peers or credentials.";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
in
|
||||||
|
assert lib.all (test: lib.assertMsg test.assertion test.message) tests;
|
||||||
|
pkgs.runCommand "physical-config-check" { } ''
|
||||||
|
sessions=${config.services.displayManager.sessionData.desktops}/share
|
||||||
|
test ! -e "$sessions/wayland-sessions/plasma.desktop"
|
||||||
|
test -f "$sessions/wayland-sessions/hyprland-uwsm.desktop"
|
||||||
|
test ! -e "$sessions/wayland-sessions/hyprland.desktop"
|
||||||
|
for tool in wg wg-quick openvpn iperf3 nmap traceroute whois mtr dig tcpdump ethtool nc socat \
|
||||||
|
fping drill torsocks proxychains4 tor nm-connection-editor; do
|
||||||
|
test -x "${config.system.path}/bin/$tool"
|
||||||
|
done
|
||||||
|
# Validate the exact generated torrc offline without touching live Tor state.
|
||||||
|
mkdir -m 700 "$TMPDIR/tor"
|
||||||
|
${config.services.tor.package}/bin/tor --verify-config \
|
||||||
|
-f ${builtins.head config.systemd.services.tor.restartTriggers} \
|
||||||
|
--DataDirectory "$TMPDIR/tor"
|
||||||
|
touch "$out"
|
||||||
|
''
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Laptop-only boot/storage, hardware measurements and checkout identity.
|
||||||
|
# The original /etc/nixos files and /home/kbot remain untouched.
|
||||||
|
{ lib, ... }:
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
./hardware-configuration.nix
|
||||||
|
./common.nix
|
||||||
|
./workstation.nix
|
||||||
|
];
|
||||||
|
|
||||||
|
boot.loader = {
|
||||||
|
systemd-boot.enable = true;
|
||||||
|
efi.canTouchEfiVariables = true;
|
||||||
|
};
|
||||||
|
networking.hostName = "nixos";
|
||||||
|
|
||||||
|
# 1440x900 logical pixels; leave other displays on automatic scaling.
|
||||||
|
home-manager.users.dev.wayland.windowManager.hyprland.extraConfig = lib.mkAfter ''
|
||||||
|
hl.monitor({ output = "eDP-1", mode = "preferred", position = "auto", scale = 4 / 3 })
|
||||||
|
'';
|
||||||
|
home-manager.users.dev.programs.ashell.settings.system_info.temperature.sensor =
|
||||||
|
"coretemp Package id 0";
|
||||||
|
|
||||||
|
systemd.services.nixos-update.environment = {
|
||||||
|
NIXOS_CONFIG_REPO = "/etc/nix";
|
||||||
|
NIXOS_UPDATE_HOST = "nixos";
|
||||||
|
};
|
||||||
|
}
|
||||||
+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@; }
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# The installed command gets its host/path from updates.nix on both machines.
|
||||||
|
# Direct execution from this checkout defaults to the laptop, never the login name.
|
||||||
|
set -euo pipefail
|
||||||
|
repo=${NIXOS_CONFIG_REPO:-/etc/nix}
|
||||||
|
host=${NIXOS_UPDATE_HOST:-nixos}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
printf 'Usage: %s [switch|dry-activate|boot|test]\n' "$0"
|
||||||
|
printf 'Build %s#%s using flake.lock.\n' "$repo" "$host"
|
||||||
|
printf '%s\n' \
|
||||||
|
'Default: switch now and save for boot (an explicit manual action).' \
|
||||||
|
'dry-activate previews changes; boot stages them; test applies temporarily.' \
|
||||||
|
'Does not pull Git, update package pins, delete generations, or reboot.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $# -eq 1 && ($1 == --help || $1 == -h) ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if (($# > 1)); then
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
action=${1:-switch}
|
||||||
|
case "$action" in
|
||||||
|
switch | dry-activate | boot | test) ;;
|
||||||
|
*)
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
case "$host" in
|
||||||
|
nixos | dev) ;;
|
||||||
|
*)
|
||||||
|
printf 'Unsupported host: %s\n' "$host" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Keep builds/Git access under the checkout owner, including when invoked via sudo.
|
||||||
|
# Only activating the built system needs root.
|
||||||
|
if ((EUID == 0)); then
|
||||||
|
exec runuser -u dev -- env NIXOS_CONFIG_REPO="$repo" NIXOS_UPDATE_HOST="$host" \
|
||||||
|
"$(readlink -f -- "${BASH_SOURCE[0]}")" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Share the automatic updater's lock. Directory ownership is managed by NixOS.
|
||||||
|
state=${CACHE_DIRECTORY:-/var/cache/nixos-update}
|
||||||
|
mkdir -p "$state"
|
||||||
|
exec 9>"$state/lock"
|
||||||
|
flock 9
|
||||||
|
|
||||||
|
cd "$repo"
|
||||||
|
printf 'Building %s#%s (%s).\n' "$repo" "$host" "$action"
|
||||||
|
built=$(nix build ".#nixosConfigurations.$host.config.system.build.toplevel" \
|
||||||
|
--no-update-lock-file --no-link --print-out-paths)
|
||||||
|
# Keep this shell alive holding the lock: sudo closes inherited descriptors.
|
||||||
|
sudo "$built/sw/bin/nixos-rebuild" "$action" --no-reexec --store-path "$built"
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Test manual switching for both hosts without Nix builds or host activation."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
script = Path(sys.argv[1]).resolve()
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
raise SystemExit("Run this check as an unprivileged user (or through nix flake check).")
|
||||||
|
|
||||||
|
mock = r'''import fcntl, json, os, pathlib, sys
|
||||||
|
name = pathlib.Path(sys.argv[0]).name
|
||||||
|
args = sys.argv[1:]
|
||||||
|
with open(os.environ["CALLS"], "a") as f:
|
||||||
|
f.write(json.dumps([name, *args]) + "\n")
|
||||||
|
if name in ("nix", "sudo"):
|
||||||
|
# Model sudo's descriptor cleanup: the parent shell must retain the lock.
|
||||||
|
try: os.close(9)
|
||||||
|
except OSError: pass
|
||||||
|
with open(pathlib.Path(os.environ["CACHE_DIRECTORY"]) / "lock", "a") as lock:
|
||||||
|
try: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError: pass
|
||||||
|
else: raise AssertionError("Updater lock was released before activation completed")
|
||||||
|
if name == "nix":
|
||||||
|
assert pathlib.Path.cwd() == pathlib.Path(os.environ["NIXOS_CONFIG_REPO"])
|
||||||
|
assert args == ["build", f'.#nixosConfigurations.{os.environ["NIXOS_UPDATE_HOST"]}.config.system.build.toplevel', "--no-update-lock-file", "--no-link", "--print-out-paths"]
|
||||||
|
if os.environ["SCENARIO"] == "build-failure": sys.exit(42)
|
||||||
|
print(os.environ["BUILT"])
|
||||||
|
elif name == "sudo":
|
||||||
|
# No sudo -v: its verifypw=all policy can demand a password even when
|
||||||
|
# the actual command is NOPASSWD (the user also has a wheel rule).
|
||||||
|
assert args[0] == os.environ["BUILT"] + "/sw/bin/nixos-rebuild"
|
||||||
|
assert args[2:] == ["--no-reexec", "--store-path", os.environ["BUILT"]]
|
||||||
|
if os.environ["SCENARIO"] == "sudo-failure": sys.exit(43)
|
||||||
|
if os.environ["SCENARIO"] == "activation-failure": sys.exit(44)
|
||||||
|
else:
|
||||||
|
raise AssertionError(name)
|
||||||
|
'''
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="switch-test-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
repo = root / "repo with spaces"
|
||||||
|
binaries = root / "bin"
|
||||||
|
calls = root / "calls"
|
||||||
|
repo.mkdir()
|
||||||
|
binaries.mkdir()
|
||||||
|
for name in ["nix", "sudo"]:
|
||||||
|
executable = binaries / name
|
||||||
|
executable.write_text("#!" + sys.executable + "\n" + mock)
|
||||||
|
executable.chmod(0o755)
|
||||||
|
env = dict(os.environ, PATH=str(binaries) + os.pathsep + os.environ["PATH"],
|
||||||
|
NIXOS_CONFIG_REPO=str(repo), CACHE_DIRECTORY=str(root / "cache"),
|
||||||
|
CALLS=str(calls), BUILT=str(root / "built system"))
|
||||||
|
cases = [([], "switch", "success", 0), (["dry-activate"], "dry-activate", "success", 0),
|
||||||
|
(["boot"], "boot", "success", 0), (["test"], "test", "success", 0),
|
||||||
|
(["--help"], None, "help", 0), (["invalid"], None, "invalid", 2),
|
||||||
|
(["switch", "extra"], None, "invalid", 2),
|
||||||
|
([], None, "build-failure", 42), ([], "switch", "sudo-failure", 43),
|
||||||
|
([], "switch", "activation-failure", 44), ([], None, "invalid-host", 2)]
|
||||||
|
for host in ["nixos", "dev"]:
|
||||||
|
for args, action, scenario, expected_code in cases:
|
||||||
|
calls.unlink(missing_ok=True)
|
||||||
|
result = subprocess.run(
|
||||||
|
["bash", str(script), *args], cwd=root,
|
||||||
|
env=env | {"NIXOS_UPDATE_HOST": "unknown" if scenario == "invalid-host" else host,
|
||||||
|
"SCENARIO": scenario}, text=True, capture_output=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == expected_code, (host, scenario, result.stdout, result.stderr)
|
||||||
|
log = [json.loads(line) for line in calls.read_text().splitlines()] if calls.exists() else []
|
||||||
|
if action:
|
||||||
|
assert [call[0] for call in log] == ["nix", "sudo"], log
|
||||||
|
assert log[-1][2] == action, log
|
||||||
|
elif scenario == "build-failure":
|
||||||
|
assert [call[0] for call in log] == ["nix"], log
|
||||||
|
else:
|
||||||
|
assert not log, log
|
||||||
|
print("PASS", host, args or ["default"], scenario)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
result
|
||||||
|
result-*
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.qcow2
|
||||||
|
*.img
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Project + DeepSeek Harness microVM
|
||||||
|
|
||||||
|
`project.nix` is the shared toolchain for `nix develop` and the guest.
|
||||||
|
`flake.nix` sets RAM, vCPUs and networking. Run from the project directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run .#agent # boots with cwd mounted read-write at its original path
|
||||||
|
# Another terminal in the same directory:
|
||||||
|
nix run .#agent -- url # private browser login URL
|
||||||
|
nix run .#agent -- ssh # root shell, starting in the same project cwd
|
||||||
|
nix run .#agent -- stop
|
||||||
|
```
|
||||||
|
|
||||||
|
`$DSH_HOME` (default `~/.dsh`) and `${DSH_AGENTS_HOME:-~/.agents}/skills` are
|
||||||
|
also mounted **read-write**. No other home directories or host sockets are shared.
|
||||||
|
The first run creates missing DSH/skills directories. Existing DSH home must be
|
||||||
|
private (`chmod 700 ~/.dsh`). Credentials, settings, profiles and skills are live
|
||||||
|
shared files, not copied into the Nix store. Select the project's original
|
||||||
|
absolute path in the DSH UI; `/workspace` is also an alias.
|
||||||
|
|
||||||
|
DSH resolves `@deepseek-ai/dsh@latest` inside the VM on startup. Nix packages
|
||||||
|
follow the rolling Nixpkgs input: `nix flake update`, then restart the VM.
|
||||||
|
|
||||||
|
See `/etc/nix/AGENT-VM.md` for the full guide and security boundaries. The reusable
|
||||||
|
input lives at `/etc/nix/agent-vm`; replace the local input with your Git remote
|
||||||
|
when sharing this project. Keep backups: the agent can modify/delete the mounted
|
||||||
|
project and its shared DSH configuration/credentials/skills.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
description = "Project toolchain + DeepSeek Harness microVM";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
# Local bootstrap. For teammates/CI use your Git remote's moving branch:
|
||||||
|
# git+https://YOUR-REMOTE/nixconfig.git?dir=agent-vm&ref=main
|
||||||
|
agent-vm.url = "path:/etc/nix/agent-vm";
|
||||||
|
agent-vm.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ nixpkgs, agent-vm, ... }:
|
||||||
|
let
|
||||||
|
system = "x86_64-linux";
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
project = import ./project.nix { inherit pkgs; };
|
||||||
|
agent = agent-vm.lib.mkAgentVM {
|
||||||
|
inherit system project;
|
||||||
|
modules = [
|
||||||
|
{
|
||||||
|
microvm.mem = 4096; # MiB
|
||||||
|
microvm.vcpu = 4;
|
||||||
|
agentVM.network = {
|
||||||
|
hostAddress = "127.0.0.1"; # Or a host LAN/VPN IPv4 address, or 0.0.0.0.
|
||||||
|
sshPort = 2222; # Always host localhost in user-network mode.
|
||||||
|
webPort = 3080;
|
||||||
|
# For 0.0.0.0, list the actual browser authorities, not a wildcard:
|
||||||
|
# trustedHosts = [ "192.168.1.20:3080" ];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells.${system}.default = pkgs.mkShell {
|
||||||
|
inherit (project) packages env;
|
||||||
|
};
|
||||||
|
packages.${system}.agent = agent.package;
|
||||||
|
apps.${system}.agent = agent.app;
|
||||||
|
nixosConfigurations.agent = agent.nixos;
|
||||||
|
formatter.${system} = pkgs.nixfmt;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{ pkgs }:
|
||||||
|
{
|
||||||
|
# Single source of truth: both pkgs.mkShell and the VM consume this attrset.
|
||||||
|
# Replace/add your project's pinned toolchain here, not in two separate lists.
|
||||||
|
packages = with pkgs; [
|
||||||
|
python3
|
||||||
|
uv
|
||||||
|
ruff
|
||||||
|
nodejs
|
||||||
|
pnpm
|
||||||
|
git
|
||||||
|
ripgrep
|
||||||
|
];
|
||||||
|
# Shared non-secret variables. Do not put API tokens in a Nix expression.
|
||||||
|
env = {
|
||||||
|
UV_PYTHON_DOWNLOADS = "never";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Bounded, offline smoke tests. No user caches, credentials, servers or downloads.
|
||||||
|
{ config, pkgs }:
|
||||||
|
assert builtins.elem pkgs.tor-browser config.home-manager.users.dev.home.packages;
|
||||||
|
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 switch-system; do
|
||||||
|
command -v "$tool" >/dev/null
|
||||||
|
done
|
||||||
|
# Inspect only: don't start a graphical browser or make Tor connections.
|
||||||
|
test -x ${pkgs.tor-browser}/bin/tor-browser
|
||||||
|
test -x ${config.home-manager.users.dev.programs.lazygit.package}/bin/lazygit
|
||||||
|
test -x ${config.home-manager.users.dev.programs.tmux.package}/bin/tmux
|
||||||
|
switch-system --help > "$TMPDIR/switch-help"
|
||||||
|
grep -Fq '/etc/nix#nixos' "$TMPDIR/switch-help"
|
||||||
|
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
|
||||||
|
socat -V
|
||||||
|
# fping opens ICMP sockets even for -v; physical-config checks its binary.
|
||||||
|
drill -v
|
||||||
|
tor --version
|
||||||
|
torsocks --version
|
||||||
|
printf 'socat-offline-check\n' | socat -u STDIN STDOUT | grep -qx socat-offline-check
|
||||||
|
torsocks curl --version
|
||||||
|
proxychains4 -q curl --version
|
||||||
|
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,39 +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
|
||||||
|
]
|
||||||
|
++ (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
|
||||||
@@ -44,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" ];
|
||||||
@@ -56,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;
|
||||||
@@ -85,15 +304,16 @@ 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";
|
fileWidget.command = "fd --type f --hidden --exclude .git";
|
||||||
changeDirWidgetCommand = "fd --type d --hidden --exclude .git";
|
changeDirWidget.command = "fd --type d --hidden --exclude .git";
|
||||||
defaultOptions = [
|
defaultOptions = [
|
||||||
"--height=45%"
|
"--height=45%"
|
||||||
"--layout=reverse"
|
"--layout=reverse"
|
||||||
"--border=rounded"
|
"--border=rounded"
|
||||||
];
|
];
|
||||||
fileWidgetOptions = [ "--preview 'bat --color=always --line-range=:200 -- {}'" ];
|
fileWidget.options = [ "--preview 'bat --color=always --line-range=:200 -- {}'" ];
|
||||||
colors = {
|
colors = {
|
||||||
bg = c.background;
|
bg = c.background;
|
||||||
fg = c.text;
|
fg = c.text;
|
||||||
@@ -102,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;
|
||||||
@@ -111,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]";
|
||||||
@@ -142,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;
|
||||||
@@ -158,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;
|
||||||
@@ -170,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;
|
||||||
@@ -185,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;
|
||||||
@@ -196,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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+36
-15
@@ -1,5 +1,11 @@
|
|||||||
# Run as dev; privileged activation uses the already declared scoped sudo rule.
|
# Both hosts stage for the next boot. Only the checkout and flake target differ;
|
||||||
repo=${NIXOS_CONFIG_REPO:-/etc/nixos}
|
# target identity is explicit, never inferred from the login name.
|
||||||
|
repo=${NIXOS_CONFIG_REPO:?Set NIXOS_CONFIG_REPO}
|
||||||
|
host=${NIXOS_UPDATE_HOST:?Set NIXOS_UPDATE_HOST}
|
||||||
|
case "$host" in
|
||||||
|
dev|nixos) ;;
|
||||||
|
*) echo "Refusing unsupported update target: $host" >&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 +17,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
|
||||||
@@ -21,40 +30,52 @@ trap cleanup EXIT
|
|||||||
|
|
||||||
git worktree add --detach "$work" "$baseline"
|
git worktree add --detach "$work" "$baseline"
|
||||||
cd "$work"
|
cd "$work"
|
||||||
# Only these stable release inputs advance. Neovim's source stays pinned.
|
# Advance package inputs to newest resolving branch heads. Neovim stays pinned.
|
||||||
nix flake update nixpkgs home-manager
|
nix flake update nixpkgs home-manager nixpkgs-latest
|
||||||
if git diff --quiet -- flake.lock; then
|
if git diff --quiet -- flake.lock; then
|
||||||
echo 'Stable inputs are already current.'
|
echo 'Package inputs are already current.'
|
||||||
exit 0
|
exit 0
|
||||||
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 .#checks.x86_64-linux.switch-system \
|
||||||
--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 stable NixOS and Home Manager 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)
|
# Preserve an already staged generation on failure, not just the running one.
|
||||||
|
previous=$(readlink -f /nix/var/nix/profiles/system)
|
||||||
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" boot --no-reexec --store-path "$built"; then
|
||||||
echo 'Activation failed; restoring the previous system. See the journal.' >&2
|
echo 'Staging failed; restoring the previous boot generation. See the journal.' >&2
|
||||||
sudo "$previous/sw/bin/nixos-rebuild" switch --no-reexec --store-path "$previous"
|
sudo "$previous/sw/bin/nixos-rebuild" boot --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 (staged for next boot): $built"
|
||||||
# No forced reboot or garbage collection: recovery generations are retained.
|
# No forced reboot or garbage collection: recovery generations are retained.
|
||||||
|
|||||||
+34
-16
@@ -17,12 +17,15 @@ with open(os.environ["CALLS"], "a") as f:
|
|||||||
f.write(json.dumps([name, *args]) + "\n")
|
f.write(json.dumps([name, *args]) + "\n")
|
||||||
if name == "nix":
|
if name == "nix":
|
||||||
if args[:2] == ["flake", "update"]:
|
if args[:2] == ["flake", "update"]:
|
||||||
assert args[2:] == ["nixpkgs", "home-manager"]
|
assert args[2:] == ["nixpkgs", "home-manager", "nixpkgs-latest"]
|
||||||
if scenario != "unchanged":
|
if scenario != "unchanged":
|
||||||
pathlib.Path("flake.lock").write_text('{"revision":2}\n')
|
pathlib.Path("flake.lock").write_text('{"revision":2}\n')
|
||||||
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,19 @@ 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] == "boot":
|
||||||
|
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"]
|
assert args == ["-f", "/nix/var/nix/profiles/system"]
|
||||||
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"):
|
||||||
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 +57,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,
|
||||||
|
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 +83,49 @@ 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)
|
||||||
|
assert all(call[2] in ("dry-activate", "boot") for call in sudo), calls
|
||||||
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", "boot"]
|
||||||
|
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", "boot", "boot"]
|
||||||
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", "boot", "boot"]
|
||||||
|
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, "boot", scenario)
|
||||||
|
|
||||||
|
|
||||||
for scenario in ["dirty", "unchanged", "evaluation-failure", "build-failure", "concurrent-edit",
|
for host in ["dev", "nixos"]:
|
||||||
"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)
|
||||||
|
run_case("invalid-host", "not-a-host")
|
||||||
|
|||||||
+39
-11
@@ -1,21 +1,48 @@
|
|||||||
{ pkgs, ... }:
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
let
|
let
|
||||||
|
env = config.systemd.services.nixos-update.environment;
|
||||||
|
repo = env.NIXOS_CONFIG_REPO;
|
||||||
|
runtimeInputs = with pkgs; [
|
||||||
|
nix
|
||||||
|
git
|
||||||
|
coreutils
|
||||||
|
util-linux
|
||||||
|
];
|
||||||
updater = pkgs.writeShellApplication {
|
updater = pkgs.writeShellApplication {
|
||||||
name = "update-system";
|
name = "update-system";
|
||||||
runtimeInputs = with pkgs; [
|
inherit runtimeInputs;
|
||||||
nix
|
|
||||||
git
|
|
||||||
coreutils
|
|
||||||
util-linux
|
|
||||||
];
|
|
||||||
text = builtins.readFile ./update-system.sh;
|
text = builtins.readFile ./update-system.sh;
|
||||||
};
|
};
|
||||||
|
switcher = pkgs.writeShellApplication {
|
||||||
|
name = "switch-system";
|
||||||
|
inherit runtimeInputs;
|
||||||
|
text = ''
|
||||||
|
export NIXOS_CONFIG_REPO=${lib.escapeShellArg repo}
|
||||||
|
export NIXOS_UPDATE_HOST=${lib.escapeShellArg env.NIXOS_UPDATE_HOST}
|
||||||
|
${builtins.readFile ./switch-system.sh}
|
||||||
|
'';
|
||||||
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
environment.systemPackages = [ updater ];
|
environment.systemPackages = [
|
||||||
systemd.tmpfiles.rules = [ "d /var/cache/nixos-update 0700 dev users -" ];
|
updater
|
||||||
|
switcher
|
||||||
|
];
|
||||||
|
# Z does not follow symlinks: in /etc/nix, Nix-owned configuration links and
|
||||||
|
# result links never cause ownership changes in /etc/static or /nix/store.
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d ${repo} 0755 dev users -"
|
||||||
|
"Z ${repo} - dev users -"
|
||||||
|
"d /var/cache/nixos-update 0700 dev users -"
|
||||||
|
"Z /var/cache/nixos-update - 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 stage NixOS/tool updates for the next boot";
|
||||||
wants = [ "network-online.target" ];
|
wants = [ "network-online.target" ];
|
||||||
after = [ "network-online.target" ];
|
after = [ "network-online.target" ];
|
||||||
path = [ "/run/wrappers" ];
|
path = [ "/run/wrappers" ];
|
||||||
@@ -23,8 +50,9 @@ in
|
|||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
User = "dev";
|
User = "dev";
|
||||||
Group = "users";
|
Group = "users";
|
||||||
WorkingDirectory = "/etc/nixos";
|
WorkingDirectory = repo;
|
||||||
CacheDirectory = "nixos-update";
|
CacheDirectory = "nixos-update";
|
||||||
|
CacheDirectoryMode = "0700";
|
||||||
UMask = "0077";
|
UMask = "0077";
|
||||||
Nice = 10;
|
Nice = 10;
|
||||||
IOSchedulingClass = "idle";
|
IOSchedulingClass = "idle";
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{ pkgs, ... }:
|
{ pkgs, ... }:
|
||||||
|
|
||||||
{
|
{
|
||||||
|
# Preserve locally provisioned passwords on every host; never invent one.
|
||||||
|
users.mutableUsers = true;
|
||||||
users.users.dev = {
|
users.users.dev = {
|
||||||
isNormalUser = true;
|
isNormalUser = true;
|
||||||
uid = 1001;
|
uid = 1001;
|
||||||
@@ -32,7 +34,5 @@
|
|||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /home/dev/.config 0755 dev users -"
|
"d /home/dev/.config 0755 dev users -"
|
||||||
"d /home/dev/projects 0755 dev users -"
|
"d /home/dev/projects 0755 dev users -"
|
||||||
# Keep the working repo editable by dev. Z does not follow store symlinks.
|
|
||||||
"Z /etc/nixos - dev users -"
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=";
|
||||||
|
}
|
||||||
+36
-26
@@ -1,47 +1,57 @@
|
|||||||
# Opt-in physical workstation integration. Never imported by the EC2 host.
|
# Local hardware/session integration. Shared by the laptop and graphical VM,
|
||||||
|
# not the headless EC2 host; all application/user settings live in common.nix.
|
||||||
{
|
{
|
||||||
config,
|
config,
|
||||||
lib,
|
lib,
|
||||||
pkgs,
|
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
|
||||||
{
|
{
|
||||||
networking.networkmanager.enable = true;
|
networking.networkmanager = {
|
||||||
|
enable = true;
|
||||||
|
plugins = [ pkgs.networkmanager-openvpn ];
|
||||||
|
};
|
||||||
networking.dhcpcd.enable = false;
|
networking.dhcpcd.enable = false;
|
||||||
users.users.dev.extraGroups = [ "networkmanager" ];
|
users.users.dev.extraGroups = [ "networkmanager" ];
|
||||||
|
|
||||||
|
services.xserver.enable = true;
|
||||||
|
services.displayManager = {
|
||||||
|
sddm.enable = true;
|
||||||
|
defaultSession = "hyprland-uwsm";
|
||||||
|
# Plain Hyprland bypasses the UWSM-owned bar/idle/polkit services.
|
||||||
|
sessionPackages = lib.mkForce [ managedHyprlandSession ];
|
||||||
|
};
|
||||||
|
services.printing.enable = true;
|
||||||
|
services.udisks2.enable = true;
|
||||||
|
services.gvfs.enable = true;
|
||||||
|
services.fwupd.enable = true;
|
||||||
|
home-manager.users.dev.services.udiskie.enable = true;
|
||||||
|
environment.systemPackages = [ pkgs.networkmanagerapplet ];
|
||||||
|
|
||||||
hardware.bluetooth = {
|
hardware.bluetooth = {
|
||||||
enable = true;
|
enable = true;
|
||||||
powerOnBoot = false;
|
powerOnBoot = false;
|
||||||
};
|
};
|
||||||
services.blueman.enable = true;
|
services.blueman.enable = true;
|
||||||
# Keep Blueman's manager/mechanism, not a second tray applet next to ashell.
|
services.upower.enable = true;
|
||||||
|
services.power-profiles-daemon.enable = true;
|
||||||
|
# Retain Blueman's manager without a second tray applet next to ashell.
|
||||||
home-manager.users.dev.xdg.configFile."autostart/blueman.desktop".text = ''
|
home-manager.users.dev.xdg.configFile."autostart/blueman.desktop".text = ''
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=Blueman
|
Name=Blueman
|
||||||
Hidden=true
|
Hidden=true
|
||||||
'';
|
'';
|
||||||
services.upower.enable = true;
|
|
||||||
services.power-profiles-daemon.enable = true;
|
|
||||||
services.udisks2.enable = true;
|
|
||||||
home-manager.users.dev.services.udiskie.enable = true;
|
|
||||||
environment.systemPackages = [ pkgs.networkmanagerapplet ];
|
|
||||||
|
|
||||||
programs.regreet = {
|
|
||||||
enable = true;
|
|
||||||
theme.name = "Adwaita-dark";
|
|
||||||
font = {
|
|
||||||
package = pkgs.inter;
|
|
||||||
name = "Inter";
|
|
||||||
size = 13;
|
|
||||||
};
|
|
||||||
settings.GTK.application_prefer_dark_theme = true;
|
|
||||||
};
|
|
||||||
# No autologin. The physical host must supply a secure credential file.
|
|
||||||
assertions = [
|
|
||||||
{
|
|
||||||
assertion = config.users.users.dev.hashedPasswordFile != null;
|
|
||||||
message = "workstation.nix requires users.users.dev.hashedPasswordFile (provision outside the Nix store); no production password is invented.";
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user