Compare commits

..

2 Commits

Author SHA1 Message Date
ca93ac478e docs: remove Ariadne skeleton from Palworld PR 2026-09-10 14:37:35 -04:00
5337452caa feat(nixos): add Palworld Tracion guest 2026-09-10 14:36:45 -04:00
3306 changed files with 4220 additions and 759337 deletions

View File

@@ -1 +0,0 @@
.pi

View File

@@ -0,0 +1,287 @@
// installed by herdr
// managed by herdr; reinstalling or updating the integration overwrites this file.
// add custom hooks/plugins beside this file instead of editing it.
// HERDR_INTEGRATION_ID=pi
// HERDR_INTEGRATION_VERSION=6
// @ts-nocheck
import net from "node:net";
const HERDR_ENV = process.env.HERDR_ENV;
const socketPath = process.env.HERDR_SOCKET_PATH;
const socketEndpoint =
process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath;
const paneId = process.env.HERDR_PANE_ID;
const source = "herdr:pi";
function enabled() {
return HERDR_ENV === "1" && !!socketPath && !!paneId;
}
function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
if (!enabled()) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
let done = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
const finish = (delivered: boolean) => {
if (done) return;
done = true;
if (timeout) {
clearTimeout(timeout);
}
socket.destroy();
resolve(delivered);
};
const socket = net.createConnection(socketEndpoint!);
socket.on("error", () => finish(false));
socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
socket.on("data", () => finish(true));
socket.on("end", () => finish(false));
timeout = setTimeout(() => finish(false), timeoutMs);
timeout.unref?.();
});
}
async function sendRequest(request: unknown): Promise<void> {
if (await sendRequestAttempt(request, 500)) {
return;
}
await sendRequestAttempt(request, 1500);
}
type AgentState = "working" | "blocked" | "idle";
type QueuedState = {
state: AgentState;
message?: string;
seq: number;
};
let reportSeq = Date.now() * 1000;
let currentAgentSessionId: string | undefined;
let currentAgentSessionPath: string | undefined;
function nextReportSeq(): number {
reportSeq += 1;
return reportSeq;
}
function updateSessionRef(ctx: any): void {
try {
const file = ctx?.sessionManager?.getSessionFile?.();
currentAgentSessionPath =
typeof file === "string" && file.startsWith("/") ? file : undefined;
} catch {
currentAgentSessionPath = undefined;
}
try {
const id = ctx?.sessionManager?.getSessionId?.();
currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined;
} catch {
currentAgentSessionId = undefined;
}
}
function withSessionRef(params: Record<string, unknown>): Record<string, unknown> {
if (currentAgentSessionPath) {
return { ...params, agent_session_path: currentAgentSessionPath };
}
if (currentAgentSessionId) {
return { ...params, agent_session_id: currentAgentSessionId };
}
return params;
}
function currentSessionRef(): Record<string, unknown> | undefined {
if (currentAgentSessionPath) {
return { agent_session_path: currentAgentSessionPath };
}
if (currentAgentSessionId) {
return { agent_session_id: currentAgentSessionId };
}
return undefined;
}
function reportSession(sessionStartSource?: string): Promise<void> {
const sessionRef = currentSessionRef();
if (!sessionRef) {
return Promise.resolve();
}
return sendRequest({
id: `${source}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`,
method: "pane.report_agent_session",
params: {
pane_id: paneId,
source,
agent: "pi",
seq: nextReportSeq(),
session_start_source: sessionStartSource,
...sessionRef,
},
});
}
function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise<void> {
return sendRequest({
id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
method: "pane.report_agent",
params: withSessionRef({
pane_id: paneId,
source,
agent: "pi",
state,
message,
seq,
}),
});
}
function releaseAgent(): Promise<void> {
return sendRequest({
id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`,
method: "pane.release_agent",
params: {
pane_id: paneId,
source,
agent: "pi",
seq: nextReportSeq(),
},
});
}
function shouldReleaseOnSessionShutdown(event: any): boolean {
// Pi tears down and rebinds extension runtimes for internal lifecycle actions
// such as /reload, /new, /resume, and /fork. Those do not mean the pane's
// agent process has exited, and releasing hook authority there can suppress
// legitimate reports from the replacement runtime. Only a user/process quit
// should release Herdr's full-lifecycle authority.
const reason = event?.reason;
return reason === "quit";
}
let sendInFlight = false;
let queuedState: QueuedState | undefined;
function queueState(state: AgentState, message?: string): void {
queuedState = { state, message, seq: nextReportSeq() };
if (!sendInFlight) {
void drainStateQueue();
}
}
async function drainStateQueue(): Promise<void> {
if (sendInFlight) {
return;
}
sendInFlight = true;
try {
while (queuedState) {
const next = queuedState;
queuedState = undefined;
await sendState(next.state, next.message, next.seq);
}
} finally {
sendInFlight = false;
if (queuedState) {
void drainStateQueue();
}
}
}
export default function (pi) {
if (!enabled()) {
return;
}
let agentActive = false;
let blockedCount = 0;
let blockedMessage: string | undefined;
let lastState: AgentState | undefined;
let lastMessage: string | undefined;
let rootSession = false;
function desiredState() {
if (blockedCount > 0) {
return { state: "blocked" as const, message: blockedMessage };
}
if (agentActive) {
return { state: "working" as const, message: undefined };
}
return { state: "idle" as const, message: undefined };
}
function publishState(force = false) {
const next = desiredState();
if (!force && next.state === lastState && next.message === lastMessage) {
return;
}
lastState = next.state;
lastMessage = next.message;
queueState(next.state, next.message);
}
pi.events.on("herdr:blocked", (data) => {
if (!rootSession) {
return;
}
if (!data?.active) {
blockedCount = Math.max(0, blockedCount - 1);
if (blockedCount === 0) {
blockedMessage = undefined;
}
publishState();
return;
}
blockedCount += 1;
blockedMessage = data.label;
publishState();
});
pi.on("session_start", async (event, ctx) => {
if (ctx?.hasUI !== true) {
return;
}
rootSession = true;
updateSessionRef(ctx);
await reportSession(event?.reason);
// A reload can replace this extension mid-run without emitting another agent_start.
agentActive = ctx?.isIdle?.() === false;
publishState(true);
});
pi.on("agent_start", (_event, ctx) => {
if (!rootSession) {
return;
}
updateSessionRef(ctx);
void reportSession();
agentActive = true;
publishState();
});
pi.on("agent_settled", (_event, ctx) => {
if (!rootSession || ctx?.isIdle?.() !== true) {
return;
}
agentActive = false;
publishState();
});
pi.on("session_shutdown", async (event) => {
if (!rootSession) {
return;
}
if (shouldReleaseOnSessionShutdown(event)) {
await releaseAgent();
}
});
}

View File

@@ -1,52 +0,0 @@
---
description: Review changes against repository style rules
argument-hint: "[target]"
---
Review target: `${ARGUMENTS:-the staged diff}`.
Audit the target for violations of the repository style rules.
Choose skills from the reviewed files:
- Use the `nix-style` skill for `*.nix` files.
- Use the `rust-style` skill for `*.rs` files.
- Use the `build-style` skill for `Cargo.toml` files.
- Use the `build-style` skill for Bazel `BUILD` files.
If the target includes multiple matching file types, use all matching skills.
If the target is a diff, choose skills from the changed files.
Do not use a style skill when no reviewed file matches it.
For every reviewed `*.nix` file, run:
```bash
bazel run //src/natu:natu -- <reviewed-nix-paths>
```
Pass every reviewed Nix path to Natu.
Report every Natu diagnostic as an actionable finding.
Report only actionable findings.
Order findings by severity.
Do not report preferences unless a loaded rule states them.
Use this format for each finding:
```text
`code-path:line`
> `smallest reviewed-code excerpt that shows the violation`
`skill::rule-file:line`: Finding title.
Summary: why the code breaks the rule
Fix: suggested fix
```
Keep the code excerpt to one to three lines when possible.
Use a longer excerpt only when the violation needs surrounding context.
Use `...` to omit unrelated lines from a multi-line excerpt.
Use a style-skill rule citation in the form `skill::rule-file:line`.
Cite Natu diagnostics as `natu::<rule-id>`.
Make the fix direct.
Do not include alternatives when one fix best follows the cited rule.
It is acceptable to report no findings when the target follows the loaded rules.

View File

@@ -1 +0,0 @@
../../src/skills/style/build

View File

@@ -1 +0,0 @@
../../src/skills/craft-skill

View File

@@ -1 +0,0 @@
../../src/skills/style/nix

View File

@@ -1 +0,0 @@
../../src/skills/style/rust

View File

@@ -1 +0,0 @@
../../src/skills/to-document

View File

@@ -95,34 +95,10 @@
committing a top-down rewrite.
- `rules_rust` `rust_test` targets cannot set both `crate` and `srcs`.
Use one input style per test target.
- Rust tests in this monorepo live in separate `*_test.rs` files, not inline `#[cfg(test)]` modules.
- `rnix::ast::HasEntry::entries()` includes assignments and `inherit` statements.
Use `attrpath_values()` when code needs only assignment entries.
- Ariadne Rust tests are declared in Bazel `BUILD` files; do not add `[[test]]` entries to
`src/ariadne/Cargo.toml` unless explicitly requested.
- In this monorepo, "dotfiles" refers to the dotfiles/NixOS configuration under `src/dotfiles/` and `configs/nixos/`.
- The flake does not currently define `formatter.x86_64-linux`.
Do not use `nix fmt` as a validation command until a formatter is added.
- The `fix` app passes its current directory to Deadnix.
Run `nix run .#fix` from the repository root so path exclusions match.
- During the Nix refactor, address each issue that comes up by adding either a Natu rule or a new Nix style rule.
- Git pathspec searches started in a subdirectory default to that subtree.
Use `git -C <repository-root>` or top-anchored pathspecs for repository-wide history searches.
- A `**/*.nix` Git pathspec does not include Nix files directly in its starting directory.
Include both `*.nix` and `**/*.nix`, or use a recursive `*.nix` pathspec, when linting every Nix file.
- Nix `let` bindings are recursive; their source order does not determine binding availability.
Treat Natu `let_order` as a readability rule, not an evaluation dependency.
- Ariadne extraction must preserve the game-root season prefix for archived source.
Different game installs can contain compiled scripts with the same internal path.
- Clew owns Ren'Py extraction, preparation, and compiled-script AST export. Ariadne owns analysis over Clew-produced data.
- Bazel Python targets require `PYTHON_TOOLCHAIN_ROOT` to point at a Python installation root.
- When checking whether a package exists in nixpkgs, check both this repository's pinned nixpkgs inputs
and the relevant current upstream channel. A package can exist on current `nixos-unstable` while being
absent from the pinned revision or from a different branch such as `nixpkgs-unstable`.
- OMP does not discover project skills from `.pi/skills`.
Put skills shared by Pi and OMP under `.agents/skills`, or configure OMP's `skills.customDirectories`.
- Pi is vendored under `third_party/pi` as a linear vendor import.
Do not use `git subtree add` or `git subtree pull` for Pi PR branches.
Gitea rebases the synthetic upstream root commit without the `third_party/pi` prefix.
Preserve `git-subtree-dir` and `git-subtree-split` trailers on Pi vendor commits.
- The default Pi model for NixOS user configuration is set in `configs/nixos/modules/shell/pi/default.nix`, not in vendored Pi source.

187
Cargo.lock generated
View File

@@ -2,15 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "1.0.0"
@@ -66,22 +57,15 @@ name = "ariadne"
version = "0.1.0"
dependencies = [
"clap",
"monorepo",
"serde",
"serde_json",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "clap"
version = "4.6.7"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
dependencies = [
"clap_builder",
"clap_derive",
@@ -89,9 +73,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.6.7"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
dependencies = [
"anstream",
"anstyle",
@@ -101,21 +85,21 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.6.7"
version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.6",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
@@ -123,41 +107,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "countme"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "googletest"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b5e2f2b556b7b90297a5a35c8267dd43a537923d2b329beefdba2b4ec19d94"
dependencies = [
"googletest_macro",
"num-traits",
"regex",
"rustversion",
]
[[package]]
name = "googletest_macro"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ae6abc96141edd26bf5aeec0f119c129c44de3ced09e5073711a02cb74725d0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "heck"
version = "0.5.0"
@@ -186,31 +135,6 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "monorepo"
version = "0.1.0"
[[package]]
name = "natu"
version = "0.1.0"
dependencies = [
"clap",
"googletest",
"monorepo",
"rnix",
"rowan",
"status",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
@@ -235,68 +159,6 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rnix"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c163bd17372eecdf10d351c34584b7de7c1a33be4e92a32f3fb3f5a7fe3f579b"
dependencies = [
"rowan",
]
[[package]]
name = "rowan"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21"
dependencies = [
"countme",
"hashbrown",
"rustc-hash",
"text-size",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "serde"
version = "1.0.229"
@@ -324,7 +186,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"syn",
]
[[package]]
@@ -340,10 +202,6 @@ dependencies = [
"zmij",
]
[[package]]
name = "status"
version = "0.1.0"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -352,37 +210,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.119"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "text-size"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
[[package]]
name = "unicode-ident"
version = "1.0.26"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"

View File

@@ -2,18 +2,5 @@
members = [
"src/ariadne",
"src/machoke",
"src/monorepo",
"src/status",
"src/natu",
]
resolver = "3"
[workspace.dependencies]
clap = "4"
googletest = "0.14"
monorepo = { path = "src/monorepo" }
rnix = "=0.14.0"
rowan = "0.16"
serde = "1.0"
serde_json = "1.0"
status = { path = "src/status" }

View File

@@ -14,5 +14,4 @@ rust = use_extension("//bazel:rust.bzl", "rust")
use_repo(rust, "rust")
register_toolchains("@python//:python")
register_toolchains("@rust//:rust")

View File

@@ -13,8 +13,7 @@ dev/ Monorepo root
├── src/ Projects
│ └── dotfiles/ Home and system configuration
└── third_party/ External source and vendoring support
├── crates/ Generated Rust crate sources and Bazel targets
└── pi/ Vendored Pi source
└── crates/ Generated Rust crate sources and Bazel targets
```
## Rust dependencies
@@ -45,45 +44,3 @@ deps = ["//third_party/crates:crate_name"]
Commit the generated files under `third_party/crates/`.
Do not edit generated files manually.
## Vendored Pi
Pi source lives in `third_party/pi/`.
The directory is a linear vendor import from upstream Pi.
Local fork changes are carried directly in that vendored tree.
Use the upstream tag that matches the target CLI release.
The CLI version lives in `packages/coding-agent/package.json`.
Do not use `git subtree add` or `git subtree pull` for Pi PR branches.
Those commands create a synthetic upstream root commit.
Gitea rebase applies that root commit without the `third_party/pi` prefix.
That causes add/add conflicts on repository root files.
Update the vendor tree with a linear commit:
```sh
git fetch https://github.com/earendil-works/pi.git refs/tags/v<version>
split="$(git rev-parse FETCH_HEAD)"
rm -rf third_party/pi
mkdir -p third_party/pi
git archive --format=tar "$split" | tar -C third_party/pi -xf -
git add third_party/pi
git commit \
-m "chore(vendor): import pi <version>" \
-m "Imported as a linear vendor commit so forge rebases do not replay the upstream root tree." \
-m "git-subtree-dir: third_party/pi" \
-m "git-subtree-split: $split"
```
Keep the vendor import or update commit separate from local edits.
Do not remove the `git-subtree-dir` and `git-subtree-split` trailers.
They record the upstream source commit for future updates.
After the upstream update, resolve conflicts in `third_party/pi/`.
Keep local fork changes as ordinary commits after the vendor update.
Then verify the package:
```sh
nix build .#nixosConfigurations.zeus.config.home-manager.users.alexion.programs.pi-coding-agent.package --no-link --print-out-paths
```

View File

@@ -2,10 +2,10 @@
## Desktop
- When adding a graphical application, register its workspace icon in `registry.window-icon`.
- TODO: Verify this path after the NixOS configuration is migrated.
When adding a graphical application, add a `window-rewrite` icon mapping in `modules/desktop/session/waybar.nix`.
The workspace indicator uses this mapping to show an application icon instead of the generic default glyph.
Match the mapping on the window class.
Store the icon as the rendered string.
Run `hyprctl clients -j | jq -r '.[].class' | sort -u` to list classes in the running session.
- TODO: Verify this path after the NixOS configuration is migrated.
Set Hyprland session environment variables in `modules/desktop/session/hyprland/default.nix` with `env = KEY,VALUE`.
@@ -40,28 +40,13 @@
## Gotchas
- `configs/nixos/registry/stable-uid.nix` receives library functions through `lib`.
Qualify its library calls with `lib.`.
- Guest modules are host-agnostic wiring, while host files own deployment-specific service configuration such as served volumes and host paths.
For example, `guests/copyparty.nix` should expose options and translate them into guest mounts/service settings, while `hosts/pikachu/default.nix` chooses `/inbox`, `/media`, and their backing paths.
Guest addresses and routes are host-level configuration because a guest module can be used on multiple hosts.
- `sops.secrets.<name>.owner` must name a user known on the decrypting host.
Do not set the owner to a guest-only service user unless the host also declares that user.
Use a host-visible owner plus a shared group mode when the guest service can read the decrypted secret file through its group.
- A read-only guest bind mount cannot contain nested writable bind mounts unless their mount-point
directories already exist inside the read-only source.
For aggregate Copyparty volumes such as `/srv` with nested `/srv/inbox`, keep the guest bind mount
writable and enforce read-only behavior through Copyparty access plus service `ReadWritePaths`.
- `deploy-to` builds from the source checkout, records the imported closure as the target system profile,
and activates it. Do not rely on a target-local monorepo checkout for remote deployment.
- On Pikachu, `nix copy --to ssh://pikachu` accepted a small `nix store add-file` probe,
but rejected locally built system paths that lacked trusted signatures.
Use a sudo `nix-store --import` path for unsigned deploy closures.
- Public DNS entries are not managed through the NixOS configuration.
Do not describe NixOS route choices as public-DNS management.
- Pikachu deploys from the monorepo checkout copied to `~/dev`.
Do not use the old `~/wrk/dotfiles` path for remote rebuild commands.
- Do not hand-author `hardware-configuration.nix` for a host that has not been generated on that machine.
Generate it on the target system or live ISO, then stage it before evaluating the flake.
- Configure the Chaotic Nyx cache only on hosts that select `linuxPackages_cachyos` (currently Neogaia
and Zeus), not in the generic host module.
- Module-relative paths resolve from the module file. Update them when moving a module into a subdirectory.
- On Pikachu, `uptime` does not support `-s`. Use `who -b` to report the boot time.

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.guests.actualbudget; # NOLINT(let_order)
cfg = config.guests.actualbudget;
dataDir = "/data";
in
{
@@ -25,14 +25,6 @@ in
}
];
registry.homepage.${config.networking.hostName}.actualbudget = {
tab = "Apps";
section = "Other";
name = "Actual Budget";
description = "Finance";
icon = "actual-budget.png";
};
guests.actualbudget = {
interior = {
modules.services.actualbudget = {

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.guests.copyparty; # NOLINT(let_order)
cfg = config.guests.copyparty;
volumeType = lib.types.submodule {
options = {
@@ -24,12 +24,6 @@ let
description = "Copyparty access map from permission string to account names.";
};
readOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Serve this Copyparty volume read-only.";
};
flags = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
@@ -90,15 +84,6 @@ in
}
];
registry.homepage.${config.networking.hostName}.copyparty = {
tab = "Apps";
section = "Files";
name = "Copyparty";
description = "Files on ${config.networking.hostName}";
icon = "copyparty.svg";
href = lib.mkIf cfg.reverseProxy.enable "https://${cfg.reverseProxy.route.domain}/?h";
};
guests.copyparty = {
interior.modules.services.copyparty = {
enable = true;
@@ -109,36 +94,31 @@ in
inherit (cfg.reverseProxy) trustedSources;
};
volumes = lib.mapAttrs (_urlPath: volume: {
inherit (volume)
path
access
flags
readOnly
;
inherit (volume) path access flags;
}) cfg.volumes;
};
mounts = {
${cfg.stateDir} = {
directory = cfg.statePath;
owner = config.registry."stable-uid".${config.networking.hostName}.copyparty;
group = "storage";
mode = "0770";
};
${"/run/secrets/${cfg.passwordSecret}"} = {
file = config.sops.secrets.${cfg.passwordSecret}.path;
readOnly = true;
};
}
// lib.mapAttrs' (
_urlPath: volume:
lib.nameValuePair volume.path {
directory = volume.hostPath;
owner = "root";
group = "storage";
mode = "2770";
mounts =
{
${cfg.stateDir} = {
directory = cfg.statePath;
owner = config.registry."stable-uid".${config.networking.hostName}.copyparty;
group = "storage";
mode = "0770";
};
${"/run/secrets/${cfg.passwordSecret}"} = {
file = config.sops.secrets.${cfg.passwordSecret}.path;
readOnly = true;
};
}
) cfg.volumes;
// lib.mapAttrs' (_urlPath: volume:
lib.nameValuePair volume.path {
directory = volume.hostPath;
owner = "root";
group = "storage";
mode = "2770";
}
) cfg.volumes;
limits = {
memory = lib.mkDefault "2G";
cpu = lib.mkDefault "200%";

View File

@@ -1,46 +0,0 @@
{
config,
lib,
...
}:
let
cfg = config.guests.homepage; # NOLINT(let_order)
environmentFile = "/run/secrets/homepage.env";
in
{
config = lib.mkIf cfg.enable {
guests.homepage = {
interior.modules.services.homepage = {
enable = true;
hostName = config.networking.hostName;
inherit environmentFile;
};
mounts.${environmentFile} = {
file = config.sops.templates."homepage.env".path;
readOnly = true;
};
limits = {
memory = lib.mkDefault "1G";
cpu = lib.mkDefault "100%";
tasksMax = lib.mkDefault 256;
};
};
sops.secrets = {
"services/metadata/latitude" = { };
"services/metadata/longitude" = { };
};
sops.templates."homepage.env" = {
owner = "root";
group = "storage";
mode = "0440";
content = ''
HOMEPAGE_VAR_LATITUDE=${config.sops.placeholder."services/metadata/latitude"}
HOMEPAGE_VAR_LONGITUDE=${config.sops.placeholder."services/metadata/longitude"}
'';
};
};
}

View File

@@ -1,103 +0,0 @@
{
config,
lib,
...
}:
let
guestCfg = config.guests.navidrome;
serviceCfg = config.containers.navidrome.config.modules.services.navidrome;
in
{
options.guests.navidrome = {
dataPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/navidrome";
description = "Host path mounted as Navidrome's data folder.";
};
libraryPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/music";
description = "Host path mounted as Navidrome's library folder.";
};
lastfmApiKeySecret = lib.mkOption {
type = lib.types.str;
default = "services/navidrome/lastfm/api-key";
description = "SOPS secret name containing the Navidrome Last.fm API key.";
};
lastfmSecretSecret = lib.mkOption {
type = lib.types.str;
default = "services/navidrome/lastfm/secret";
description = "SOPS secret name containing the Navidrome Last.fm secret.";
};
};
config = lib.mkIf guestCfg.enable {
assertions = [
{
assertion = guestCfg.dataPath != null;
message = "guests.navidrome.dataPath must point at the host directory that stores Navidrome data.";
}
{
assertion = guestCfg.libraryPath != null;
message = "guests.navidrome.libraryPath must point at the host directory that stores Navidrome's library folder.";
}
];
registry.homepage.${config.networking.hostName}.navidrome = {
tab = "Apps";
section = "Media";
name = "Navidrome";
description = "Music";
icon = "navidrome.png";
};
guests.navidrome = {
interior.modules.services.navidrome.enable = true;
mounts = {
${serviceCfg.dataFolder} = {
directory = guestCfg.dataPath;
owner = config.registry."stable-uid".${config.networking.hostName}.navidrome;
group = "storage";
mode = "2770";
};
${serviceCfg.musicFolder} = {
directory = guestCfg.libraryPath;
owner = "root";
group = "storage";
mode = "2770";
};
${serviceCfg.environmentFile} = {
file = config.sops.templates."navidrome.env".path;
readOnly = true;
};
};
limits = {
memory = lib.mkDefault "2G";
cpu = lib.mkDefault "200%";
tasksMax = lib.mkDefault 512;
};
};
sops.secrets = {
${guestCfg.lastfmApiKeySecret}.sopsFile = ../secrets + "/${config.networking.hostName}.yaml";
${guestCfg.lastfmSecretSecret}.sopsFile = ../secrets + "/${config.networking.hostName}.yaml";
};
sops.templates."navidrome.env" = {
owner = "root";
group = "storage";
mode = "0440";
content = ''
ND_LASTFM_APIKEY=${config.sops.placeholder.${guestCfg.lastfmApiKeySecret}}
ND_LASTFM_SECRET=${config.sops.placeholder.${guestCfg.lastfmSecretSecret}}
'';
};
};
}

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.guests.palworld; # NOLINT(let_order)
cfg = config.guests.palworld;
steamDir = "/data/steamcmd";
installDir = "/data/server";
stateDir = "/data/state";
@@ -50,12 +50,6 @@ in
description = "Public IP address advertised by the Palworld server.";
};
autoStart = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Start the Palworld server after the guest reaches network readiness.";
};
passwordSecret = lib.mkOption {
type = lib.types.str;
default = "services/game-servers/password";
@@ -88,47 +82,43 @@ in
guests.palworld = {
interior.modules.services.games.palworld = {
enable = true;
inherit (cfg)
serverName
port
publicIP
autoStart
;
inherit (cfg) serverName port publicIP;
inherit steamDir installDir stateDir;
passwordFile = "/run/secrets/${cfg.passwordSecret}";
adminPasswordFile = "/run/secrets/${cfg.adminPasswordSecret}";
};
mounts = {
${steamDir} = {
directory = cfg.steamPath;
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = "storage";
mode = "0770";
mounts =
{
${steamDir} = {
directory = cfg.steamPath;
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = "storage";
mode = "0770";
};
${installDir} = {
directory = cfg.installPath;
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = "storage";
mode = "0770";
};
${stateDir} = {
directory = cfg.statePath;
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = "storage";
mode = "0770";
};
${"/run/secrets/${cfg.passwordSecret}"} = {
file = config.sops.secrets.${cfg.passwordSecret}.path;
readOnly = true;
};
}
// lib.optionalAttrs (cfg.adminPasswordSecret != cfg.passwordSecret) {
${"/run/secrets/${cfg.adminPasswordSecret}"} = {
file = config.sops.secrets.${cfg.adminPasswordSecret}.path;
readOnly = true;
};
};
${installDir} = {
directory = cfg.installPath;
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = "storage";
mode = "0770";
};
${stateDir} = {
directory = cfg.statePath;
owner = config.registry."stable-uid".${config.networking.hostName}.steamcmd;
group = "storage";
mode = "0770";
};
${"/run/secrets/${cfg.passwordSecret}"} = {
file = config.sops.secrets.${cfg.passwordSecret}.path;
readOnly = true;
};
}
// lib.optionalAttrs (cfg.adminPasswordSecret != cfg.passwordSecret) {
${"/run/secrets/${cfg.adminPasswordSecret}"} = {
file = config.sops.secrets.${cfg.adminPasswordSecret}.path;
readOnly = true;
};
};
limits = {
memory = lib.mkDefault "8G";
@@ -137,19 +127,20 @@ in
};
};
sops.secrets = {
${cfg.passwordSecret} = {
owner = "root";
group = "storage";
mode = "0440";
sops.secrets =
{
${cfg.passwordSecret} = {
owner = "root";
group = "storage";
mode = "0440";
};
}
// lib.optionalAttrs (cfg.adminPasswordSecret != cfg.passwordSecret) {
${cfg.adminPasswordSecret} = {
owner = "root";
group = "storage";
mode = "0440";
};
};
}
// lib.optionalAttrs (cfg.adminPasswordSecret != cfg.passwordSecret) {
${cfg.adminPasswordSecret} = {
owner = "root";
group = "storage";
mode = "0440";
};
};
};
}

View File

@@ -23,6 +23,11 @@ in
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"music.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"photos.alexion.dev" = {
forceSSL = true;
enableACME = true;

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.guests.valheim; # NOLINT(let_order)
cfg = config.guests.valheim;
steamDir = "/data/steamcmd";
installDir = "/data/server";
stateDir = "/data/state";

View File

@@ -1,6 +1,6 @@
{
inputs,
util,
pkgs,
...
}:
# neogaia — Dell XPS 13 9380 laptop.
@@ -13,9 +13,10 @@
./disk.nix
];
modules.host.cachyos.enable = true;
boot.kernelPackages = pkgs.linuxPackages_cachyos;
modules.network.enable = true;
# So wifi can be joined from the console.
networking.networkmanager.enable = true;
registry.address.neogaia.self = "10.23.50.146";
@@ -27,9 +28,15 @@
peer = {
publicKey = "sGEkSD+gEdIMXi4PBN3AhwHGc4tWMtcjOIey3s7yb3I=";
endpoint = "100.0.90.192:51820";
allowedIPs = [ "10.23.0.0/16" ];
};
};
registry.shell-shortcut.neogaia = {
vpn-on = "sudo systemctl start wireguard-wg-home.target";
vpn-off = ''sudo systemctl stop 'wireguard-wg-home*.service' wireguard-wg-home.target'';
};
modules.monorepo.enable = true;
modules.shell.herdr.enable = true;
modules.shell.gitea-axi.enable = true;
@@ -40,23 +47,4 @@
modules.desktop.apps.obsidian.enable = true;
modules.desktop.apps.steam.enable = true;
registry.waybar.neogaia.battery = {
order = 70;
settings = {
format = "{icon} {capacity}%";
format-charging = "${util.glyph "f0e7"} {capacity}%";
format-icons = [
(util.glyph "f244")
(util.glyph "f243")
(util.glyph "f242")
(util.glyph "f241")
(util.glyph "f240")
];
states = {
warning = 20;
critical = 10;
};
};
};
}

View File

@@ -1,4 +1,4 @@
_:
{ ... }:
# neogaia's disk layout for disko: one NVMe disk, GPT, with an EFI system
# partition and a LUKS container holding btrfs subvolumes.
# No swap partition, since swap is zram.

View File

@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia

View File

@@ -15,15 +15,52 @@
hardware.cpu.intel.updateMicrocode = true;
modules.network.vlans = {
interface = "enp2s0";
ids = [ 20 ];
systemd.network = {
enable = true;
netdevs = {
"40-br-vlan20".netdevConfig = {
Name = "br-vlan20";
Kind = "bridge";
};
"40-vlan20" = {
netdevConfig = {
Name = "vlan20";
Kind = "vlan";
};
vlanConfig.Id = 20;
};
};
networks = {
"10-uplink" = {
matchConfig = {
Name = "enp2s0";
MACAddress = "78:55:36:07:af:49";
};
networkConfig.DHCP = "yes";
vlan = [ "vlan20" ];
linkConfig.RequiredForOnline = "routable";
};
"40-vlan20" = {
matchConfig.Name = "vlan20";
networkConfig.Bridge = "br-vlan20";
linkConfig.RequiredForOnline = "no";
};
"40-br-vlan20" = {
matchConfig.Name = "br-vlan20";
linkConfig.RequiredForOnline = "no";
};
};
};
networking.useDHCP = false;
modules.network.vlans.ids = [ 20 ];
boot.zfs.forceImportRoot = false;
modules.host.zfs = {
modules.zfs = {
enable = true;
hostId = "2346edbd";
pools.pikachu = { };
};
@@ -41,8 +78,7 @@
enable = true;
route = {
domain = "valheim.alexion.dev";
protocol = "udp";
ports = [
udp.ports = [
config.guests.valheim.port
(config.guests.valheim.port + 1)
];
@@ -58,15 +94,13 @@
installPath = "/pikachu/data/servers/palworld/tracion";
statePath = "/pikachu/media/games/servers/palworld/tracion";
serverName = "Tracion";
autoStart = false;
adminPasswordSecret = "services/game-servers/admin/password";
publicIP = config.guests.palworld.address;
reverseProxy = {
enable = true;
route = {
domain = "palworld.alexion.dev";
protocol = "udp";
ports = [ config.guests.palworld.port ];
udp.ports = [ config.guests.palworld.port ];
};
};
};
@@ -81,29 +115,11 @@
enable = true;
route = {
domain = "budget.alexion.dev";
protocol = "https";
port = 5006;
};
};
};
guests.navidrome = {
enable = true;
vlan = 20;
mac = "BC:24:11:C9:17:83";
dataPath = "/pikachu/data/navidrome";
libraryPath = "/pikachu/media/music";
address = "10.23.20.43";
reverseProxy = {
enable = true;
route = {
domain = "music.alexion.dev";
protocol = "https";
port = 4533;
};
};
};
guests.reverse-proxy = {
enable = true;
vlan = 20;
@@ -111,20 +127,6 @@
address = "10.23.20.109";
};
guests.homepage = {
enable = true;
vlan = 20;
address = "10.23.20.110";
reverseProxy = {
enable = true;
route = {
domain = "home";
protocol = "http";
port = 8082;
};
};
};
guests.copyparty = {
enable = true;
vlan = 20;
@@ -135,22 +137,14 @@
enable = true;
route = {
domain = "files.alexion.dev";
protocol = "https";
port = 3923;
clientMaxBodySize = "1g";
};
trustedSources = [ "${registry.address.${config.networking.hostName}."reverse-proxy"}/32" ];
};
volumes = {
"/" = {
hostPath = "/pikachu/data/copyparty/root";
path = "/srv";
access.r = [ "alexion" ];
readOnly = true;
};
"/inbox" = {
hostPath = "/pikachu/__inbox";
path = "/pikachu/__inbox";
path = "/srv/inbox";
access.A = [ "alexion" ];
flags = [
"d2d"
@@ -160,7 +154,7 @@
};
"/media" = {
hostPath = "/pikachu/media";
path = "/pikachu/media";
path = "/srv/media";
access.A = [ "alexion" ];
flags = [
"d2d"
@@ -168,13 +162,6 @@
"dthumb"
];
};
"/opds" = {
hostPath = "/pikachu/data/opds";
path = "/pikachu/data/opds";
access.r = [ "alexion" ];
readOnly = true;
flags = [ "opds" ];
};
};
};

View File

@@ -1,4 +1,4 @@
_:
{ ... }:
# pikachu's install layout for disko: one NVMe boot disk with an EFI system partition and ext4 root.
# The existing 8 TB ZFS mirror is imported by name and is never declared here.
{

View File

@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu

View File

@@ -1,32 +1,25 @@
{
config,
lib,
pkgs,
...
}:
# zeus — Ryzen desktop with AMD graphics.
# Disk layout is in ./disk.nix.
# `fileSystems` are derived from it, none declared here.
{
imports = [
./disk.nix
]
++ lib.optional (builtins.pathExists ./hardware-configuration.nix) ./hardware-configuration.nix;
imports = [ ./disk.nix ] ++ lib.optional (builtins.pathExists ./hardware-configuration.nix) ./hardware-configuration.nix;
nixpkgs.hostPlatform = "x86_64-linux";
modules.host.cachyos.enable = true;
boot.kernelPackages = pkgs.linuxPackages_cachyos;
hardware.graphics = {
enable = true;
enable32Bit = true;
};
modules.network.enable = true;
services.logind.settings.Login = {
HandlePowerKey = "ignore";
HandlePowerKeyLongPress = "ignore";
};
networking.networkmanager.enable = true;
registry.address.zeus.self = "10.23.50.221";
@@ -39,29 +32,16 @@
modules.shell.herdr.enable = true;
modules.shell.gitea-axi.enable = true;
modules.shell.pi.enable = true;
modules.shell.omp.enable = true;
modules.desktop.enable = true;
modules.desktop.apps.obsidian.enable = true;
modules.desktop.apps.r2modman.enable = true;
modules.desktop.apps.signal.enable = true;
modules.desktop.apps.steam.enable = true;
modules.desktop.apps.vencord.enable = true;
home-manager.users.${config.user.name}.wayland.windowManager.hyprland.settings.monitor = [
{
output = "HDMI-A-1";
mode = "1920x1080@120";
position = "-1080x0";
scale = 1;
transform = 3;
}
{
output = "DP-3";
mode = "2560x1440@239.96";
position = "0x0";
scale = 1;
}
"HDMI-A-1,1920x1080@120,0x0,1,transform,3"
"DP-3,2560x1440@239.96,1080x0,1"
];
}

View File

@@ -1,4 +1,4 @@
_:
{ ... }:
# zeus's disk layout for disko: two NVMe disks with one EFI system partition
# and two LUKS containers joined into one btrfs filesystem.
# No swap partition, since swap is zram.

View File

@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIRBcHHuubptXCdvED/p2dR3MpjGN/AimqLasLY5984G alexion@zeus

View File

@@ -1,10 +1,14 @@
{
config,
lib,
pkgs,
inputs,
...
}:
# The shared foundation for every host and guest.
let
stateVersion = "26.05";
in
{
imports = [ inputs.home-manager.nixosModules.home-manager ];
@@ -13,14 +17,14 @@
type = lib.types.str;
default = "alexion";
description = ''
Names the primary user account.
Host configuration uses this name for the Home Manager user.
Primary interactive user for this system.
This drives the system account and Home Manager user together.
'';
};
};
config = {
system.stateVersion = "26.05";
system.stateVersion = stateVersion;
nixpkgs.config.allowUnfree = true;
nix.settings.experimental-features = [
@@ -38,7 +42,66 @@
users.groups.storage.gid = 10000;
security.sudo.extraConfig = ''
Defaults env_keep += "EDITOR VISUAL"
'';
time.timeZone = "America/New_York";
i18n.defaultLocale = "en_GB.UTF-8";
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
users.${config.user.name} = {
home.username = config.user.name;
home.homeDirectory = "/home/${config.user.name}";
home.stateVersion = stateVersion;
home.packages = with pkgs; [
eza
wget
];
};
};
registry.shell-shortcut.${config.networking.hostName} = {
ls = {
command = "eza -alg --color=always --group-directories-first --icons=always";
abbreviation = false;
};
ll = {
command = "eza -lg --color=always --group-directories-first --icons=always";
abbreviation = false;
};
lt = {
command = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
abbreviation = false;
};
".." = "cd ..";
"..." = "cd ../..";
"...." = "cd ../../..";
cp = "cp -v";
tarnow = "tar -acf ";
untar = "tar -zxvf ";
wget = "wget -c ";
grep = "grep --color=auto";
fgrep = "fgrep --color=auto";
egrep = "egrep --color=auto";
jctl = "journalctl -p 3 -xb";
update = "sudo nixos-rebuild switch --flake .#${config.networking.hostName}";
cleanup = "sudo nix-collect-garbage -d";
sops-host = {
command = "sudo --preserve-env=EDITOR,VISUAL env SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt nix shell nixpkgs#sops nixpkgs#neovim -c sops";
abbreviation = false;
};
};
modules.shell.fish.enable = lib.mkDefault true;
modules.shell.fish.defaultShell = lib.mkDefault true;
modules.shell.tmux.enable = lib.mkDefault true;
modules.shell.nvim.enable = lib.mkDefault true;
modules.shell.git.enable = lib.mkDefault true;
modules.shell.yazi.enable = lib.mkDefault true;
};
}

View File

@@ -0,0 +1,18 @@
{
config,
lib,
pkgs,
...
}:
# Alacritty as the desktop terminal.
let
cfg = config.modules.desktop.apps.alacritty;
user = config.user.name;
in
{
options.modules.desktop.apps.alacritty.enable = lib.mkEnableOption "Alacritty as the desktop terminal";
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.alacritty ];
};
}

View File

@@ -3,12 +3,13 @@
inputs,
lib,
pkgs,
util,
...
}:
# Configures Firefox as the desktop browser.
# Firefox as the desktop browser: stock mainline, hardened and de-monetized by policy.
let
cfg = config.modules.desktop.apps.firefox; # NOLINT(let_order)
cfg = config.modules.desktop.apps.firefox;
user = config.user.name;
firefoxAddons = inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system};
in
{
options.modules.desktop.apps.firefox = {
@@ -18,25 +19,19 @@ in
type = lib.types.str;
default = "1.0";
description = ''
Scales Firefox UI and page content.
Sets `layout.css.devPixelsPerPx`.
Use a larger value for high-DPI displays.
Firefox UI and page scale, passed to `layout.css.devPixelsPerPx`.
Override it on hosts that need a larger browser scale, such as small
high-DPI laptop panels.
'';
};
};
config = lib.mkIf cfg.enable {
registry.window-icon.${config.networking.hostName}.firefox = {
class = ".*[Ff]irefox.*";
icon = util.glyph "f269";
};
home-manager.users.${config.user.name} = hm: {
home-manager.users.${user} = hm: {
programs.firefox = {
enable = true;
# Applies policies that users cannot change in Firefox.
# Enterprise policies: enforced and unchangeable from the browser UI.
policies = {
DisableTelemetry = true;
DisableFirefoxStudies = true;
@@ -45,7 +40,7 @@ in
DontCheckDefaultBrowser = true;
DisableFirefoxAccounts = true;
# Removes sponsored content from the new-tab page.
# Strip the monetized surfaces from the new-tab page.
FirefoxHome = {
SponsoredTopSites = false;
SponsoredPocket = false;
@@ -54,19 +49,23 @@ in
};
profiles.default = {
isDefault = true;
extensions = {
packages = with inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system}; [
packages = with firefoxAddons; [
ublock-origin
proton-pass
sponsorblock
];
# Home Manager overwrites Nord theme changes made in Firefox.
# The Nord chrome theme is a declared extension setting, so home-manager
# owns the extension-settings store, overwriting runtime changes to it.
force = true;
};
# Stylix uses a near-white selection background.
# Use the Nord selection color because its text stays readable.
# Stylix's Nord mapping paints the selected address-bar result a
# near-white grey, leaving its light text unreadable. Darken that one
# highlight to the Nord selection grey, from the same scheme.
extensions.settings."FirefoxColor@mozilla.com".settings.theme.colors.popup_highlight =
let
c = hm.config.lib.stylix.colors;
@@ -78,26 +77,32 @@ in
};
settings = {
# Scale the UI and page by a fixed factor.
# Left at auto (-1), Firefox reads the panel's 1.5x and inflates its
# whole chrome while point-sized apps stay put.
# A shade under that brings it into line without dropping to true
# 1:1, which reads too small at this DPI.
"layout.css.devPixelsPerPx" = cfg.uiScale;
# Enables Firefox Color after Firefox sideloads the add-on.
# Auto-enable the sideloaded Firefox Color add-on carrying the Nord
# chrome theme, which Firefox otherwise leaves disabled.
"extensions.autoDisableScopes" = 0;
# Removes sponsored content outside Firefox policies.
# Sponsored surfaces the policies above do not reach.
"browser.urlbar.suggest.quicksuggest.sponsored" = false;
"browser.newtabpage.activity-stream.showSponsored" = false;
"browser.newtabpage.activity-stream.showSponsoredTopSites" = false;
};
search = {
# Home Manager replaces the engine list when this setting is enabled.
# Engines added in Firefox do not survive a rebuild.
# Declaring search overwrites Firefox's own engine list wholesale,
# so engines added later in the UI do not survive a rebuild.
force = true;
default = "ddg";
# Hides commercial search engines.
# Bangs still reach these engines.
# Entries with only `metaData` configure Firefox built-in engines.
# The general-purpose commercial engines, hidden to leave a lean
# DuckDuckGo-and-Wikipedia list. They stay reachable through bangs.
# An engine carrying only metaData is treated as a builtin.
engines = {
google.metaData.hidden = true;
bing.metaData.hidden = true;
@@ -109,7 +114,8 @@ in
};
};
# Applies the Nord theme through Stylix's Firefox Color add-on.
# Nord chrome for the one profile, applied through the Stylix-managed
# Firefox Color add-on that colorTheme enables.
stylix.targets.firefox = {
enable = true;
profileNames = [ "default" ];
@@ -127,8 +133,8 @@ in
};
};
# Firefox creates `profiles.ini` on its first launch.
# Force replacement during Home Manager activation.
# Firefox writes profiles.ini itself on first launch, so home-manager is
# told to own the file rather than fail activation refusing to clobber it.
home.file."${hm.config.programs.firefox.configPath}/profiles.ini".force = true;
};
};

View File

@@ -2,30 +2,17 @@
config,
lib,
pkgs,
util,
...
}:
# Ghostty as the desktop terminal.
let
cfg = config.modules.desktop.apps.ghostty;
user = config.user.name;
in
{
options.modules.desktop.apps.ghostty.enable = lib.mkEnableOption "Ghostty as the desktop terminal";
config = lib.mkIf config.modules.desktop.apps.ghostty.enable {
registry.keybind.${config.networking.hostName}.ghostty = {
keys = "SUPER + Return";
action = {
kind = "exec";
command = lib.getExe pkgs.ghostty;
};
};
registry.window-icon.${config.networking.hostName}.ghostty = {
class = "com.mitchellh.ghostty";
icon = util.glyph "f120";
};
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.ghostty ];
};
}

View File

@@ -2,23 +2,17 @@
config,
lib,
pkgs,
util,
...
}:
# Obsidian as the desktop note-taking vault.
let
cfg = config.modules.desktop.apps.obsidian;
user = config.user.name;
in
{
options.modules.desktop.apps.obsidian.enable =
lib.mkEnableOption "Obsidian as the desktop note-taking vault";
options.modules.desktop.apps.obsidian.enable = lib.mkEnableOption "Obsidian as the desktop note-taking vault";
config = lib.mkIf config.modules.desktop.apps.obsidian.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.obsidian ];
registry.window-icon.${config.networking.hostName}.obsidian = {
class = "obsidian";
icon = util.glyph "f02d";
};
};
}

View File

@@ -1,18 +0,0 @@
{
config,
lib,
pkgs,
...
}:
# r2modman, a Thunderstore mod manager.
let
user = config.user.name;
in
{
options.modules.desktop.apps.r2modman.enable =
lib.mkEnableOption "r2modman, a Thunderstore mod manager";
config = lib.mkIf config.modules.desktop.apps.r2modman.enable {
home-manager.users.${user}.home.packages = [ pkgs.r2modman ];
};
}

View File

@@ -2,22 +2,17 @@
config,
lib,
pkgs,
util,
...
}:
# Signal desktop messenger.
let
cfg = config.modules.desktop.apps.signal;
user = config.user.name;
in
{
options.modules.desktop.apps.signal.enable = lib.mkEnableOption "Signal desktop messenger";
config = lib.mkIf config.modules.desktop.apps.signal.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.signal-desktop ];
registry.window-icon.${config.networking.hostName}.signal = {
class = "[Ss]ignal";
icon = util.glyph "f4ad";
};
};
}

View File

@@ -2,16 +2,16 @@
config,
lib,
pkgs,
util,
...
}:
# Steam game launcher and runtime integration.
let
cfg = config.modules.desktop.apps.steam;
in
{
options.modules.desktop.apps.steam.enable = lib.mkEnableOption "Steam game launcher";
config = lib.mkIf config.modules.desktop.apps.steam.enable {
programs.gamemode.enable = true;
config = lib.mkIf cfg.enable {
programs.steam = {
enable = true;
package = pkgs.steam.override {
@@ -20,14 +20,5 @@
};
hardware.steam-hardware.enable = true;
environment.systemPackages = [
pkgs.gamescope
];
registry.window-icon.${config.networking.hostName}.steam = {
class = "[Ss]team";
icon = util.glyph "f1b6";
};
};
}

View File

@@ -2,23 +2,17 @@
config,
lib,
pkgs,
util,
...
}:
# Vesktop, a Discord client with Vencord built in.
let
cfg = config.modules.desktop.apps.vencord;
user = config.user.name;
in
{
options.modules.desktop.apps.vencord.enable =
lib.mkEnableOption "Vesktop, a Discord client with Vencord built in";
options.modules.desktop.apps.vencord.enable = lib.mkEnableOption "Vesktop, a Discord client with Vencord built in";
config = lib.mkIf config.modules.desktop.apps.vencord.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.vesktop ];
registry.window-icon.${config.networking.hostName}.vesktop = {
class = "[Vv]esktop";
icon = util.glyph "f1ff";
};
};
}

View File

@@ -1,11 +1,14 @@
{ config, lib, ... }:
# The desktop aggregator: one flag brings up the whole graphical session.
let
cfg = config.modules.desktop;
in
{
options.modules.desktop.enable = lib.mkEnableOption "the keyboard-driven Hyprland desktop";
# Each piece is turned on at default priority, so a host can still override
# any one of them while the single flag above enables the whole desktop.
config = lib.mkIf config.modules.desktop.enable {
config = lib.mkIf cfg.enable {
modules.desktop.apps.firefox.enable = lib.mkDefault true;
modules.desktop.apps.ghostty.enable = lib.mkDefault true;
modules.desktop.session.hyprland.enable = lib.mkDefault true;
@@ -23,6 +26,5 @@
modules.desktop.utilities.screenshot.enable = lib.mkDefault true;
modules.desktop.utilities.theming.enable = lib.mkDefault true;
modules.desktop.utilities.userdirs.enable = lib.mkDefault true;
registry.env.${config.networking.hostName}.NIXOS_OZONE_WL = "1";
};
}

View File

@@ -1,15 +1,41 @@
{
config,
lib,
util,
pkgs,
...
}:
# The Hyprland compositor.
let
cfg = config.modules.desktop.session.hyprland;
user = config.user.name;
cursor = config.stylix.cursor;
# Numbered-workspace switch and move for 1..9, the operator's i3 muscle memory.
workspaceBinds = lib.concatMap (n: [
"$mod, ${toString n}, workspace, ${toString n}"
"$mod SHIFT, ${toString n}, movetoworkspace, ${toString n}"
]) (lib.range 1 9);
# Flip the tiling strategy between the two built-in layouts, since neither a
# dispatcher nor a keyword toggles it on its own.
toggleLayout = pkgs.writeShellScript "hypr-toggle-layout" ''
if [ "$(hyprctl getoption -j general:layout | ${pkgs.jq}/bin/jq -r .str)" = dwindle ]; then
hyprctl keyword general:layout master
else
hyprctl keyword general:layout dwindle
fi
'';
in
{
options.modules.desktop.session.hyprland.enable = lib.mkEnableOption "the Hyprland compositor";
options.modules.desktop.session.hyprland = {
enable = lib.mkEnableOption "the Hyprland compositor";
config = lib.mkIf config.modules.desktop.session.hyprland.enable {
blur = lib.mkEnableOption ''
window blur. Off by default as the single biggest battery cost on a
laptop, left on for a host with the headroom to spend it'';
};
config = lib.mkIf cfg.enable {
# This program integration owns the session, portals, and polkit, launched
# through the universal Wayland session manager.
programs.hyprland = {
@@ -17,7 +43,7 @@
withUWSM = true;
};
home-manager.users.${config.user.name}.wayland.windowManager.hyprland = {
home-manager.users.${user}.wayland.windowManager.hyprland = {
enable = true;
# One package drives the whole session, so there is never a version split.
@@ -29,216 +55,136 @@
# uwsm owns the systemd graphical-session targets.
systemd.enable = false;
# Write the native Lua Hyprland config. Home Manager renders the Nix
# settings below as hl.* calls.
configType = "lua";
# Write the native hyprlang hyprland.conf, whose variable and bind syntax
# the settings below are expressed in.
configType = "hyprlang";
settings = {
config = {
input = {
kb_layout = config.services.xserver.xkb.layout;
repeat_delay = 250;
repeat_rate = 45;
accel_profile = "flat";
touchpad = {
natural_scroll = true;
tap_to_click = true;
disable_while_typing = true;
};
};
"$mod" = "SUPER";
"$terminal" = "ghostty";
general = {
gaps_in = 4;
gaps_out = 8;
border_size = 2;
layout = "dwindle";
};
decoration = {
rounding = 6;
blur.enabled = false;
};
xwayland.force_zero_scaling = true;
dwindle.preserve_split = true;
};
env = lib.mapAttrsToList (name: value: {
_args = [
name
value
];
}) (lib.attrByPath [ config.networking.hostName ] { } config.registry.env);
bind = lib.mapAttrsToList (_name: keybind: {
_args = [
keybind.keys
(lib.generators.mkLuaInline (
{
exec = "hl.dsp.exec_cmd(${lib.generators.toLua { } keybind.action.command})";
focus = "hl.dsp.focus(${
lib.generators.toLua { } { inherit (keybind.action) direction workspace; }
})";
"move-window" = "hl.dsp.window.move(${
lib.generators.toLua { } { inherit (keybind.action) direction workspace; }
})";
"resize-window" = "hl.dsp.window.resize(${
lib.generators.toLua { } (
{
down = {
x = 0;
y = 10;
relative = true;
};
left = {
x = -10;
y = 0;
relative = true;
};
right = {
x = 10;
y = 0;
relative = true;
};
up = {
x = 0;
y = -10;
relative = true;
};
}
.${keybind.action.direction}
)
})";
"toggle-floating" = ''hl.dsp.window.float({ action = "toggle" })'';
"toggle-fullscreen" = "hl.dsp.window.fullscreen()";
"close-window" = "hl.dsp.window.close()";
}
.${keybind.action.kind}
))
]
++ lib.optional keybind.repeating {
repeating = true;
}
++ lib.optional keybind.locked {
locked = true;
};
}) (lib.attrByPath [ config.networking.hostName ] { } config.registry.keybind);
};
};
registry.keybind.${config.networking.hostName} = lib.mkMerge [
# Generate numeric workspace keybinds.
(lib.listToAttrs (
lib.concatMap (workspace: [
{
name = "hyprland-workspace-${toString workspace}";
value = {
keys = "SUPER + ${toString workspace}";
action = {
kind = "focus";
inherit workspace;
};
};
}
{
name = "hyprland-move-to-workspace-${toString workspace}";
value = {
keys = "SUPER + SHIFT + ${toString workspace}";
action = {
kind = "move-window";
inherit workspace;
};
};
}
]) (lib.range 1 9)
))
# Generate directional keybinds.
(lib.listToAttrs (
lib.concatMap
(
{ direction, key }:
[
{
name = "hyprland-focus-${direction}";
value = {
keys = "SUPER + ${key}";
action = {
kind = "focus";
inherit direction;
};
};
}
{
name = "hyprland-move-${direction}";
value = {
keys = "SUPER + SHIFT + ${key}";
action = {
kind = "move-window";
inherit direction;
};
};
}
{
name = "hyprland-resize-${direction}";
value = {
keys = "SUPER + ALT + ${key}";
repeating = true;
action = {
kind = "resize-window";
inherit direction;
};
};
}
]
)
# Session variables handed to the compositor directly.
# UWSM launches the session without the shell profile that would carry
# them, so a variable the compositor or its children must see is set
# here rather than through home-manager's sessionVariables.
env =
[
{
key = "H";
direction = "left";
}
{
key = "J";
direction = "down";
}
{
key = "K";
direction = "up";
}
{
key = "L";
direction = "right";
}
# Chromium and Electron apps read this to select native Wayland;
# nixpkgs wrappers (Obsidian's included) gate their Wayland flags on
# it, so without it they run under XWayland and blur at this DPI.
"NIXOS_OZONE_WL,1"
]
))
{
hyprland-toggle-floating = {
keys = "SUPER + Space";
action.kind = "toggle-floating";
# Bibata ships XCursor only.
# The hyprcursor variables name the same theme, which Hyprland
# resolves through its XCursor fallback.
++ lib.optionals (cursor != null) [
"XCURSOR_THEME,${cursor.name}"
"XCURSOR_SIZE,${toString cursor.size}"
"HYPRCURSOR_THEME,${cursor.name}"
"HYPRCURSOR_SIZE,${toString cursor.size}"
];
input = {
kb_layout = "us";
# Caps is a second Escape.
# Shift+Caps still toggles a real CapsLock.
kb_options = "caps:escape_shifted_capslock";
# Snappy key repeat.
repeat_delay = 250;
repeat_rate = 45;
accel_profile = "flat";
touchpad = {
natural_scroll = true;
tap-to-click = true;
disable_while_typing = true;
};
};
hyprland-toggle-fullscreen = {
keys = "SUPER + F";
action.kind = "toggle-fullscreen";
general = {
gaps_in = 4;
gaps_out = 8;
border_size = 2;
layout = "dwindle";
};
hyprland-close-window = {
keys = "SUPER + SHIFT + Q";
action.kind = "close-window";
decoration = {
rounding = 6;
blur.enabled = cfg.blur;
};
}
];
registry.waybar.${config.networking.hostName}."hyprland/workspaces" = {
position = "left";
order = 10;
settings = {
format = "{id} {windows}";
format-window-separator = " ";
window-rewrite-default = util.glyph "f2d0";
window-rewrite = lib.mapAttrs' (
_name: windowIcon: lib.nameValuePair "class<${windowIcon.class}>" windowIcon.icon
) (lib.attrByPath [ config.networking.hostName ] { } config.registry.window-icon);
# XWayland clients render at the panel's native resolution instead of
# being raster-scaled by the compositor at the fractional monitor scale.
xwayland.force_zero_scaling = true;
animations = {
enabled = true;
bezier = [ "ease, 0.25, 0.1, 0.25, 1.0" ];
# Durations are in centiseconds.
# Short values keep the motion subtle.
animation = [
"windows, 1, 3, ease"
"fade, 1, 3, ease"
# Layer surfaces like the launcher fade in a touch quicker than windows.
"layersIn, 1, 2, ease"
"fadeLayersIn, 1, 2, ease"
"workspaces, 1, 3, ease"
"border, 1, 3, ease"
];
};
dwindle = {
preserve_split = true;
};
bind = [
"$mod, Return, exec, $terminal"
# Move focus.
"$mod, H, movefocus, l"
"$mod, J, movefocus, d"
"$mod, K, movefocus, u"
"$mod, L, movefocus, r"
# Move the window within the layout.
"$mod SHIFT, H, movewindow, l"
"$mod SHIFT, J, movewindow, d"
"$mod SHIFT, K, movewindow, u"
"$mod SHIFT, L, movewindow, r"
# Resize the active window.
"$mod ALT, H, resizeactive, -40 0"
"$mod ALT, J, resizeactive, 0 40"
"$mod ALT, K, resizeactive, 0 -40"
"$mod ALT, L, resizeactive, 40 0"
"$mod, Space, togglefloating,"
"$mod, F, fullscreen,"
# togglesplit is a dwindle layout message, reached through layoutmsg.
"$mod, T, layoutmsg, togglesplit"
"$mod SHIFT, T, exec, ${toggleLayout}"
"$mod SHIFT, Q, killactive,"
"$mod CTRL, Q, forcekillactive,"
]
++ workspaceBinds;
# Volume and brightness keys repeat while held, each raising a popup
# through the OSD client.
# Volume is capped at 100 percent.
# The client floors brightness so a full hold cannot black the screen out.
binde = [
", XF86AudioRaiseVolume, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume raise --max-volume 100"
", XF86AudioLowerVolume, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume lower"
", XF86MonBrightnessUp, exec, ${pkgs.swayosd}/bin/swayosd-client --brightness raise"
", XF86MonBrightnessDown, exec, ${pkgs.swayosd}/bin/swayosd-client --brightness lower"
];
# Mute and media transport still fire while the session is locked.
bindl = [
", XF86AudioMute, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume mute-toggle"
", XF86AudioPlay, exec, ${pkgs.playerctl}/bin/playerctl play-pause"
", XF86AudioNext, exec, ${pkgs.playerctl}/bin/playerctl next"
", XF86AudioPrev, exec, ${pkgs.playerctl}/bin/playerctl previous"
];
};
};
};

View File

@@ -5,34 +5,62 @@
...
}:
# hypridle: idle-triggered locking and display power management.
{
options.modules.desktop.session.hyprland.hypridle.enable =
lib.mkEnableOption "hypridle idle management";
let
cfg = config.modules.desktop.session.hyprland.hypridle;
user = config.user.name;
config = lib.mkIf config.modules.desktop.session.hyprland.hypridle.enable {
hyprctl = "${config.programs.hyprland.package}/bin/hyprctl";
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock";
# The guard drops the call when a locker is already up, so no idle trigger
# stacks a second hyprlock over the first.
lockCmd = "${pkgs.procps}/bin/pidof hyprlock || ${hyprlock}";
in
{
options.modules.desktop.session.hyprland.hypridle = {
enable = lib.mkEnableOption "hypridle idle management";
lockTimeout = lib.mkOption {
type = lib.types.ints.positive;
default = 300;
description = "Seconds of inactivity before the screen locks.";
};
screenOffTimeout = lib.mkOption {
type = lib.types.ints.positive;
default = 360;
description = "Seconds of inactivity before the displays are powered off.";
};
};
config = lib.mkIf cfg.enable {
# Closing the lid suspends, and every suspend locks first through the
# before_sleep_cmd below, so the lid always lands at a locked screen.
services.logind.settings.Login.HandleLidSwitch = "suspend";
home-manager.users.${config.user.name}.services.hypridle = {
home-manager.users.${user}.services.hypridle = {
enable = true;
settings = {
general = {
# The guard drops the call when a locker is already up, so no idle
# trigger stacks a second hyprlock over the first.
lock_cmd = "${lib.getExe' pkgs.procps "pidof"} hyprlock || ${lib.getExe pkgs.hyprlock}";
lock_cmd = lockCmd;
before_sleep_cmd = "loginctl lock-session";
# Waking pokes the compositor in case firmware leaves displays dark.
after_sleep_cmd = "${lib.getExe' config.programs.hyprland.package "hyprctl"} dispatch 'hl.dsp.dpms({ action = \"enable\" })'";
# Waking restores the displays the screen-off listener may have cut.
after_sleep_cmd = "${hyprctl} dispatch dpms on";
};
listener = [
# Lock on idle.
{
timeout = 300;
timeout = cfg.lockTimeout;
on-timeout = "loginctl lock-session";
}
# Power the displays off a little later, restoring them on any activity.
{
timeout = cfg.screenOffTimeout;
on-timeout = "${hyprctl} dispatch dpms off";
on-resume = "${hyprctl} dispatch dpms on";
}
];
};
};

View File

@@ -2,63 +2,53 @@
config,
lib,
pkgs,
util,
...
}:
# The lock screen: a hyprlock session-lock surface the compositor owns.
let
sleepIcon = util.glyph [
"db81"
"dcb2"
];
sleepOffIcon = util.glyph [
"db81"
"dcb3"
];
cfg = config.modules.desktop.session.hyprland.hyprlock;
user = config.user.name;
# The hyprlock this module installs, used by the lock keybind below.
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock";
sleepIcon = builtins.fromJSON ''"\udb81\udcb2"'';
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"'';
idleLockToggle = pkgs.writeShellScript "lockscreen-idle-inhibit-toggle" ''
unit=lockscreen-idle-inhibit.service
refresh_waybar() {
${lib.getExe' pkgs.procps "pkill"} -RTMIN+9 waybar >/dev/null 2>&1 || true
${pkgs.procps}/bin/pkill -RTMIN+9 waybar >/dev/null 2>&1 || true
}
if ${lib.getExe' pkgs.systemd "systemctl"} --user is-active --quiet "$unit"; then
${lib.getExe' pkgs.systemd "systemctl"} --user stop "$unit"
if ${pkgs.systemd}/bin/systemctl --user is-active --quiet "$unit"; then
${pkgs.systemd}/bin/systemctl --user stop "$unit"
refresh_waybar
${lib.getExe' pkgs.swayosd "swayosd-client"} --custom-message "${sleepIcon}"
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepIcon}"
exit 0
fi
if ${lib.getExe' pkgs.systemd "systemd-run"} \
if ${pkgs.systemd}/bin/systemd-run \
--user \
--collect \
--unit=lockscreen-idle-inhibit \
--description="Disable idle-triggered screen locking" \
${lib.getExe' pkgs.systemd "systemd-inhibit"} \
${pkgs.systemd}/bin/systemd-inhibit \
--what=idle \
--who=lockscreen-hotkey \
--why="lock screen disabled by hotkey" \
${lib.getExe' pkgs.coreutils "sleep"} infinity >/dev/null; then
${pkgs.coreutils}/bin/sleep infinity >/dev/null; then
refresh_waybar
${lib.getExe' pkgs.swayosd "swayosd-client"} --custom-message "${sleepOffIcon}"
fi
'';
idleLockStatus = pkgs.writeShellScript "waybar-idle-lock-status" ''
if ${lib.getExe' pkgs.systemd "systemctl"} --user is-active --quiet lockscreen-idle-inhibit.service; then
printf '{"text":"${sleepOffIcon}","tooltip":"Idle locking inhibited","class":"inhibited"}\n'
else
printf '{"text":"","class":"idle"}\n'
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepOffIcon}"
fi
'';
in
{
options.modules.desktop.session.hyprland.hyprlock.enable =
lib.mkEnableOption "the hyprlock lock screen";
options.modules.desktop.session.hyprland.hyprlock.enable = lib.mkEnableOption "the hyprlock lock screen";
config = lib.mkIf config.modules.desktop.session.hyprland.hyprlock.enable {
home-manager.users.${config.user.name} = {
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
# Colors and the lock-screen background come from Stylix's hyprlock target,
# so only geometry and behaviour are set here.
programs.hyprlock = {
@@ -91,36 +81,14 @@ in
};
};
};
};
registry.keybind.${config.networking.hostName} = {
hyprlock-lock = {
keys = "SUPER + X";
action = {
kind = "exec";
# The guard drops the call when a locker is already up, so no manual
# trigger stacks a second hyprlock over the first.
command = "${lib.getExe' pkgs.procps "pidof"} hyprlock || ${lib.getExe pkgs.hyprlock}";
};
};
hyprlock-toggle-idle-inhibit = {
keys = "SUPER + CTRL + X";
action = {
kind = "exec";
command = "${idleLockToggle}";
};
};
};
registry.waybar.${config.networking.hostName}."custom/idle-lock" = {
order = 20;
settings = {
return-type = "json";
exec = "${idleLockStatus}";
interval = "once";
signal = 9;
};
# Lock on Super+X.
# The guard drops the keypress when a locker is already up, so a second
# hyprlock never stacks over the first.
wayland.windowManager.hyprland.settings.bind = [
"$mod, X, exec, ${pkgs.procps}/bin/pidof hyprlock || ${hyprlock}"
"$mod CTRL, X, exec, ${idleLockToggle}"
];
};
};
}

View File

@@ -6,20 +6,18 @@
}:
# Text login: greetd running the tuigreet greeter, which starts the session
# through the universal Wayland session manager.
let
cfg = config.modules.desktop.session.login;
in
{
options.modules.desktop.session.login.enable =
lib.mkEnableOption "greetd with the tuigreet text greeter";
options.modules.desktop.session.login.enable = lib.mkEnableOption "greetd with the tuigreet text greeter";
config = lib.mkIf config.modules.desktop.session.login.enable {
config = lib.mkIf cfg.enable {
services.greetd = {
enable = true;
settings.default_session.command = lib.escapeShellArgs [
(lib.getExe pkgs.tuigreet)
"--time"
"--remember"
"--cmd"
"${lib.getExe config.programs.uwsm.package} start -e -D Hyprland hyprland.desktop"
];
settings.default_session.command =
"${lib.getExe pkgs.tuigreet} --time --remember "
+ "--cmd 'uwsm start -e -D Hyprland hyprland.desktop'";
};
};
}

View File

@@ -2,29 +2,20 @@
config,
lib,
pkgs,
util,
...
}:
# mako as the notification daemon: toasts, a do-not-disturb mode, and history recall.
let
dndStatus = pkgs.writeShellScript "waybar-dnd-status" ''
if ${lib.getExe' pkgs.mako "makoctl"} mode 2>/dev/null | ${lib.getExe pkgs.gnugrep} -qx dnd; then
printf '{"text":"${util.glyph "f1f6"}","tooltip":"Do not disturb: on","class":"dnd"}\n'
else
printf '{"text":"${util.glyph "f0f3"}","tooltip":"Notifications on","class":"active"}\n'
fi
'';
cfg = config.modules.desktop.session.mako;
user = config.user.name;
dndToggle = pkgs.writeShellScript "waybar-dnd-toggle" ''
${lib.getExe' pkgs.mako "makoctl"} mode -t dnd >/dev/null 2>&1
${lib.getExe' pkgs.procps "pkill"} -RTMIN+8 waybar
'';
makoctl = "${pkgs.mako}/bin/makoctl";
in
{
options.modules.desktop.session.mako.enable = lib.mkEnableOption "the mako notification daemon";
config = lib.mkIf config.modules.desktop.session.mako.enable {
home-manager.users.${config.user.name} = {
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
# Colors and the popup font come from Stylix's mako target, so only
# behaviour is set here.
services.mako = {
@@ -41,25 +32,13 @@ in
"mode=dnd".invisible = true;
};
};
};
registry.keybind.${config.networking.hostName}.mako-history = {
keys = "SUPER + N";
action = {
kind = "exec";
command = "${lib.getExe' pkgs.mako "makoctl"} restore";
};
};
registry.waybar.${config.networking.hostName}."custom/dnd" = {
order = 80;
settings = {
return-type = "json";
exec = "${dndStatus}";
on-click = "${dndToggle}";
interval = "once";
signal = 8;
};
# Recall the last notification from history, keyboard-only like the rest
# of the session.
# $mod is defined by the compositor config these binds share.
wayland.windowManager.hyprland.settings.bind = [
"$mod, N, exec, ${makoctl} restore"
];
};
};
}

View File

@@ -1,10 +1,13 @@
{ config, lib, ... }:
# XDG desktop portal routing for in-app screen sharing and file dialogs.
let
cfg = config.modules.desktop.session.portals;
in
{
options.modules.desktop.session.portals.enable =
lib.mkEnableOption "XDG desktop portals for in-app screen sharing and file dialogs";
config = lib.mkIf config.modules.desktop.session.portals.enable {
config = lib.mkIf cfg.enable {
# The backend packages arrive with the compositor, so only the routing is set
# here.
# Screen sharing, screenshots, and global shortcuts need the compositor's

View File

@@ -6,29 +6,31 @@
}:
# rofi as the desktop launcher and dmenu-style menu frontend.
let
cfg = config.modules.desktop.session.rofi;
user = config.user.name;
# A session menu built on the same themed rofi, so the power actions look
# like every other menu the launcher drives.
powerMenu = pkgs.writeShellScript "rofi-power-menu" ''
chosen=$(printf '%s\n' Lock Suspend 'Log out' Reboot 'Shut down' \
| ${lib.getExe pkgs.rofi} -dmenu -i -p Power)
| ${pkgs.rofi}/bin/rofi -dmenu -i -p Power)
case "$chosen" in
Lock) ${lib.getExe' pkgs.systemd "loginctl"} lock-session ;;
Suspend) ${lib.getExe' pkgs.systemd "systemctl"} suspend ;;
'Log out') ${lib.getExe' pkgs.systemd "loginctl"} terminate-session "$XDG_SESSION_ID" ;;
Reboot) ${lib.getExe' pkgs.systemd "systemctl"} reboot ;;
'Shut down') ${lib.getExe' pkgs.systemd "systemctl"} poweroff ;;
Lock) ${pkgs.systemd}/bin/loginctl lock-session ;;
Suspend) ${pkgs.systemd}/bin/systemctl suspend ;;
'Log out') ${pkgs.systemd}/bin/loginctl terminate-session "$XDG_SESSION_ID" ;;
Reboot) ${pkgs.systemd}/bin/systemctl reboot ;;
'Shut down') ${pkgs.systemd}/bin/systemctl poweroff ;;
esac
'';
in
{
options.modules.desktop.session.rofi.enable = lib.mkEnableOption "the rofi launcher";
config = lib.mkIf config.modules.desktop.session.rofi.enable {
home-manager.users.${config.user.name} = hm: {
# Stylix supplies the Nord theme.
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
# No theme is set here: Stylix's rofi target supplies the Nord one.
programs.rofi = {
enable = true;
font = lib.mkForce "JetBrains Mono 12";
# Application launch is the only mode, kept minimal so the prompt opens
# as fast as rofi can read the desktop entries.
@@ -37,31 +39,14 @@ in
modi = "drun";
show-icons = false;
};
theme.window = {
border = hm.config.lib.formats.rasi.mkLiteral "2px";
border-color = hm.config.lib.formats.rasi.mkLiteral "#FFFFFF";
border-radius = hm.config.lib.formats.rasi.mkLiteral "8px";
};
};
};
registry.keybind.${config.networking.hostName} = {
rofi-launcher = {
keys = "SUPER + R";
action = {
kind = "exec";
command = "${lib.getExe pkgs.rofi} -show drun";
};
};
rofi-power-menu = {
keys = "SUPER + SHIFT + X";
action = {
kind = "exec";
command = "${powerMenu}";
};
};
# The launcher and the session menu, kept next to the tool they drive.
# $mod is defined by the compositor config these binds share.
wayland.windowManager.hyprland.settings.bind = [
"$mod, R, exec, ${pkgs.rofi}/bin/rofi -show drun"
"$mod SHIFT, X, exec, ${powerMenu}"
];
};
};
}

View File

@@ -6,56 +6,165 @@
}:
# Waybar: a top status bar reading system state at a glance.
let
moduleNames =
position:
map (entry: entry.name) (
lib.sort (left: right: left.module.order < right.module.order) (
lib.filter (entry: entry.module.position == position) moduleEntries
)
);
cfg = config.modules.desktop.session.waybar;
vpnClient = config.modules.network.vpn-client;
user = config.user.name;
moduleEntries = lib.mapAttrsToList (name: module: {
inherit name module;
}) modules;
# A Nerd Font Private-Use-Area codepoint as a literal character, decoded
# through JSON so the glyph survives as bytes rather than an editor paste.
g = code: builtins.fromJSON ''"\u${code}"'';
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"'';
modules = lib.attrByPath [ config.networking.hostName ] { } config.registry.waybar;
# Renders and toggles mako's do-not-disturb mode.
# With no mako daemon answering, the status reads "notifications on", keeping
# the widget inert rather than broken.
dndStatus = pkgs.writeShellScript "waybar-dnd-status" ''
if ${pkgs.mako}/bin/makoctl mode 2>/dev/null | ${pkgs.gnugrep}/bin/grep -qx dnd; then
printf '{"text":"${g "f1f6"}","tooltip":"Do not disturb: on","class":"dnd"}\n'
else
printf '{"text":"${g "f0f3"}","tooltip":"Notifications on","class":"active"}\n'
fi
'';
dndToggle = pkgs.writeShellScript "waybar-dnd-toggle" ''
${pkgs.mako}/bin/makoctl mode -t dnd >/dev/null 2>&1
${pkgs.procps}/bin/pkill -RTMIN+8 waybar
'';
# Lit while a wf-recorder capture is running.
# Empty text collapses the widget when idle, so it shows nothing until then.
recStatus = pkgs.writeShellScript "waybar-recording-status" ''
if ${pkgs.procps}/bin/pgrep -x wf-recorder >/dev/null; then
printf '{"text":"${g "f03d"}","tooltip":"Recording","class":"recording"}\n'
else
printf '{"text":"","class":"idle"}\n'
fi
'';
idleLockStatus = pkgs.writeShellScript "waybar-idle-lock-status" ''
if ${pkgs.systemd}/bin/systemctl --user is-active --quiet lockscreen-idle-inhibit.service; then
printf '{"text":"${sleepOffIcon}","tooltip":"Idle locking inhibited","class":"inhibited"}\n'
else
printf '{"text":"","class":"idle"}\n'
fi
'';
vpnStatus = pkgs.writeShellScript "waybar-vpn-status" ''
interface=${lib.escapeShellArg vpnClient.interface}
if ! ${pkgs.iproute2}/bin/ip link show dev "$interface" >/dev/null 2>&1; then
printf '{"text":"","class":"idle"}\n'
exit 0
fi
ipaddr=$(${pkgs.iproute2}/bin/ip -4 -o addr show dev "$interface" | ${pkgs.gawk}/bin/awk '{ print $4; exit }')
${pkgs.jq}/bin/jq -cn \
--arg text "󰲝" \
--arg tooltip "VPN $interface: ''${ipaddr:-no IPv4}" \
--arg class "active" \
'{ text: $text, tooltip: $tooltip, class: $class }'
'';
networkStatus = pkgs.writeShellScript "waybar-network-status" ''
active=$(
${pkgs.networkmanager}/bin/nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device status \
| ${pkgs.gawk}/bin/awk -F: '$3 == "connected" && $2 != "loopback" { print; exit }'
)
if [ -z "$active" ]; then
${pkgs.jq}/bin/jq -cn \
--arg text "${g "f127"} off" \
--arg tooltip "Network disconnected" \
--arg class "disconnected" \
'{ text: $text, tooltip: $tooltip, class: $class }'
exit 0
fi
IFS=: read -r device type _state connection <<< "$active"
case "$type" in
wifi) icon="${g "f1eb"}" ;;
ethernet) icon="LAN" ;;
*) icon="NET" ;;
esac
ipaddr=$(${pkgs.iproute2}/bin/ip -4 -o addr show dev "$device" | ${pkgs.gawk}/bin/awk '{ print $4; exit }')
${pkgs.jq}/bin/jq -cn \
--arg text "$icon $connection" \
--arg tooltip "$device: ''${ipaddr:-no IPv4}" \
--arg class "$type" \
'{ text: $text, tooltip: $tooltip, class: $class }'
'';
in
{
options.modules.desktop.session.waybar.enable = lib.mkEnableOption "the Waybar status bar";
config = lib.mkIf config.modules.desktop.session.waybar.enable {
config = lib.mkIf cfg.enable {
# A symbols font backs the module glyphs, so pango falls back to it for the
# codepoints Stylix's monospace font does not carry.
fonts.packages = [ pkgs.nerd-fonts.symbols-only ];
home-manager.users.${config.user.name}.programs.waybar = {
home-manager.users.${user}.programs.waybar = {
enable = true;
# A user service bound to graphical-session.target, which uwsm activates,
# so the bar comes up with the session and no compositor exec-once.
systemd.enable = true;
settings.mainBar = lib.mkMerge [
{
layer = "top";
position = "top";
height = 30;
spacing = 8;
margin-right = 8;
modules-left = moduleNames "left";
modules-center = moduleNames "center";
modules-right = moduleNames "right";
style = ''
.modules-right {
margin-right: 8px;
}
(lib.mapAttrs (_name: module: module.settings) modules)
];
};
registry.waybar.${config.networking.hostName} = {
clock = {
position = "center";
order = 10;
settings = {
#custom-dnd {
margin-right: 8px;
}
'';
settings.mainBar = {
layer = "top";
position = "top";
height = 30;
spacing = 8;
modules-left = [ "hyprland/workspaces" ];
modules-center = [ "clock" ];
modules-right = [
"custom/recording"
"custom/idle-lock"
"mpris"
"wireplumber"
]
++ lib.optional vpnClient.enable "custom/vpn"
++ [
"custom/network"
"battery"
"custom/dnd"
];
# Per-application icons for the windows on each workspace, mapped from
# window class with a neutral glyph for anything unlisted.
"hyprland/workspaces" = {
on-click = "activate";
format = "{id} {windows}";
format-window-separator = " ";
window-rewrite-default = g "f2d0";
window-rewrite = {
"class<Alacritty>" = g "f120";
"class<com.mitchellh.ghostty>" = g "f120";
"class<.*[Ff]irefox.*>" = g "f269";
"class<chromium.*>" = g "f268";
"class<[Cc]ode>" = g "f121";
"class<obsidian>" = g "f02d";
"class<[Ss]ignal>" = g "f4ad";
"class<[Ss]team>" = g "f1b6";
"class<[Vv]esktop>" = g "f1ff";
};
};
clock = {
format = "{:%a %d %b %H:%M}";
tooltip-format = "<tt>{calendar}</tt>";
calendar = {
@@ -64,6 +173,77 @@ in
format.today = "<b><u>{}</u></b>";
};
};
mpris = {
format = "${g "f001"} {title}";
format-paused = "${g "f04c"} {title}";
max-length = 40;
on-click = "play-pause";
on-scroll-up = "next";
on-scroll-down = "previous";
};
wireplumber = {
format = "{icon} {volume}%";
format-muted = "${g "f026"} muted";
format-icons = [
(g "f026")
(g "f027")
(g "f028")
];
on-click = "${pkgs.wireplumber}/bin/wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle";
};
"custom/vpn" = {
return-type = "json";
exec = "${vpnStatus}";
interval = 5;
};
"custom/network" = {
return-type = "json";
exec = "${networkStatus}";
interval = 5;
};
battery = {
format = "{icon} {capacity}%";
format-charging = "${g "f0e7"} {capacity}%";
format-icons = [
(g "f244")
(g "f243")
(g "f242")
(g "f241")
(g "f240")
];
states = {
warning = 20;
critical = 10;
};
};
"custom/dnd" = {
return-type = "json";
exec = "${dndStatus}";
on-click = "${dndToggle}";
interval = "once";
signal = 8;
};
# Recording state has no event to hook, so the widget samples the
# wf-recorder process once a second.
"custom/recording" = {
return-type = "json";
exec = "${recStatus}";
interval = 1;
};
"custom/idle-lock" = {
return-type = "json";
exec = "${idleLockStatus}";
interval = "once";
signal = 9;
};
};
};
};

View File

@@ -1,25 +1,12 @@
{
config,
lib,
pkgs,
util,
...
}:
{ config, lib, ... }:
# PipeWire as the desktop audio server.
let
mediaKey = keys: command: {
inherit keys;
locked = true;
action = {
kind = "exec";
command = "${lib.getExe pkgs.playerctl} ${command}";
};
};
cfg = config.modules.desktop.utilities.audio;
in
{
options.modules.desktop.utilities.audio.enable = lib.mkEnableOption "the PipeWire audio server";
config = lib.mkIf config.modules.desktop.utilities.audio.enable {
config = lib.mkIf cfg.enable {
# Realtime scheduling for the audio threads, so playback survives load.
security.rtkit.enable = true;
@@ -28,39 +15,5 @@ in
alsa.enable = true;
pulse.enable = true;
};
registry.keybind.${config.networking.hostName} = {
media-play = mediaKey "XF86AudioPlay" "play-pause";
media-next = mediaKey "XF86AudioNext" "next";
media-previous = mediaKey "XF86AudioPrev" "previous";
};
registry.waybar.${config.networking.hostName} = {
mpris = {
order = 30;
settings = {
format = "${util.glyph "f001"} {title}";
format-paused = "${util.glyph "f04c"} {title}";
max-length = 40;
on-click = "play-pause";
on-scroll-up = "next";
on-scroll-down = "previous";
};
};
wireplumber = {
order = 40;
settings = {
format = "{icon} {volume}%";
format-muted = "${util.glyph "f026"} muted";
format-icons = [
(util.glyph "f026")
(util.glyph "f027")
(util.glyph "f028")
];
on-click = "${lib.getExe' pkgs.wireplumber "wpctl"} set-mute @DEFAULT_AUDIO_SINK@ toggle";
};
};
};
};
}

View File

@@ -6,23 +6,29 @@
}:
# Clipboard history: cliphist records every copy, picked back through rofi.
let
cfg = config.modules.desktop.utilities.clipboard;
user = config.user.name;
cliphist = "${pkgs.cliphist}/bin/cliphist";
rofi = "${pkgs.rofi}/bin/rofi";
wl-copy = "${pkgs.wl-clipboard}/bin/wl-copy";
# The picker reuses the themed rofi, so history looks like every other menu
# the launcher drives.
# decode is needed because list emits id-prefixed lines rather than the copied
# bytes, so the chosen id has to be resolved back before it can be re-copied.
picker = pkgs.writeShellScript "clipboard-picker" ''
${lib.getExe pkgs.cliphist} list \
| ${lib.getExe pkgs.rofi} -dmenu -i -p Clipboard \
| ${lib.getExe pkgs.cliphist} decode \
| ${lib.getExe' pkgs.wl-clipboard "wl-copy"}
${cliphist} list \
| ${rofi} -dmenu -i -p Clipboard \
| ${cliphist} decode \
| ${wl-copy}
'';
in
{
options.modules.desktop.utilities.clipboard.enable =
lib.mkEnableOption "cliphist clipboard history";
options.modules.desktop.utilities.clipboard.enable = lib.mkEnableOption "cliphist clipboard history";
config = lib.mkIf config.modules.desktop.utilities.clipboard.enable {
home-manager.users.${config.user.name} = {
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
# wl-copy and wl-paste on PATH, so the shell can pipe into and out of the
# clipboard.
# The watchers and picker above reach wl-clipboard by store path, so this
@@ -32,14 +38,11 @@ in
# Two watchers record text and images to history, bound to the graphical
# session so uwsm starts and stops them with it.
services.cliphist.enable = true;
};
registry.keybind.${config.networking.hostName}.clipboard-history = {
keys = "SUPER + SHIFT + V";
action = {
kind = "exec";
command = "${picker}";
};
# $mod is defined by the compositor config these binds share.
wayland.windowManager.hyprland.settings.bind = [
"$mod SHIFT, V, exec, ${picker}"
];
};
};
}

View File

@@ -6,60 +6,13 @@
}:
# SwayOSD: a transient on-screen popup for volume and brightness changes.
let
cfg = config.modules.desktop.utilities.osd;
user = config.user.name;
in
{
options.modules.desktop.utilities.osd.enable =
lib.mkEnableOption "the SwayOSD volume and brightness popup";
config = lib.mkIf config.modules.desktop.utilities.osd.enable {
registry.keybind.${config.networking.hostName} = {
volume-up = {
keys = "XF86AudioRaiseVolume";
repeating = true;
action = {
kind = "exec";
command = "${pkgs.swayosd}/bin/swayosd-client --output-volume raise --max-volume 100";
};
};
volume-down = {
keys = "XF86AudioLowerVolume";
repeating = true;
action = {
kind = "exec";
command = "${pkgs.swayosd}/bin/swayosd-client --output-volume lower";
};
};
brightness-up = {
keys = "XF86MonBrightnessUp";
repeating = true;
action = {
kind = "exec";
command = "${pkgs.swayosd}/bin/swayosd-client --brightness raise";
};
};
brightness-down = {
keys = "XF86MonBrightnessDown";
repeating = true;
action = {
kind = "exec";
command = "${pkgs.swayosd}/bin/swayosd-client --brightness lower";
};
};
mute = {
keys = "XF86AudioMute";
locked = true;
action = {
kind = "exec";
command = "${pkgs.swayosd}/bin/swayosd-client --output-volume mute-toggle";
};
};
};
options.modules.desktop.utilities.osd.enable = lib.mkEnableOption "the SwayOSD volume and brightness popup";
config = lib.mkIf cfg.enable {
environment.systemPackages = [ pkgs.swayosd ];
# The udev rule chgrps each backlight's brightness node to `video` and adds

View File

@@ -2,11 +2,22 @@
config,
lib,
pkgs,
util,
...
}:
# Keybound screen recording: pick a region, then toggle a video-only capture.
let
cfg = config.modules.desktop.utilities.recording;
user = config.user.name;
slurp = "${pkgs.slurp}/bin/slurp";
wf-recorder = "${pkgs.wf-recorder}/bin/wf-recorder";
notify-send = "${pkgs.libnotify}/bin/notify-send";
pgrep = "${pkgs.procps}/bin/pgrep";
pkill = "${pkgs.procps}/bin/pkill";
date = "${pkgs.coreutils}/bin/date";
mkdir = "${pkgs.coreutils}/bin/mkdir";
xdgUserDir = "${pkgs.xdg-user-dirs}/bin/xdg-user-dir";
# A single key both starts and stops.
# A running wf-recorder is stopped with SIGINT so it finalises the file.
# Otherwise slurp picks a region and wf-recorder runs in the foreground until
@@ -14,51 +25,30 @@ let
# reports it saved.
# No -a means no audio is captured.
toggle = pkgs.writeShellScript "screen-record-toggle" ''
if ${lib.getExe' pkgs.procps "pgrep"} -x wf-recorder >/dev/null; then
${lib.getExe' pkgs.procps "pkill"} -INT -x wf-recorder
if ${pgrep} -x wf-recorder >/dev/null; then
${pkill} -INT -x wf-recorder
exit 0
fi
region=$(${lib.getExe pkgs.slurp}) || exit 0
dir="$(${lib.getExe' pkgs.xdg-user-dirs "xdg-user-dir"} VIDEOS)/Recordings"
${lib.getExe' pkgs.coreutils "mkdir"} -p "$dir"
file="$dir/recording-$(${lib.getExe' pkgs.coreutils "date"} +%Y%m%d-%H%M%S).mp4"
region=$(${slurp}) || exit 0
dir="$(${xdgUserDir} VIDEOS)/Recordings"
${mkdir} -p "$dir"
file="$dir/recording-$(${date} +%Y%m%d-%H%M%S).mp4"
${lib.getExe' pkgs.libnotify "notify-send"} -a "Screen recording" "Recording started" "Region capture, no audio."
${lib.getExe pkgs.wf-recorder} -g "$region" -f "$file"
${lib.getExe' pkgs.libnotify "notify-send"} -a "Screen recording" "Recording saved" "$file"
'';
# Lit while a wf-recorder capture is running.
# Empty text collapses the widget when idle, so it shows nothing until then.
recordingStatus = pkgs.writeShellScript "waybar-recording-status" ''
if ${lib.getExe' pkgs.procps "pgrep"} -x wf-recorder >/dev/null; then
printf '{"text":"${util.glyph "f03d"}","tooltip":"Recording","class":"recording"}\n'
else
printf '{"text":"","class":"idle"}\n'
fi
${notify-send} -a "Screen recording" "Recording started" "Region capture, no audio."
${wf-recorder} -g "$region" -f "$file"
${notify-send} -a "Screen recording" "Recording saved" "$file"
'';
in
{
options.modules.desktop.utilities.recording.enable =
lib.mkEnableOption "wf-recorder screen recording";
options.modules.desktop.utilities.recording.enable = lib.mkEnableOption "wf-recorder screen recording";
config = lib.mkIf config.modules.desktop.utilities.recording.enable {
registry.keybind.${config.networking.hostName}.recording-toggle = {
keys = "SUPER + SHIFT + R";
action = {
kind = "exec";
command = "${toggle}";
};
};
registry.waybar.${config.networking.hostName}."custom/recording" = {
order = 10;
settings = {
return-type = "json";
exec = "${recordingStatus}";
interval = 1;
};
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
# $mod is defined by the compositor config these binds share.
wayland.windowManager.hyprland.settings.bind = [
"$mod SHIFT, R, exec, ${toggle}"
];
};
};
}

View File

@@ -4,26 +4,25 @@
pkgs,
...
}:
# Keyboard-driven screenshots copy to the clipboard before Satty opens for
# annotation. Satty copies annotated screenshots when it saves them.
# Keyboard-driven screenshots that open in satty for annotation, then land in
# both the clipboard and a dated file.
let
cfg = config.modules.desktop.utilities.screenshot;
user = config.user.name;
grimblast = "${pkgs.grimblast}/bin/grimblast";
satty = "${pkgs.satty}/bin/satty";
tee = "${pkgs.coreutils}/bin/tee";
wl-copy = "${pkgs.wl-clipboard}/bin/wl-copy";
xdgUserDir = "${pkgs.xdg-user-dirs}/bin/xdg-user-dir";
# Copy the original screenshot before Satty opens.
# Satty copies and saves annotated screenshots when the user presses Enter.
# satty's copy action also saves, so one keystroke lands the shot in both the
# clipboard and a file.
capture =
target:
pkgs.writeShellScript "screenshot-${target}" ''
dir="$(${xdgUserDir} PICTURES)/Screenshots"
mkdir -p "$dir"
${grimblast} save ${target} - \
| ${tee} >(${wl-copy}) \
| ${satty} --filename - \
--output-filename "$dir/screenshot-%Y%m%d-%H%M%S.png" \
--copy-command ${wl-copy} \
@@ -33,46 +32,26 @@ let
'';
in
{
options.modules.desktop.utilities.screenshot.enable =
lib.mkEnableOption "screenshots via grimblast and satty";
config = lib.mkIf config.modules.desktop.utilities.screenshot.enable {
registry.keybind.${config.networking.hostName} = {
screenshot-area = {
keys = "Print";
action = {
kind = "exec";
command = "${capture "area"}";
};
};
screenshot-active = {
keys = "SHIFT + Print";
action = {
kind = "exec";
command = "${capture "active"}";
};
};
screenshot-screen = {
keys = "CTRL + Print";
action = {
kind = "exec";
command = "${capture "screen"}";
};
};
};
options.modules.desktop.utilities.screenshot.enable = lib.mkEnableOption "screenshots via grimblast and satty";
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
# satty is a one-shot annotation surface, so float it rather than letting
# it claim a tile in the layout.
wayland.windowManager.hyprland.settings.window_rule = [
{
match.class = "^(com\\.gabm\\.satty)$";
float = true;
}
# windowrule v3 syntax: space-separated field/value tokens, comma
# separated, with matchers under a match: prefix and effects bare.
wayland.windowManager.hyprland.settings.windowrule = [
"float 1, match:class ^(com\\.gabm\\.satty)$"
];
# Print with plain/Shift/Ctrl for region/window/full.
# Super+L is already the hjkl focus and movement bind, so screenshots take
# the Print key instead.
wayland.windowManager.hyprland.settings.bind = [
", Print, exec, ${capture "area"}"
"SHIFT, Print, exec, ${capture "active"}"
"CTRL, Print, exec, ${capture "screen"}"
];
};
};
}

View File

@@ -6,6 +6,8 @@
}:
# Nord theming for the graphical layer, driven from one base16 scheme by Stylix.
let
cfg = config.modules.desktop.utilities.theming;
user = config.user.name;
# A single static Nord wallpaper, drawn as a vertical gradient across the
# Polar Night shades so it needs no committed binary and no network fetch.
@@ -14,13 +16,12 @@ let
-size 2560x1440 gradient:'#2E3440'-'#3B4252' \
"$out"
'';
user = config.user.name;
in
{
options.modules.desktop.utilities.theming.enable =
lib.mkEnableOption "Nord theming of the graphical layer via Stylix";
config = lib.mkIf config.modules.desktop.utilities.theming.enable {
config = lib.mkIf cfg.enable {
stylix = {
enable = true;
@@ -58,10 +59,6 @@ in
};
};
};
registry.env.${config.networking.hostName} = {
HYPRCURSOR_THEME = config.stylix.cursor.name;
HYPRCURSOR_SIZE = toString config.stylix.cursor.size;
};
home-manager.users.${user} = {
# Stylix drives the cursor through home-manager's pointer-cursor config,

View File

@@ -2,12 +2,13 @@
# Declares the XDG user directories, so a tool that queries them lands in a
# well-known folder a host can relocate from one place.
let
cfg = config.modules.desktop.utilities.userdirs;
user = config.user.name;
in
{
options.modules.desktop.utilities.userdirs.enable = lib.mkEnableOption "XDG user directories";
config = lib.mkIf config.modules.desktop.utilities.userdirs.enable {
config = lib.mkIf cfg.enable {
# Write ~/.config/user-dirs.dirs from the option defaults.
home-manager.users.${user}.xdg.userDirs.enable = true;
};

View File

@@ -1,9 +1,12 @@
{ config, lib, ... }:
let
cfg = config.modules.guest;
in
{
options.modules.guest.enable = lib.mkEnableOption "nested guest configuration";
options.modules.guest.enable = lib.mkEnableOption "nested-guest base configuration";
config = lib.mkIf config.modules.guest.enable {
modules.network.ssh.enable = lib.mkDefault false;
config = lib.mkIf cfg.enable {
modules.network.ssh.enable = lib.mkDefault true;
modules.network.ssh.hostKeys.restore = lib.mkDefault false;
};
}

View File

@@ -0,0 +1,52 @@
{
config,
lib,
...
}:
let
cfg = config.modules.host;
user = config.user;
hostSecrets = ../secrets + "/${config.networking.hostName}.yaml";
passwordSecret = "${user.name}-password";
in
{
options.modules.host.enable = lib.mkEnableOption "physical-host base configuration";
config = lib.mkIf cfg.enable {
nix.settings.extra-substituters = [ "https://nyx-cache.chaotic.cx/" ];
nix.settings.extra-trusted-public-keys = [
"nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk="
];
nix.gc.automatic = true;
nix.gc.dates = "Mon 03:15";
nix.gc.options = "--delete-older-than 30d";
nix.optimise.automatic = true;
nix.optimise.dates = [ "Thu 03:45" ];
boot.loader.systemd-boot.enable = true;
boot.loader.systemd-boot.configurationLimit = 15;
boot.loader.efi.canTouchEfiVariables = true;
hardware.enableRedistributableFirmware = true;
zramSwap.enable = true;
modules.network.ssh.enable = true;
modules.network.ssh.hostKeys.sopsFile = hostSecrets;
modules.network.ssh.userKey.sopsFile = hostSecrets;
services.xserver.xkb.layout = "us";
services.xserver.xkb.options = "caps:escape_shifted_capslock";
console.useXkbConfig = true;
sops.defaultSopsFile = ../secrets/shared.yaml;
sops.age.keyFile = "/var/lib/sops-nix/key.txt";
sops.age.sshKeyPaths = [ ];
sops.gnupg.sshKeyPaths = [ ];
users.mutableUsers = false;
sops.secrets.${passwordSecret}.neededForUsers = true;
users.users.${user.name}.hashedPasswordFile = config.sops.secrets.${passwordSecret}.path;
};
}

View File

@@ -1,18 +0,0 @@
{
config,
lib,
pkgs,
...
}:
{
options.modules.host.cachyos.enable = lib.mkEnableOption "the CachyOS kernel";
config = lib.mkIf config.modules.host.cachyos.enable {
boot.kernelPackages = pkgs.linuxPackages_cachyos;
nix.settings.extra-substituters = [ "https://nyx-cache.chaotic.cx/" ];
nix.settings.extra-trusted-public-keys = [
"nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk="
];
};
}

View File

@@ -1,106 +0,0 @@
{
config,
lib,
pkgs,
...
}:
let
hostSecrets = ../../secrets + "/${config.networking.hostName}.yaml";
in
{
options.modules.host.enable = lib.mkEnableOption "physical host configuration";
config = lib.mkIf config.modules.host.enable {
nix.gc.automatic = true;
nix.gc.dates = "Mon 03:15";
nix.gc.options = "--delete-older-than 30d";
nix.optimise.automatic = true;
nix.optimise.dates = [ "Thu 03:45" ];
boot.loader.systemd-boot.enable = true;
boot.loader.systemd-boot.configurationLimit = 15;
boot.loader.efi.canTouchEfiVariables = true;
hardware.enableRedistributableFirmware = true;
zramSwap.enable = true;
modules.network.ssh.enable = true;
modules.network.ssh.hostKeys.sopsFile = hostSecrets;
modules.network.ssh.userKey.sopsFile = hostSecrets;
services.xserver.xkb.layout = "us";
console.useXkbConfig = true;
sops.defaultSopsFile = ../../secrets/shared.yaml;
sops.age.keyFile = "/var/lib/sops-nix/key.txt";
sops.age.sshKeyPaths = [ ];
sops.gnupg.sshKeyPaths = [ ];
users.mutableUsers = false;
sops.secrets."${config.user.name}-password".neededForUsers = true;
users.users.${config.user.name}.hashedPasswordFile =
config.sops.secrets."${config.user.name}-password".path;
security.sudo.extraConfig = ''
Defaults env_keep += "EDITOR VISUAL"
'';
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
users.${config.user.name} = {
home.username = config.user.name;
home.homeDirectory = "/home/${config.user.name}";
home.stateVersion = config.system.stateVersion;
home.sessionVariables = lib.attrByPath [ config.networking.hostName ] { } config.registry.env;
home.packages = with pkgs; [
eza
unzip
wget
];
};
};
registry.shell-shortcut.${config.networking.hostName} = {
ls = {
command = "eza -alg --color=always --group-directories-first --icons=always";
abbreviation = false;
};
ll = {
command = "eza -lg --color=always --group-directories-first --icons=always";
abbreviation = false;
};
lt = {
command = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
abbreviation = false;
};
".." = "cd ..";
"..." = "cd ../..";
"...." = "cd ../../..";
cp = "cp -v";
tarnow = "tar -acf ";
untar = "tar -zxvf ";
wget = "wget -c ";
grep = "grep --color=auto";
fgrep = "fgrep --color=auto";
egrep = "egrep --color=auto";
jctl = "journalctl -p 3 -xb";
update = "sudo nixos-rebuild switch --flake .#${config.networking.hostName}";
cleanup = "sudo nix-collect-garbage -d";
sops-host = {
command = "sudo --preserve-env=EDITOR,VISUAL env SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt nix shell nixpkgs#sops nixpkgs#neovim -c sops";
abbreviation = false;
};
};
modules.shell.fish.enable = lib.mkDefault true;
modules.shell.fish.defaultShell = lib.mkDefault true;
modules.shell.tmux.enable = lib.mkDefault true;
modules.shell.nvim.enable = lib.mkDefault true;
modules.shell.git.enable = lib.mkDefault true;
modules.shell.yazi.enable = lib.mkDefault true;
};
}

View File

@@ -1,57 +0,0 @@
{
config,
lib,
...
}:
# Imports existing ZFS pools that hold durable service state.
let
cfg = config.modules.host.zfs; # NOLINT(let_order)
in
{
options.modules.host.zfs = {
enable = lib.mkEnableOption "ZFS pool imports for durable service state";
pools = lib.mkOption {
type = lib.types.attrsOf (lib.types.attrsOf lib.types.path);
default = { };
example = lib.literalExpression ''
{
tank = {
media = "/srv/media";
downloads = "/srv/downloads";
};
}
'';
description = ''
Lists pools that NixOS imports at boot.
Maps each dataset name to its mount point.
NixOS does not create or destroy these pools during rebuilds.
An empty map imports the pool without declaring file systems.
'';
};
};
config = lib.mkIf cfg.enable {
boot.supportedFilesystems = [ "zfs" ];
# This module derives a stable host ID from the configured hostname.
networking.hostId = builtins.substring 0 8 (
builtins.hashString "sha256" config.networking.hostName
);
boot.zfs.extraPools = lib.attrNames cfg.pools;
fileSystems = lib.mkMerge (
lib.mapAttrsToList (
pool: mounts:
lib.mapAttrs' (
dataset: mountpoint:
lib.nameValuePair mountpoint {
device = "${pool}/${dataset}";
fsType = "zfs";
}
) mounts
) cfg.pools
);
};
}

View File

@@ -4,10 +4,13 @@
...
}:
# Tools for developing this monorepo.
let
cfg = config.modules.monorepo;
in
{
options.modules.monorepo.enable = lib.mkEnableOption "tools for developing this monorepo";
config = lib.mkIf config.modules.monorepo.enable {
config = lib.mkIf cfg.enable {
modules.shell.bazel.enable = true;
modules.shell.python.enable = true;
modules.shell.rust.enable = true;

View File

@@ -1,57 +0,0 @@
{
config,
lib,
pkgs,
util,
...
}:
# NetworkManager-managed networking.
let
networkStatus = pkgs.writeShellScript "waybar-network-status" ''
active=$(
${lib.getExe' pkgs.networkmanager "nmcli"} -t -f DEVICE,TYPE,STATE,CONNECTION device status \
| ${lib.getExe' pkgs.gawk "awk"} -F: '$3 == "connected" && $2 != "loopback" { print; exit }'
)
if [ -z "$active" ]; then
${lib.getExe pkgs.jq} -cn \
--arg text "${util.glyph "f127"} off" \
--arg tooltip "Network disconnected" \
--arg class "disconnected" \
'{ text: $text, tooltip: $tooltip, class: $class }'
exit 0
fi
IFS=: read -r device type _state connection <<< "$active"
case "$type" in
wifi) icon="${util.glyph "f1eb"}" ;;
ethernet) icon="LAN" ;;
*) icon="NET" ;;
esac
ipaddr=$(${lib.getExe' pkgs.iproute2 "ip"} -4 -o addr show dev "$device" | ${lib.getExe' pkgs.gawk "awk"} '{ print $4; exit }')
${lib.getExe pkgs.jq} -cn \
--arg text "$icon $connection" \
--arg tooltip "$device: ''${ipaddr:-no IPv4}" \
--arg class "$type" \
'{ text: $text, tooltip: $tooltip, class: $class }'
'';
in
{
options.modules.network.enable = lib.mkEnableOption "NetworkManager-managed networking";
config = lib.mkIf config.modules.network.enable {
networking.networkmanager.enable = true;
registry.waybar.${config.networking.hostName}."custom/network" = {
order = 60;
settings = {
return-type = "json";
exec = "${networkStatus}";
interval = 5;
};
};
};
}

View File

@@ -4,207 +4,343 @@
registry,
...
}:
# Configures inbound and outbound SSH access.
# SSH on this machine, in both directions.
let
cfg = config.modules.network.ssh;
user = config.user.name; # NOLINT(let_order)
user = config.user.name;
# NOLINT(let_order)
workstationNames = [
"neogaia"
"zeus"
];
serverNames = [ "pikachu" ];
inherit (lib)
concatLists
concatStringsSep
elem
filter
genAttrs
hasAttr
imap0
listToAttrs
mapAttrs
mapAttrsToList
mkIf
mkMerge
mkOption
nameValuePair
optional
optionalAttrs
types
unique
;
isWorkstation = lib.elem config.networking.hostName workstationNames; # NOLINT(let_order)
isServer = lib.elem config.networking.hostName serverNames; # NOLINT(let_order)
hostKeySecret = type: "ssh-host-${type}-key";
userKeySecret = "ssh-user-ed25519-key";
# NOLINT(let_order)
clientKeyFor = host: builtins.readFile (../../hosts + "/${host}/ssh_user_ed25519_key.pub");
hostKeysFor =
host:
map builtins.readFile [
(../../hosts + "/${host}/ssh_host_ed25519_key.pub")
(../../hosts + "/${host}/ssh_host_rsa_key.pub")
];
targetType = types.submodule (
{ name, ... }:
{
options = {
hostName = mkOption {
type = types.str;
default = name;
description = ''
The network address OpenSSH connects to for this target.
'';
};
# NOLINT(let_order)
managedTargets = lib.genAttrs (workstationNames ++ serverNames) (name: {
hostName = registry.address.${name}.self;
clientKey = clientKeyFor name;
hostKeys = hostKeysFor name;
});
user = mkOption {
type = types.str;
default = config.user.name;
description = ''
The remote login name OpenSSH uses for this target.
'';
};
extraTargets = {
# TODO: Pin Gitea's host key after migrating it from Proxmox.
gitea = {
hostName = "git.alexion.dev";
port = 2022;
user = "gitea";
aliases = [
"gitea"
"git.alexion.dev"
];
};
};
port = mkOption {
type = types.port;
default = 22;
description = ''
The TCP port OpenSSH uses for this target.
'';
};
# NOLINT(let_order)
mkTarget = name: target: {
hostName = target.hostName or name;
user = target.user or user;
port = target.port or 22;
aliases = target.aliases or [ name ];
clientKey = target.clientKey or null;
hostKeys = target.hostKeys or [ ];
};
targets = lib.mapAttrs mkTarget (managedTargets // extraTargets); # NOLINT(let_order)
aliases = mkOption {
type = types.listOf types.str;
default = [ name ];
description = ''
Host patterns written into the generated OpenSSH client block.
'';
};
clientKeysFor = names: lib.filter (key: key != null) (map (name: targets.${name}.clientKey) names); # NOLINT(let_order)
# NOLINT(let_order)
defaultAuthorizedKeys = lib.flatten (
clientKeysFor workstationNames ++ lib.optional isServer (clientKeysFor serverNames)
clientKey = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The public key this target offers when it connects outward.
Other machines admit this key according to the host groups below.
'';
};
hostKeys = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
The public keys this target presents when it accepts inbound SSH.
These keys generate system-wide known-host entries.
'';
};
};
}
);
outboundTargetNames = # NOLINT(let_order)
keyWithoutComment = key: concatStringsSep " " (lib.take 2 (lib.splitString " " key));
targetNamesIn = names: filter (name: hasAttr name cfg.targets) names;
workstationNames = targetNamesIn cfg.hosts.workstations;
serverNames = targetNamesIn cfg.hosts.servers;
clientKeysFor = names: filter (key: key != null) (map (name: cfg.targets.${name}.clientKey) names);
currentHost = config.networking.hostName;
isServer = elem currentHost cfg.hosts.servers;
isWorkstation = elem currentHost cfg.hosts.workstations;
defaultAuthorizedKeys = lib.flatten (
clientKeysFor workstationNames
++ optional isServer (clientKeysFor serverNames)
);
outboundTargetNames =
let
groupTargets =
if isServer then
builtins.attrNames extraTargets ++ serverNames
[ "gitea" ] ++ cfg.hosts.servers
else if isWorkstation then
builtins.attrNames extraTargets ++ serverNames ++ workstationNames
[ "gitea" ] ++ cfg.hosts.servers ++ cfg.hosts.workstations
else
builtins.attrNames extraTargets;
[ "gitea" ];
in
lib.filter (name: name != config.networking.hostName) groupTargets;
filter (name: name != currentHost) (targetNamesIn groupTargets);
knownHosts = lib.listToAttrs (
lib.concatLists (
lib.mapAttrsToList (
sshSettingsFor = name:
let
target = cfg.targets.${name};
in
{
header = "Host ${concatStringsSep " " target.aliases}";
HostName = target.hostName;
User = target.user;
}
// optionalAttrs (target.port != 22) { Port = target.port; };
knownHostNamesFor = target:
let
names = unique (target.aliases ++ [ target.hostName ]);
withPort = name: if target.port == 22 then name else "[${name}]:${toString target.port}";
in
map withPort names;
knownHosts = listToAttrs (
concatLists (
mapAttrsToList (
targetName: target:
lib.imap0 (
i: key:
lib.nameValuePair "${targetName}-${toString i}" {
imap0 (i: key:
nameValuePair "${targetName}-${toString i}" {
hostNames = knownHostNamesFor target;
publicKey = keyWithoutComment key;
}
) target.hostKeys
) targets
) cfg.targets
)
);
knownHostNamesFor =
target:
let
withPort = name: if target.port == 22 then name else "[${name}]:${toString target.port}";
names = lib.unique (target.aliases ++ [ target.hostName ]);
in
map withPort names;
keyWithoutComment = key: lib.concatStringsSep " " (lib.take 2 (lib.splitString " " key));
sshSettingsFor =
name:
let
target = targets.${name};
in
{
header = "Host ${lib.concatStringsSep " " target.aliases}";
HostName = target.hostName;
User = target.user;
}
// lib.optionalAttrs (target.port != 22) { Port = target.port; };
hostKeySecret = type: "ssh-host-${type}-key";
hostKeyTypes = [
"ed25519"
"rsa"
];
userKeySecret = "ssh-user-ed25519-key";
in
{
options.modules.network.ssh = {
enable = lib.mkEnableOption "the OpenSSH daemon";
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets";
hostKeys.restore = lib.mkOption {
type = lib.types.bool;
hosts = {
servers = mkOption {
type = types.listOf types.str;
default = [ "pikachu" ];
description = ''
Hosts that serve durable services.
They admit workstation keys and server keys, and they receive aliases for other servers and the forge.
'';
};
workstations = mkOption {
type = types.listOf types.str;
default = [
"neogaia"
"zeus"
];
description = ''
Hosts the operator works from.
Their keys are admitted by every host, and they receive aliases for the whole fleet and the forge.
'';
};
};
targets = mkOption {
type = types.attrsOf targetType;
default = {
gitea = {
hostName = "git.alexion.dev";
port = 2022;
user = "gitea";
aliases = [
"gitea"
"git.alexion.dev"
];
};
neogaia = {
hostName = registry.address.neogaia.self;
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia";
hostKeys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p neogaia"
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCo2wWUKxyAS4J5TqbWf8glDhJvS5XmdRqFhMeJwG3pOB+4AccZ1T8LU7ZN+RjtRi3j2qXBJvIHuzhtQNtmT59TxocvfobYiqOgJpvVO5K6yD8ZoUJs6ziDkIduI9w9mdRIESoi+dBbVu8n24r61cKDVh+jWX+yjzkOcWcOzqDyQhhkjqblZ1WMAdujEMuEPvif1i2LCxStUaZqRGcx09m/ME2fYcaJrpuxxxvX2+CPJNicoo6Rx9i7ZjAoNuvH+jui4KT62DzlQtQtCl2CFUOM0gCPSa+MbNQ9elfHPvGzEcwOIMo2cuy9KURUkQu+sAgaG8S1PEniDDTecskHtuRdmPZawnQGpIhzo919Q6wUgjT8scK4mmSXRWmGmkMt0GNA2tfj5tDks6r5Q8XsYqtWs4rsOEvfmxVSdM771w+fqDBAil99Jsh0ksPK9+Bwgg8cMDzLLFDn8JA5y2G1HocMMom+u5DYKwPXEKnCILkasB8y24+O3PhSu1EuWw277w6EUEXvU03rCf0Ak/ULjxp9a00EGlloEwSmFI7Aub9XHDr87IdbGInEn+PMqyBYADiN+3h6nE2JO+nMa6i/CHdebmT+T7YJvuTKHD9sjFmQsYaghlq03DZrhHcm4hgUvE1dqGojHrhk/WgA3EWTWtK/+BP0Vy2jXaaz+qAx+EGnhQ== neogaia"
];
};
zeus = {
hostName = registry.address.zeus.self;
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIRBcHHuubptXCdvED/p2dR3MpjGN/AimqLasLY5984G alexion@zeus";
hostKeys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ4GBPt966LR4LpmXgiMMkepIqNIlXTUBh4zdNeek/lN root@zeus"
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC3xOxGAJCG9PE0FVufgJX9VRO64IUUlRGHSOfYfAI4Mit46xKsr7z/OvqPPE9z8rOIcVPbLkc/mkMuHbDgolleunAFCIeuIIYpkL4o4/TZECh9BtN2WdPhjcD7S0rV+rR7p24Qy5bTCSwZmsQq8QxHTVPjjBngtPGRSpKVrZAuHCcdCJ13hBWAvnaav5KysKC89nMj/7FIh02v8kipGfq5LGCZ20Qp2lmv2DS24NhBPXzcw8pLN47I5GYVkZh/0RWOByteo3eXtKCF+JCwfUPCTP+zHGD+1tbO8//B2fkt3Pe9lUrn5eM2ZnX1emXSWfQng3cMqkftzWqvBwv0sSDU+9YV6H2Ury+GrrfukpBtQVe9PQcBWqbRPhxuagncR+af/tx0ZohZS0t74m3WgLtZ4r2mpTZS4oBmRsfvWzMFGSgwtva+3Y+NsLjPPM1Cz5Dn05JJlidzcABfsUg2H23vkmO2otaQI01dUni+szlRn4w6sVyxNcvY2fQGjmt9k7jmVI5D2A3yl+OLJguAAlw3ia335X1tNUG8FoI/VyZNhOckIihmbTtihNIg37Ro4yiuPRhnUWFvg2hcBCa9hzNP7XDUPnoCRQyJ4adQHQA/x4Kh+HJ6pZqtGcdqU80fzmWtrLkWG6v5/NRKs6soYDup7oQIKbJo9TtXvqtzmVmovQ== root@zeus"
];
};
pikachu = {
hostName = registry.address.pikachu.self;
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu";
hostKeys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu"
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy/riwm7dflA3mT+3a0/2CIoS2LbAsK/vn35kOoNeuzn0yhiF+imexP6tkB3S2t+H5ybRzkbbuNZcynFfeCqthFc8kvbdCnt8Diqoeg96fZ6ecvh5QE5yH9op8534EySetZ/exakFLnF+6EiWMuWUW3DFwsc2kcgDJObqSE8gTx/d7JK953MiTFmSJBFyg1RtQ3ZnMT+iCrvY2dyCLQai7VeF8koVKF2c0leAq2Hc75rb/L9md8MoJa64iPiz7hwTCin3xoFyaY/5hNVvyqFd5PivgR69gLdJkuVsUYO2mJzhur8cYmJD+pGjJ0U45hyE9TMrCFjeJHHuvSt3+2kph62wv95jLNk0WmMlwgyunISxENCSVVtNYdBMXhUh8VhEAW17QpVUg9EnPvxOdTKEjrvfOZYASWUa51JKbgBgexVgFbxdjDZR88DZa31AVBts/cx/59gXTUahFXMYLdZgssx+5uibZQWnvCyfUV9WLbfmK1lgL6hzReg1VkQ87iGr6skjtQYemJxRaFNA1+Q5f3kmG3KncuK/594a3qXYP4gC6A2blf8om1YZ4aXXh6f+GFKLjoEw1vvM2rJ+rjzfymwDX+pxVQ9L13OEtVZc9Ez76pOkbm1hqdbL0gY45+0cpxodhWV0wMQJBDXL1MHP8qcs+/vw0GxVK5l1SnWBGlw== root@pikachu"
];
};
};
description = ''
SSH targets known to the fleet.
The inventory holds connection details plus public keys used for authorization and host verification.
'';
};
extraAuthorizedKeys = mkOption {
type = types.listOf types.str;
default = [ ];
description = ''
Additional client public keys admitted by this host.
'';
};
authorizedKeys = mkOption {
type = types.nullOr (types.listOf types.str);
default = null;
description = ''
Complete override for client public keys admitted by this host.
Leave null to derive access from `modules.network.ssh.hosts` and `modules.network.ssh.targets`.
'';
};
extraSettings = mkOption {
type = types.attrsOf types.anything;
default = { };
description = ''
Additional OpenSSH client settings merged into the generated Home Manager configuration.
'';
};
hostKeys.restore = mkOption {
type = types.bool;
default = true;
description = ''
Restores host keys from secrets.
Preserves the fingerprint across reimages.
Guests disable this option and generate their own host keys.
Restore the host keys from secrets rather than letting the daemon generate its own.
A machine with its own identity keeps its fingerprint across a reimage by restoring committed keys.
A guest carries no host identity, so it turns this off and presents a self-generated key instead.
'';
};
hostKeys.sopsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
hostKeys.sopsFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Sets encrypted file that contains host private keys.
Names each secret `ssh-host-<type>-key`.
Requires a value when `restore` is enabled.
Encrypted file holding this host's SSH host private keys, one entry per key type, named `ssh-host-<type>-key`.
Required when `restore` is on.
'';
};
userKey.sopsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
hostKeys.types = mkOption {
type = types.listOf types.str;
default = [
"ed25519"
"rsa"
];
description = ''
Key types to restore, naming both the entries read from the encrypted file and the algorithms the daemon offers.
'';
};
userKey.sopsFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Sets encrypted file that contains the SSH client private key.
Names the secret `ssh-user-ed25519-key`.
Guests leave this unset because they do not authenticate to remote servers.
Encrypted file holding this machine's SSH client private key, under the entry `ssh-user-ed25519-key`.
Left unset on a machine that authenticates to no remote server, such as a guest.
'';
};
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
config = mkIf cfg.enable (
mkMerge [
{
services.openssh.enable = true;
users.users.${user}.openssh.authorizedKeys.keys = defaultAuthorizedKeys;
users.users.${user}.openssh.authorizedKeys.keys =
if cfg.authorizedKeys != null then
cfg.authorizedKeys
else
defaultAuthorizedKeys ++ cfg.extraAuthorizedKeys;
programs.ssh.knownHosts = knownHosts;
home-manager.users.${user}.programs.ssh = {
enable = true;
enableDefaultConfig = false;
settings = lib.mapAttrs (name: _: sshSettingsFor name) (
lib.genAttrs outboundTargetNames (name: name)
);
settings =
mapAttrs (name: _: sshSettingsFor name) (genAttrs outboundTargetNames (name: name))
// cfg.extraSettings;
};
}
(lib.mkIf cfg.hostKeys.restore {
(mkIf cfg.hostKeys.restore {
assertions = [
{
assertion = cfg.hostKeys.sopsFile != null;
message = "Set `modules.network.ssh.hostKeys.sopsFile` when host-key restoration is enabled.";
message = "modules.network.ssh.hostKeys.restore requires modules.network.ssh.hostKeys.sopsFile to name the encrypted host keys.";
}
];
sops.secrets = lib.genAttrs (map hostKeySecret hostKeyTypes) (_: {
sops.secrets = genAttrs (map hostKeySecret cfg.hostKeys.types) (_: {
inherit (cfg.hostKeys) sopsFile;
mode = "0400";
restartUnits = [ "sshd.service" ];
});
services.openssh.hostKeys = [ ];
services.openssh.extraConfig = lib.concatStringsSep "" (
map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") hostKeyTypes
services.openssh.extraConfig = concatStringsSep "" (
map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") cfg.hostKeys.types
);
})
(lib.mkIf (cfg.userKey.sopsFile != null) {
(mkIf (cfg.userKey.sopsFile != null) {
sops.secrets.${userKeySecret} = {
inherit (cfg.userKey) sopsFile;
mode = "0400";
owner = user;
};
home-manager.users.${user}.programs.ssh.settings."*" = {
IdentityFile = config.sops.secrets.${userKeySecret}.path;
IdentitiesOnly = true;
};
home-manager.users.${user}.programs.ssh.settings."*".IdentityFile =
config.sops.secrets.${userKeySecret}.path;
})
]
);

View File

@@ -4,17 +4,24 @@
util,
...
}:
# Configures bridges for tagged VLANs on a physical interface that uses DHCP.
# The host networking foundation: per-VLAN bridges over a tagged trunk.
let
cfg = config.modules.network.vlans; # NOLINT(let_order)
cfg = config.modules.network.vlans;
inherit (util) bridgeName;
vlanName = id: "${cfg.trunk}.${toString id}";
in
{
options.modules.network.vlans = {
interface = lib.mkOption {
enable = lib.mkEnableOption "host VLAN bridges over a tagged trunk";
trunk = lib.mkOption {
type = lib.types.str;
example = "enp1s0";
description = ''
Names the physical interface that carries untagged DHCP traffic and tagged VLANs.
Physical interface that carries 802.1Q-tagged traffic for every VLAN.
Each declared VLAN attaches to its own bridge.
'';
};
@@ -27,37 +34,66 @@ in
30
];
description = ''
Creates bridges for the specified tagged VLAN IDs.
VLAN ID N creates bridge `br-vlanN`.
Tagged VLAN ids to materialize.
Each id N creates one bridge named `br-vlanN`.
'';
};
management = {
vlan = lib.mkOption {
type = lib.types.nullOr (lib.types.ints.between 1 4094);
default = null;
description = ''
VLAN whose bridge carries the host's own management address.
The value must be one of `ids`.
Left null, the host takes no address on any bridge.
'';
};
address = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "10.0.10.2/24";
description = ''
Host static management address in CIDR form.
Left null, the host takes its management address by DHCP.
'';
};
};
};
config = lib.mkIf (cfg.ids != [ ]) {
networking = {
useDHCP = false;
networkmanager.unmanaged = [
"interface-name:${cfg.interface}"
]
++ map (id: "interface-name:${util.bridgeName id}") cfg.ids
++ map (id: "interface-name:vlan${toString id}") cfg.ids;
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.management.vlan == null || lib.elem cfg.management.vlan cfg.ids;
message = "modules.network.vlans.management.vlan (${toString cfg.management.vlan}) must name one of modules.network.vlans.ids.";
}
{
assertion = cfg.management.address == null || cfg.management.vlan != null;
message = "modules.network.vlans.management.address needs modules.network.vlans.management.vlan to say which bridge carries it.";
}
];
# networkd owns these interfaces so NetworkManager does not contend for them.
networking.networkmanager.unmanaged =
[ "interface-name:${cfg.trunk}" ]
++ map (id: "interface-name:${bridgeName id}") cfg.ids
++ map (id: "interface-name:${vlanName id}") cfg.ids;
systemd.network = {
enable = true;
netdevs = lib.mkMerge (
map (id: {
"40-${util.bridgeName id}" = {
"40-${bridgeName id}" = {
netdevConfig = {
Name = util.bridgeName id;
Name = bridgeName id;
Kind = "bridge";
};
};
"40-vlan${toString id}" = {
"40-${vlanName id}" = {
netdevConfig = {
Name = "vlan${toString id}";
Name = vlanName id;
Kind = "vlan";
};
vlanConfig.Id = id;
@@ -67,25 +103,37 @@ in
networks = lib.mkMerge (
[
# The trunk carries only tagged frames and takes no address.
{
"10-${cfg.interface}" = {
matchConfig.Name = cfg.interface;
networkConfig.DHCP = "yes";
vlan = map (id: "vlan${toString id}") cfg.ids;
linkConfig.RequiredForOnline = "routable";
"30-${cfg.trunk}" = {
matchConfig.Name = cfg.trunk;
networkConfig.LinkLocalAddressing = "no";
linkConfig.RequiredForOnline = "no";
vlan = map vlanName cfg.ids;
};
}
]
++ map (id: {
"40-vlan${toString id}" = {
matchConfig.Name = "vlan${toString id}";
networkConfig.Bridge = util.bridgeName id;
linkConfig.RequiredForOnline = "no";
};
"40-${util.bridgeName id}" = {
matchConfig.Name = util.bridgeName id;
# Enslaved to its bridge, carrying no address itself.
"40-${vlanName id}" = {
matchConfig.Name = vlanName id;
networkConfig.Bridge = bridgeName id;
linkConfig.RequiredForOnline = "no";
};
# The management bridge carries the host address.
# Other bridges provide L2 segments for guests.
"40-${bridgeName id}" = lib.mkMerge [
{
matchConfig.Name = bridgeName id;
linkConfig.RequiredForOnline = "no";
}
(lib.mkIf (cfg.management.vlan == id) (
if cfg.management.address == null then
{ networkConfig.DHCP = "yes"; }
else
{ address = [ cfg.management.address ]; }
))
];
}) cfg.ids
);
};

View File

@@ -1,34 +1,10 @@
{
config,
lib,
pkgs,
util,
...
}:
let
cfg = config.modules.network.vpn-client; # NOLINT(let_order)
vpnIcon = util.glyph [
"db83"
"dc9d"
];
vpnStatus = pkgs.writeShellScript "waybar-vpn-status" ''
interface=${lib.escapeShellArg cfg.interface}
if ! ${lib.getExe' pkgs.iproute2 "ip"} link show dev "$interface" >/dev/null 2>&1; then
printf '{"text":"","class":"idle"}\n'
exit 0
fi
ipaddr=$(${lib.getExe' pkgs.iproute2 "ip"} -4 -o addr show dev "$interface" | ${lib.getExe' pkgs.gawk "awk"} '{ print $4; exit }')
${lib.getExe pkgs.jq} -cn \
--arg text "${vpnIcon}" \
--arg tooltip "VPN $interface: ''${ipaddr:-no IPv4}" \
--arg class "active" \
'{ text: $text, tooltip: $tooltip, class: $class }'
'';
cfg = config.modules.network.vpn-client;
in
{
options.modules.network.vpn-client = {
@@ -37,31 +13,61 @@ in
interface = lib.mkOption {
type = lib.types.str;
default = "wg-home";
description = "Names the WireGuard interface.";
description = "WireGuard interface name.";
};
address = lib.mkOption {
type = lib.types.str;
example = "10.23.100.3/32";
description = "Sets the client tunnel address in CIDR notation.";
description = "Client tunnel address in CIDR notation.";
};
privateKeySecret = lib.mkOption {
type = lib.types.str;
default = "wireguard-home-private-key";
description = "SOPS secret containing the WireGuard client private key.";
};
autoStart = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to start the VPN tunnel during boot.";
};
mtu = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = 1280;
description = "Tunnel MTU, or null to use the WireGuard default.";
};
sopsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Names the encrypted file that contains the WireGuard private key.";
description = "Encrypted secrets file that contains privateKeySecret.";
};
peer = {
publicKey = lib.mkOption {
type = lib.types.str;
description = "Sets the WireGuard public key for the remote peer.";
description = "WireGuard public key for the remote peer.";
};
endpoint = lib.mkOption {
type = lib.types.str;
example = "203.0.113.10:51820";
description = "Sets the WireGuard endpoint for the remote peer.";
description = "WireGuard endpoint for the remote peer.";
};
allowedIPs = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "10.23.0.0/16" ];
description = "Routes to send through the VPN tunnel.";
};
persistentKeepalive = lib.mkOption {
type = lib.types.nullOr lib.types.int;
default = 25;
description = "Persistent keepalive interval in seconds, or null to disable it.";
};
};
};
@@ -70,39 +76,28 @@ in
assertions = [
{
assertion = cfg.sopsFile != null;
message = "Set modules.network.vpn-client.sopsFile when the VPN client is enabled.";
message = "modules.network.vpn-client.sopsFile must name the encrypted secrets file containing the WireGuard private key.";
}
];
sops.secrets.wireguard-home-private-key.sopsFile = cfg.sopsFile;
sops.secrets.${cfg.privateKeySecret}.sopsFile = cfg.sopsFile;
registry.shell-shortcut.${config.networking.hostName} = {
vpn-on = "sudo systemctl start wireguard-${cfg.interface}.target";
vpn-off = "sudo systemctl stop 'wireguard-${cfg.interface}*.service' wireguard-${cfg.interface}.target";
systemd.targets."wireguard-${cfg.interface}" = lib.mkIf (!cfg.autoStart) {
wantedBy = lib.mkForce [ ];
};
registry.waybar.${config.networking.hostName}."custom/vpn" = {
order = 50;
settings = {
return-type = "json";
exec = "${vpnStatus}";
interval = 5;
};
};
systemd.targets."wireguard-${cfg.interface}".wantedBy = lib.mkForce [ ];
networking.wireguard.interfaces.${cfg.interface} = {
ips = [ cfg.address ];
mtu = 1280;
privateKeyFile = config.sops.secrets.wireguard-home-private-key.path;
mtu = cfg.mtu;
privateKeyFile = config.sops.secrets.${cfg.privateKeySecret}.path;
peers = [
{
inherit (cfg.peer) publicKey endpoint;
allowedIPs = [ "10.23.0.0/16" ];
persistentKeepalive = 25;
({
inherit (cfg.peer) publicKey endpoint allowedIPs;
}
// lib.optionalAttrs (cfg.peer.persistentKeepalive != null) {
inherit (cfg.peer) persistentKeepalive;
})
];
};
};

View File

@@ -4,7 +4,7 @@
...
}:
let
cfg = config.modules.services.actualbudget; # NOLINT(let_order)
cfg = config.modules.services.actualbudget;
in
{
options.modules.services.actualbudget = {
@@ -45,14 +45,14 @@ in
users.users.${cfg.user} = {
isSystemUser = true;
uid = config.registry."stable-uid".${config.networking.hostName}.actualbudget;
inherit (cfg) group;
group = cfg.group;
};
services.actual = {
enable = true;
openFirewall = true;
inherit (cfg) user;
inherit (cfg) group;
user = cfg.user;
group = cfg.group;
settings = {
inherit (cfg) port dataDir;
};

View File

@@ -5,7 +5,7 @@
...
}:
let
cfg = config.modules.services.copyparty; # NOLINT(let_order)
cfg = config.modules.services.copyparty;
volumeType = lib.types.submodule {
options = {
@@ -23,12 +23,6 @@ let
description = "Copyparty access map from permission string to account names.";
};
readOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Serve this volume read-only.";
};
flags = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
@@ -38,6 +32,37 @@ let
};
};
renderAccess = permission: accounts: " ${permission}: ${lib.concatStringsSep ", " accounts}";
hasFlag = name: flags: lib.any (flag: lib.hasPrefix "${name}:" flag || lib.hasPrefix "${name}=" flag) flags;
defaultPermissionFlags = flags:
lib.optionals (!(hasFlag "chmod_d" flags)) [ "chmod_d: 2770" ]
++ lib.optionals (!(hasFlag "chmod_f" flags)) [ "chmod_f: 660" ];
renderFlags = flags: lib.concatMapStringsSep "\n" (flag: " ${flag}") (flags ++ defaultPermissionFlags flags);
renderVolume = urlPath: volume: ''
[${urlPath}]
${volume.path}
accs:
${lib.concatStringsSep "\n" (lib.mapAttrsToList renderAccess volume.access)}
${lib.optionalString (volume.flags != [ ]) ''
flags:
${renderFlags volume.flags}
''}
'';
renderTrustedSource = source: " xff-src: ${source}";
renderedReverseProxy = lib.optionalString cfg.reverseProxy.enable ''
rproxy: 1
xff-hdr: x-forwarded-for
${lib.concatStringsSep "\n" (map renderTrustedSource cfg.reverseProxy.trustedSources)}
'';
renderedVolumes = lib.concatStringsSep "\n" (lib.mapAttrsToList renderVolume cfg.volumes);
configScript = pkgs.writeShellScript "copyparty-config" ''
set -eu
@@ -63,40 +88,6 @@ let
${renderedVolumes}
EOF
'';
renderAccess = permission: accounts: " ${permission}: ${lib.concatStringsSep ", " accounts}";
hasFlag =
name: flags: lib.any (flag: lib.hasPrefix "${name}:" flag || lib.hasPrefix "${name}=" flag) flags;
defaultPermissionFlags =
flags:
lib.optionals (!(hasFlag "chmod_d" flags)) [ "chmod_d: 2770" ]
++ lib.optionals (!(hasFlag "chmod_f" flags)) [ "chmod_f: 660" ];
renderFlags =
flags: lib.concatMapStringsSep "\n" (flag: " ${flag}") (flags ++ defaultPermissionFlags flags);
renderVolume = urlPath: volume: ''
[${urlPath}]
${volume.path}
accs:
${lib.concatStringsSep "\n" (lib.mapAttrsToList renderAccess volume.access)}
${lib.optionalString (volume.flags != [ ]) ''
flags:
${renderFlags volume.flags}
''}
'';
renderTrustedSource = source: " xff-src: ${source}";
renderedReverseProxy = lib.optionalString cfg.reverseProxy.enable ''
rproxy: 1
xff-hdr: x-forwarded-for
${lib.concatStringsSep "\n" (map renderTrustedSource cfg.reverseProxy.trustedSources)}
'';
renderedVolumes = lib.concatStringsSep "\n" (lib.mapAttrsToList renderVolume cfg.volumes);
in
{
options.modules.services.copyparty = {
@@ -176,7 +167,7 @@ in
users.users.${cfg.user} = {
isSystemUser = true;
uid = config.registry."stable-uid".${config.networking.hostName}.copyparty;
inherit (cfg) group;
group = cfg.group;
home = cfg.stateDir;
};
@@ -205,10 +196,7 @@ in
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [
cfg.stateDir
]
++ map (volume: volume.path) (lib.filter (volume: !volume.readOnly) (lib.attrValues cfg.volumes));
ReadWritePaths = [ cfg.stateDir ] ++ map (volume: volume.path) (lib.attrValues cfg.volumes);
};
};

View File

@@ -56,12 +56,6 @@
description = "Advertise the server in Palworld's public lobby.";
};
autoStart = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Start the Palworld server after the guest reaches network readiness.";
};
rcon = {
enable = lib.mkEnableOption "Palworld RCON" // {
default = true;
@@ -132,20 +126,20 @@
inherit (service) steamDir installDir stateDir;
serviceName = "palworld-${lib.toLower service.serverName}";
appId = 2394010;
inherit (service) autoStart;
executable = "${startScript}";
arguments = [
"-useperfthreads"
"-NoAsyncLoadingThread"
"-UseMultithreadForDS"
"-port=${toString service.port}"
"-publicport=${toString service.port}"
"-servername=${service.serverName}"
"-players=${toString service.maxPlayers}"
]
++ lib.optional service.publicLobby "-publiclobby"
++ lib.optionals (service.publicIP != null) [ "-publicip=${service.publicIP}" ]
++ lib.optional service.rcon.enable "-rcon";
arguments =
[
"-useperfthreads"
"-NoAsyncLoadingThread"
"-UseMultithreadForDS"
"-port=${toString service.port}"
"-publicport=${toString service.port}"
"-servername=${service.serverName}"
"-players=${toString service.maxPlayers}"
]
++ lib.optional service.publicLobby "-publiclobby"
++ lib.optionals (service.publicIP != null) [ "-publicip=${service.publicIP}" ]
++ lib.optional service.rcon.enable "-rcon";
udpPorts = [ service.port ];
tcpPorts = [ service.port ];
};

View File

@@ -75,9 +75,6 @@
service.stateDir
"-public"
"0"
"-modifier"
"raids"
"none"
];
udpPorts = [
service.port

View File

@@ -1,227 +0,0 @@
{
config,
lib,
registry,
...
}:
let
cfg = config.modules.services.homepage; # NOLINT(let_order)
derivedAllowedHosts = lib.unique (
lib.optional (selfRoute != null) selfRoute.domain
++ lib.optionals (selfAddress != null) [
selfAddress
"${selfAddress}:${toString cfg.listenPort}"
]
);
selfRoute = selfEntry.route or null;
selfEntry = registry.join cfg.hostName cfg.entryName;
selfAddress = selfEntry.address or null;
homepageServices = map mkTab (lib.filter (tab: lib.hasAttr tab entriesByTab) orderedTabs);
mkTab =
tab:
let
sections = entriesByTab.${tab};
in
{
${tab} = map (section: { ${section} = sections.${section}; }) (orderedSections tab sections);
};
entriesByTab = lib.foldl' insertEntry { } sortedEntries;
insertEntry =
acc: item:
acc
// {
${item.homepage.tab} = (acc.${item.homepage.tab} or { }) // {
${item.homepage.section} = (acc.${item.homepage.tab}.${item.homepage.section} or [ ]) ++ [
{ ${item.homepage.name} = mkServiceAttrs item; }
];
};
};
sortedEntries = lib.sort (left: right: left.homepage.name < right.homepage.name) homepageEntries;
homepageEntries = lib.concatLists (
lib.mapAttrsToList (
host: entries:
lib.mapAttrsToList (entry: homepage: {
inherit host entry homepage;
composite = registry.join host entry;
}) entries
) registry.homepage
);
orderedTabs = tabOrder ++ lib.filter (tab: !lib.elem tab tabOrder) (lib.attrNames entriesByTab);
tabOrder = [
"Apps"
"Download"
"System"
];
mkRegistryPath =
registryName: host: entry:
"registry.${registryName}.${host}.${entry}";
mkHref =
item:
if item.homepage.href != null then
item.homepage.href
else if (item.composite.route or null) != null then
if item.composite.route.protocol == "https" then
"https://${item.composite.route.domain}"
else if item.composite.route.protocol == "http" then
"http://${item.composite.route.domain}"
else
throw "${mkRegistryPath "homepage" item.host item.entry} needs href or a matching HTTP route entry"
else
throw "${mkRegistryPath "homepage" item.host item.entry} needs href or a matching route entry";
mkServiceAttrs =
item:
lib.filterAttrs (_: value: value != null) {
href = mkHref item;
inherit (item.homepage) description icon;
};
sectionOrderByTab = {
Apps = [
"Media"
"Files"
"Other"
];
Download = [
"Library Managers"
"Other Utilities"
"Download Clients"
];
System = [
"Monitoring"
"Network"
"Machines"
"Docker"
];
};
orderedSections =
tab: sections:
let
sectionOrder = sectionOrderByTab.${tab} or [ ];
in
sectionOrder ++ lib.filter (section: !lib.elem section sectionOrder) (lib.attrNames sections);
in
{
options.modules.services.homepage = {
enable = lib.mkEnableOption "Homepage Dashboard";
listenPort = lib.mkOption {
type = lib.types.port;
default = 8082;
description = "Port for Homepage to bind to.";
};
hostName = lib.mkOption {
type = lib.types.str;
description = "Host registry key that owns the Homepage guest entry.";
};
entryName = lib.mkOption {
type = lib.types.str;
default = "homepage";
description = "Registry entry name for this Homepage service.";
};
allowedHosts = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
default = null;
description = "Host headers accepted by Homepage. Defaults to matching address and route registry entries.";
};
environmentFile = lib.mkOption {
type = lib.types.path;
default = "/run/secrets/homepage.env";
description = "Environment file passed to Homepage.";
};
};
config = lib.mkIf cfg.enable {
services.homepage-dashboard = {
enable = true;
openFirewall = true;
inherit (cfg) listenPort;
environmentFiles = [ cfg.environmentFile ];
allowedHosts = lib.concatStringsSep "," (
if cfg.allowedHosts == null then derivedAllowedHosts else cfg.allowedHosts
);
services = homepageServices;
widgets = [
{
greeting = {
text_size = "xl";
text = "tracion";
};
}
{
openmeteo = {
units = "metric";
latitude = "{{HOMEPAGE_VAR_LATITUDE}}";
longitude = "{{HOMEPAGE_VAR_LONGITUDE}}";
cache = 5;
};
}
{
datetime = {
text_size = "xl";
locale = "en";
format = {
dateStyle = "full";
timeStyle = "medium";
hour12 = false;
};
};
}
];
settings = {
title = "Tracion";
theme = "dark";
color = "slate";
showStats = false;
hideVersion = true;
bookmarksStyle = "icons";
useEqualHeights = true;
layout = {
Apps = {
style = "row";
columns = 1;
tab = "Apps";
Media = {
style = "row";
columns = 2;
};
Files = {
style = "row";
columns = 2;
};
Other = {
style = "row";
columns = 3;
iconsOnly = true;
};
};
Download = {
style = "row";
columns = 1;
tab = "Download";
};
System = {
tab = "System";
};
};
};
};
};
}

View File

@@ -1,64 +0,0 @@
{
config,
lib,
...
}:
let
cfg = config.modules.services.navidrome; # NOLINT(let_order)
in
{
options.modules.services.navidrome = {
enable = lib.mkEnableOption "Navidrome";
dataFolder = lib.mkOption {
type = lib.types.str;
default = "/var/lib/navidrome";
readOnly = true;
description = "Path for Navidrome's `DataFolder` setting.";
};
musicFolder = lib.mkOption {
type = lib.types.str;
default = "/music";
readOnly = true;
description = "Path for Navidrome's `MusicFolder` setting.";
};
environmentFile = lib.mkOption {
type = lib.types.path;
default = "/run/secrets/navidrome.env";
readOnly = true;
description = "Path to Navidrome's environment file.";
};
};
config = lib.mkMerge [
{
registry."stable-uid".${config.networking.hostName}.navidrome = 20004;
}
(lib.mkIf cfg.enable {
users.users.navidrome = {
isSystemUser = true;
uid = config.registry."stable-uid".${config.networking.hostName}.navidrome;
group = "storage";
home = cfg.dataFolder;
};
services.navidrome = {
enable = true;
user = "navidrome";
group = "storage";
openFirewall = true;
inherit (cfg) environmentFile;
settings = {
Address = "0.0.0.0";
Port = 4533;
DataFolder = cfg.dataFolder;
MusicFolder = cfg.musicFolder;
EnableSharing = true;
};
};
})
];
}

View File

@@ -5,61 +5,35 @@
...
}:
let
cfg = config.modules.services.reverse-proxy;
generatedUdpStreams = lib.concatLists (
lib.mapAttrsToList (
host: routes:
lib.concatLists (
lib.mapAttrsToList (
entry: route: lib.optionals (route.protocol == "udp") (map (mkUdpStream host entry) route.ports)
) routes
)
) registry.route
);
mkRegistryPath = registryName: host: entry: "registry.${registryName}.${host}.${entry}";
mkUdpStream =
host: entry: port:
mkBackend = address: route: "http://${address}:${toString route.port}";
mkVirtualHost = host: entry:
let
address =
compositeEntry.address
or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
compositeEntry = registry.join host entry;
route = compositeEntry.route;
address = compositeEntry.address or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
in
{
inherit
host
entry
port
address
;
inherit host entry;
name = route.domain;
value = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = mkBackend address route;
};
};
generatedStreamConfig = lib.concatMapStringsSep "\n" (stream: ''
server {
listen ${toString stream.port} udp;
proxy_pass ${stream.address}:${toString stream.port};
}
'') generatedUdpStreams;
generatedVirtualHosts =
if duplicateDomainMessages != [ ] then
throw (builtins.head duplicateDomainMessages)
else
lib.listToAttrs generatedVirtualHostList;
duplicateDomainMessages = lib.concatLists (
generatedVirtualHostList = lib.concatLists (
lib.mapAttrsToList (
domain: virtualHosts:
if lib.length virtualHosts > 1 then
[
"duplicate route domain ${domain} in ${
lib.concatMapStringsSep " and " (
virtualHost: mkRegistryPath virtualHost.registryName virtualHost.host virtualHost.entry
) virtualHosts
}"
]
else
[ ]
) virtualHostsByDomain
host: routes:
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry) (
lib.filterAttrs (_entry: route: route.port != null) routes
)
) registry.route
);
virtualHostsByDomain = lib.foldl' (
@@ -70,64 +44,55 @@ let
}
) { } generatedVirtualHostList;
generatedVirtualHostList = generatedHttpsVirtualHostList ++ generatedHttpVirtualHostList;
generatedHttpsVirtualHostList = lib.concatLists (
duplicateDomainMessages = lib.concatLists (
lib.mapAttrsToList (
host: routes:
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "https") (
lib.filterAttrs (_entry: route: route.protocol == "https") routes
)
) registry.route
domain: virtualHosts:
if lib.length virtualHosts > 1 then
[
"duplicate route domain ${domain} in ${lib.concatMapStringsSep " and " (virtualHost: mkRegistryPath "route" virtualHost.host virtualHost.entry) virtualHosts}"
]
else
[ ]
) virtualHostsByDomain
);
mkVirtualHost =
host: entry: mode:
generatedVirtualHosts =
if duplicateDomainMessages != [ ] then
throw (builtins.head duplicateDomainMessages)
else
lib.listToAttrs generatedVirtualHostList;
mkUdpStream = host: entry: port:
let
isHttp = mode == "http";
address =
compositeEntry.address
or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
compositeEntry = registry.join host entry;
inherit (compositeEntry) route;
inherit (route) port;
address = compositeEntry.address or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
in
{
inherit host entry;
registryName = "route";
name = route.domain;
value = {
forceSSL = !isHttp;
enableACME = !isHttp;
extraConfig = lib.optionalString (route.clientMaxBodySize != null) ''
client_max_body_size ${route.clientMaxBodySize};
'';
locations."/".proxyPass = mkBackend address port;
};
inherit host entry port address;
};
mkBackend = address: port: "http://${address}:${toString port}";
generatedHttpVirtualHostList = lib.concatLists (
generatedUdpStreams = lib.concatLists (
lib.mapAttrsToList (
host: routes:
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry "http") (
lib.filterAttrs (_entry: route: route.protocol == "http") routes
lib.concatLists (
lib.mapAttrsToList (entry: route: map (mkUdpStream host entry) route.udp.ports) routes
)
) registry.route
);
mkRegistryPath =
registryName: host: entry:
"registry.${registryName}.${host}.${entry}";
generatedStreamConfig = lib.concatMapStringsSep "\n" (stream: ''
server {
listen ${toString stream.port} udp;
proxy_pass ${stream.address}:${toString stream.port};
}
'') generatedUdpStreams;
in
{
options.modules.services.reverse-proxy = {
enable = lib.mkEnableOption "the public edge reverse proxy";
};
config = lib.mkIf config.modules.services.reverse-proxy.enable {
config = lib.mkIf cfg.enable {
networking.firewall.allowedTCPPorts = [
80
443

View File

@@ -65,12 +65,6 @@
default = [ ];
description = "TCP ports opened for the game service.";
};
autoStart = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Start the game service after the guest reaches network readiness.";
};
};
config = lib.mkMerge [
@@ -80,10 +74,10 @@
(lib.mkIf config.modules.services.steamcmd.enable (
let
group = "storage";
service = config.modules.services.steamcmd;
user = "steamcmd";
group = "storage";
prepareScript = pkgs.writeShellScript "steamcmd-${service.serviceName}-prepare" ''
set -eu
@@ -144,18 +138,6 @@
WorkingDirectory = "-${service.steamDir}";
ExecStartPre = "+${prepareScript}";
ExecStart = updateScript;
TimeoutStartSec = "15min";
};
};
"${service.serviceName}-autostart" = {
description = "Queue ${service.serviceName} game server startup";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = lib.mkIf service.autoStart [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.systemd}/bin/systemctl --no-block start ${service.serviceName}.service";
};
};
@@ -165,19 +147,19 @@
"network-online.target"
"steamcmd-${service.serviceName}-update.service"
];
wants = [
"network-online.target"
"steamcmd-${service.serviceName}-update.service"
];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = user;
Group = group;
WorkingDirectory = "-${service.installDir}";
ExecStartPre = "+${prepareScript}";
ExecStartPre = [
"+${prepareScript}"
updateScript
];
ExecStart = startScript;
Restart = "on-failure";
TimeoutStartSec = "15min";
};
};
};

View File

@@ -6,13 +6,13 @@
}:
# Bazel for the primary user's shell environment.
let
cfg = config.modules.shell.bazel;
user = config.user.name;
in
{
options.modules.shell.bazel.enable =
lib.mkEnableOption "Bazel for the primary user's shell environment";
options.modules.shell.bazel.enable = lib.mkEnableOption "Bazel for the primary user's shell environment";
config = lib.mkIf config.modules.shell.bazel.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = [ pkgs.bazel ];
};
}

View File

@@ -5,13 +5,14 @@
}:
# direnv for the primary user, configured through home-manager.
let
cfg = config.modules.shell.direnv;
user = config.user.name;
in
{
options.modules.shell.direnv.enable =
lib.mkEnableOption "direnv for the primary user, with nix-direnv shell caching";
config = lib.mkIf config.modules.shell.direnv.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user}.programs.direnv = {
enable = true;
silent = true;

View File

@@ -6,10 +6,14 @@
}:
# fish for the primary user, configured through home-manager.
let
cfg = config.modules.shell.fish; # NOLINT(let_order)
commandsOf = shortcuts: lib.mapAttrs (_name: shortcut: shortcut.command) shortcuts;
shortcuts = lib.attrByPath [ config.networking.hostName ] { } config.registry.shell-shortcut;
cfg = config.modules.shell.fish;
user = config.user.name;
shortcuts = lib.attrByPath [ config.networking.hostName ] { } config.registry.shell-shortcut;
envVars = lib.attrByPath [ config.networking.hostName ] { } config.registry.env-var;
commandsOf = shortcuts: lib.mapAttrs (_name: shortcut: shortcut.command) shortcuts;
exportEnvVar = name: value: ''
set -gx ${name} ${lib.escapeShellArg value}
'';
in
{
options.modules.shell.fish = {
@@ -95,14 +99,17 @@ in
};
deploy-to = {
description = "Build the current checkout locally, import it on a host, and activate it";
description = "Deploy the current dotfiles branch to a host through target-local nixos-rebuild";
body = builtins.readFile ./functions/deploy-to.fish;
};
};
# Rendered by home-manager into ~/.config/fish/config.fish.
interactiveShellInit = builtins.readFile ./config.fish;
interactiveShellInit = lib.concatStringsSep "\n" (
(lib.mapAttrsToList exportEnvVar envVars)
++ [ (builtins.readFile ./config.fish) ]
);
};
};
};

View File

@@ -1,9 +1,16 @@
if test (count $argv) -ne 1
echo "usage: deploy-to <host>" >&2
if test (count $argv) -lt 1; or test (count $argv) -gt 2
echo "usage: deploy-to <host> [branch]" >&2
return 2
end
set host $argv[1]
set branch $argv[2]
set branch_source explicit
if test -z "$branch"
set branch_source current
set branch (git branch --show-current)
end
git rev-parse --show-toplevel >/dev/null 2>&1
or begin
@@ -11,85 +18,69 @@ or begin
return 2
end
set repository_root (git rev-parse --show-toplevel)
git fetch origin
or return $status
set untracked_nix_files (git -C $repository_root ls-files --others --exclude-standard -- '*.nix' flake.nix flake.lock)
if test (count $untracked_nix_files) -gt 0
echo "deploy-to: warning: untracked Nix files are not part of Git flake evaluation" >&2
printf ' %s\n' $untracked_nix_files >&2
if test -z "$branch"; or not git rev-parse --verify --quiet refs/remotes/origin/$branch >/dev/null
if test "$branch_source" = explicit
echo "deploy-to: origin/$branch does not exist" >&2
return 1
end
set branch_source upstream
set upstream_ref (git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)
set branch (string replace -r '^origin/' '' -- $upstream_ref)
end
set installable "$repository_root#nixosConfigurations.$host.config.system.build.toplevel"
if test -z "$branch"; or not git rev-parse --verify --quiet refs/remotes/origin/$branch >/dev/null
set branch_source default
set origin_head (git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
set branch (string replace -r '^origin/' '' -- $origin_head)
end
echo "deploy-to: building $installable" >&2
set system_path (nix build --no-link --print-out-paths $installable)
if test -z "$branch"
echo "deploy-to: could not determine a deploy branch" >&2
return 2
end
set remote_ref refs/remotes/origin/$branch
git rev-parse --verify --quiet $remote_ref >/dev/null
or begin
echo "deploy-to: build failed for $host" >&2
return $status
end
if test (count $system_path) -ne 1
echo "deploy-to: expected one build output, got "(count $system_path) >&2
echo "deploy-to: origin/$branch does not exist" >&2
return 1
end
set closure_paths (nix-store -qR $system_path)
or begin
echo "deploy-to: closure query failed for $host" >&2
return $status
set has_local_changes false
if not git diff --quiet $remote_ref --
set has_local_changes true
end
echo "deploy-to: checking which closure paths $host is missing" >&2
set missing_paths (printf '%s\n' $closure_paths | ssh $host 'xargs -r nix-store --check-validity --print-invalid')
or begin
echo "deploy-to: remote closure check failed for $host" >&2
return $status
end
set escaped_branch (string escape -- $branch)
set escaped_host (string escape -- $host)
set remote_command "cd ~/dev && git fetch origin && git switch -- $escaped_branch && git reset --hard origin/$escaped_branch && git clean -fd"
if test (count $missing_paths) -gt 0
set nar_file (mktemp --suffix=.nar)
or return $status
set remote_nar "/tmp/deploy-to-$host-"(date +%s)"-$fish_pid.nar"
echo "deploy-to: exporting "(count $missing_paths)" missing closure paths for $system_path" >&2
nix-store --export $missing_paths >$nar_file
set patch_file ""
set remote_patch_file ""
if test "$has_local_changes" = true
set patch_file (mktemp)
set remote_patch_file "/tmp/deploy-to-$host.patch"
git diff --binary $remote_ref -- >$patch_file
or begin
set export_status $status
rm -f $nar_file
echo "deploy-to: closure export failed for $host" >&2
return $export_status
rm -f $patch_file
return $status
end
echo "deploy-to: copying closure archive to $host:$remote_nar" >&2
scp $nar_file $host:$remote_nar
scp $patch_file $host:$remote_patch_file
set scp_status $status
rm -f $nar_file
rm -f $patch_file
if test $scp_status -ne 0
echo "deploy-to: closure archive copy failed for $host" >&2
return $scp_status
end
echo "deploy-to: importing closure on $host" >&2
ssh -t $host "sudo sh -c 'nix-store --import < $remote_nar' && rm -f $remote_nar"
or begin
echo "deploy-to: closure import failed for $host" >&2
return $status
end
else
echo "deploy-to: $host already has the full closure for $system_path" >&2
set escaped_remote_patch_file (string escape -- $remote_patch_file)
set remote_command "$remote_command && git apply --index $escaped_remote_patch_file && rm -f $escaped_remote_patch_file"
end
echo "deploy-to: recording $system_path as the system profile on $host" >&2
ssh -t $host sudo nix-env --profile /nix/var/nix/profiles/system --set $system_path
or begin
echo "deploy-to: system profile update failed for $host" >&2
return $status
end
set remote_command "$remote_command && sudo nixos-rebuild switch --flake .#$escaped_host --print-build-logs"
echo "deploy-to: activating $system_path on $host" >&2
ssh -t $host sudo "$system_path/bin/switch-to-configuration" switch
or begin
echo "deploy-to: activation failed for $host" >&2
return $status
end
ssh -t $host $remote_command

View File

@@ -5,13 +5,14 @@
}:
# git for the primary user, configured through home-manager.
let
cfg = config.modules.shell.git;
user = config.user.name;
in
{
options.modules.shell.git.enable =
lib.mkEnableOption "git for the primary user, carrying the operator's commit identity";
config = lib.mkIf config.modules.shell.git.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user}.programs.git = {
enable = true;

View File

@@ -6,13 +6,14 @@
}:
# gitea-axi for the primary user, installed through its own home-manager module.
let
cfg = config.modules.shell.gitea-axi;
user = config.user.name;
in
{
options.modules.shell.gitea-axi.enable =
lib.mkEnableOption "gitea-axi, an agent-ergonomic CLI for Gitea issues and pull requests";
config = lib.mkIf config.modules.shell.gitea-axi.enable {
config = lib.mkIf cfg.enable {
home-manager.sharedModules = [ inputs.gitea-axi.homeModules.default ];
home-manager.users.${user}.programs.gitea-axi.enable = true;
};

View File

@@ -6,58 +6,42 @@
}:
# Herdr, a terminal multiplexer for coding agents.
let
cfg = config.modules.shell.herdr;
user = config.user.name;
in
{
options.modules.shell.herdr.enable =
lib.mkEnableOption "Herdr, a terminal multiplexer for coding agents";
options.modules.shell.herdr.enable = lib.mkEnableOption "Herdr, a terminal multiplexer for coding agents";
config = lib.mkIf config.modules.shell.herdr.enable {
config = lib.mkIf cfg.enable {
registry.shell-shortcut.${config.networking.hostName}.hs = "herdr --session";
home-manager.users.${user} = lib.mkMerge [
{
home.packages = [ pkgs.herdr ];
home-manager.users.${user} = {
home.packages = [ pkgs.herdr ];
xdg.configFile."herdr/config.toml".text = ''
onboarding = false
xdg.configFile."herdr/config.toml".text = ''
[keys]
prefix = "ctrl+space"
detach = "prefix+d"
reload_config = "prefix+r"
new_workspace = "prefix+c"
new_tab = "prefix+shift+c"
rename_workspace = "prefix+comma"
rename_tab = "prefix+<"
split_vertical = "prefix+backslash"
split_horizontal = "prefix+minus"
switch_workspace = "prefix+1..9"
switch_tab = "prefix+shift+1..9"
focus_pane_left = "prefix+h"
focus_pane_down = "prefix+j"
focus_pane_up = "prefix+k"
focus_pane_right = "prefix+l"
[keys]
prefix = "ctrl+space"
detach = "prefix+d"
reload_config = "prefix+r"
new_workspace = "prefix+c"
new_tab = "prefix+shift+c"
rename_workspace = "prefix+comma"
rename_tab = "prefix+<"
split_vertical = "prefix+backslash"
split_horizontal = "prefix+minus"
switch_workspace = "prefix+1..9"
switch_tab = "prefix+shift+1..9"
focus_pane_left = "prefix+h"
focus_pane_down = "prefix+j"
focus_pane_up = "prefix+k"
focus_pane_right = "prefix+l"
[ui]
prompt_new_tab_name = false
[ui]
prompt_new_tab_name = false
[experimental]
kitty_graphics = true
'';
}
(lib.mkIf config.modules.shell.pi.enable {
home.file.".pi/agent/extensions/herdr-agent-state.ts" = {
source = "${pkgs.herdr.src}/src/integration/assets/pi/herdr-agent-state.ts";
force = true;
};
})
(lib.mkIf config.modules.shell.omp.enable {
home.file.".omp/agent/extensions/herdr-omp-agent-state.ts" = {
source = "${pkgs.herdr.src}/src/integration/assets/omp/herdr-agent-state.ts";
force = true;
};
})
];
[experimental]
kitty_graphics = true
'';
};
};
}

View File

@@ -7,13 +7,13 @@
}:
# Neovim for the primary user, configured declaratively through nixvim.
let
cfg = config.modules.shell.nvim;
user = config.user.name;
in
{
options.modules.shell.nvim.enable =
lib.mkEnableOption "Neovim, configured declaratively via nixvim";
options.modules.shell.nvim.enable = lib.mkEnableOption "Neovim, configured declaratively via nixvim";
config = lib.mkIf config.modules.shell.nvim.enable {
config = lib.mkIf cfg.enable {
registry.shell-shortcut.${config.networking.hostName} = {
vi = "nvim";
vim = "nvim";
@@ -31,7 +31,6 @@ in
ripgrep # backs the picker's live grep
fd # backs the picker's file finder
wl-clipboard # provides a non-blocking clipboard provider on Wayland
nixd # provides Nix language-server support
];
globals.mapleader = " ";
@@ -189,8 +188,6 @@ in
installRustc = false;
installRustfmt = false;
};
servers.nixd.enable = true;
};
gitsigns = {

View File

@@ -1,19 +0,0 @@
{
config,
lib,
pkgs,
...
}:
# Oh My Pi, installed alongside upstream Pi as the `omp` command.
let
user = config.user.name;
in
{
options.modules.shell.omp = {
enable = lib.mkEnableOption "Oh My Pi, a batteries-included fork of Pi";
};
config = lib.mkIf config.modules.shell.omp.enable {
home-manager.users.${user}.home.packages = [ pkgs.omp ];
};
}

View File

@@ -8,26 +8,31 @@
# home-manager, which ships the package and manages global settings.
# The login credential is left unmanaged, so it survives rebuilds.
let
cfg = config.modules.shell.pi;
user = config.user.name;
piDir = "${config.users.users.${user}.home}/.pi/agent";
patchedPi = pkgs.pi-coding-agent.overrideAttrs (old: {
patches = (old.patches or [ ]) ++ [
./patches/pi-project-resource-ancestors.patch
./patches/pi-tool-lookup-validation.patch
];
});
in
{
options.modules.shell.pi = {
enable = lib.mkEnableOption "Pi, a terminal coding agent, configured through home-manager";
enable = lib.mkEnableOption ''
Pi, a terminal coding agent, configured through home-manager'';
};
config = lib.mkIf config.modules.shell.pi.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
programs.pi-coding-agent = {
enable = true;
package = pkgs.pi-coding-agent.overrideAttrs {
src = ../../../../../third_party/pi;
patches = [ ];
};
package = patchedPi;
settings = {
defaultProvider = "openai-codex";
defaultModel = "gpt-5.6-terra";
defaultModel = "gpt-5.5";
defaultThinkingLevel = "medium";
theme = "dark";
enableInstallTelemetry = false;

View File

@@ -0,0 +1,140 @@
diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts
--- a/packages/coding-agent/src/core/package-manager.ts
+++ b/packages/coding-agent/src/core/package-manager.ts
@@ -459,14 +459,14 @@ function findGitRepoRoot(startDir: string): string | null {
}
}
-function collectAncestorAgentsSkillDirs(startDir: string): string[] {
- const skillDirs: string[] = [];
+function collectAncestorConfigDirs(startDir: string, configDirName: string): string[] {
+ const configDirs: string[] = [];
const resolvedStartDir = resolve(startDir);
const gitRepoRoot = findGitRepoRoot(resolvedStartDir);
let dir = resolvedStartDir;
while (true) {
- skillDirs.push(join(dir, ".agents", "skills"));
+ configDirs.push(join(dir, configDirName));
if (gitRepoRoot && dir === gitRepoRoot) {
break;
}
@@ -477,7 +477,7 @@ function collectAncestorAgentsSkillDirs(startDir: string): string[] {
dir = parent;
}
- return skillDirs;
+ return configDirs;
}
function collectAutoPromptEntries(dir: string): string[] {
@@ -2388,16 +2388,13 @@ export class DefaultPackageManager implements PackageManager {
prompts: join(globalBaseDir, "prompts"),
themes: join(globalBaseDir, "themes"),
};
- const projectDirs = {
- extensions: join(projectBaseDir, "extensions"),
- skills: join(projectBaseDir, "skills"),
- prompts: join(projectBaseDir, "prompts"),
- themes: join(projectBaseDir, "themes"),
- };
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
const projectTrusted = this.settingsManager.isProjectTrusted();
- const projectAgentsSkillDirs = projectTrusted
- ? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))
+ const projectConfigDirs = projectTrusted
+ ? collectAncestorConfigDirs(this.cwd, CONFIG_DIR_NAME).filter((dir) => resolve(dir) !== resolve(globalBaseDir))
+ : [];
+ const projectAgentsDirs = projectTrusted
+ ? collectAncestorConfigDirs(this.cwd, ".agents").filter((dir) => resolve(dir) !== resolve(dirname(userAgentsSkillsDir)))
: [];
const addResources = (
@@ -2414,56 +2411,54 @@ export class DefaultPackageManager implements PackageManager {
}
};
- if (projectTrusted) {
- // Project extensions from .pi/
+ // Project resources from .pi/ in cwd and ancestors.
+ for (const configDir of projectConfigDirs) {
+ const configMetadata: PathMetadata = {
+ ...projectMetadata,
+ baseDir: configDir,
+ };
addResources(
"extensions",
- collectAutoExtensionEntries(projectDirs.extensions),
- projectMetadata,
+ collectAutoExtensionEntries(join(configDir, "extensions")),
+ configMetadata,
projectOverrides.extensions,
- projectBaseDir,
+ configDir,
);
-
- // Project skills from .pi/
addResources(
"skills",
- collectAutoSkillEntries(projectDirs.skills, "pi"),
- projectMetadata,
+ collectAutoSkillEntries(join(configDir, "skills"), "pi"),
+ configMetadata,
projectOverrides.skills,
- projectBaseDir,
+ configDir,
+ );
+ addResources(
+ "prompts",
+ collectAutoPromptEntries(join(configDir, "prompts")),
+ configMetadata,
+ projectOverrides.prompts,
+ configDir,
+ );
+ addResources(
+ "themes",
+ collectAutoThemeEntries(join(configDir, "themes")),
+ configMetadata,
+ projectOverrides.themes,
+ configDir,
);
}
- // Project skills from .agents/ (each with its own baseDir)
- for (const agentsSkillsDir of projectAgentsSkillDirs) {
- const agentsBaseDir = dirname(agentsSkillsDir); // the .agents directory
+ // Project skills from .agents/ in cwd and ancestors.
+ for (const agentsDir of projectAgentsDirs) {
const agentsMetadata: PathMetadata = {
...projectMetadata,
- baseDir: agentsBaseDir,
+ baseDir: agentsDir,
};
addResources(
"skills",
- collectAutoSkillEntries(agentsSkillsDir, "agents"),
+ collectAutoSkillEntries(join(agentsDir, "skills"), "agents"),
agentsMetadata,
projectOverrides.skills,
- agentsBaseDir,
- );
- }
-
- if (projectTrusted) {
- addResources(
- "prompts",
- collectAutoPromptEntries(projectDirs.prompts),
- projectMetadata,
- projectOverrides.prompts,
- projectBaseDir,
- );
- addResources(
- "themes",
- collectAutoThemeEntries(projectDirs.themes),
- projectMetadata,
- projectOverrides.themes,
- projectBaseDir,
+ agentsDir,
);
}

View File

@@ -0,0 +1,23 @@
diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts
--- a/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:36.970496010 -0400
+++ b/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:37.028186009 -0400
@@ -74,8 +74,8 @@
function commandExists(cmd: string): boolean {
try {
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
- // Check for ENOENT error (command not found)
- return result.error === undefined || result.error === null;
+ // Downloaded generic Linux binaries can exist but fail on NixOS before they print a version.
+ return (result.error === undefined || result.error === null) && result.status === 0;
} catch {
return false;
}
@@ -88,7 +87,7 @@
// Check our tools directory first
const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : ""));
- if (existsSync(localPath)) {
+ if (existsSync(localPath) && commandExists(localPath)) {
return localPath;
}

View File

@@ -6,14 +6,14 @@
}:
# Python tools for the primary user's shell environment.
let
cfg = config.modules.shell.python;
user = config.user.name;
in
{
options.modules.shell.python.enable =
lib.mkEnableOption "Python tools for the primary user's shell environment";
options.modules.shell.python.enable = lib.mkEnableOption "Python tools for the primary user's shell environment";
config = lib.mkIf config.modules.shell.python.enable {
registry.env.${config.networking.hostName}.PYTHON_TOOLCHAIN_ROOT = "${pkgs.python3}";
config = lib.mkIf cfg.enable {
registry.env-var.${config.networking.hostName}.PYTHON_TOOLCHAIN_ROOT = "${pkgs.python3}";
home-manager.users.${user}.home.packages = [ pkgs.python3 ];
};

View File

@@ -6,6 +6,8 @@
}:
# Rust tools for the primary user's shell environment.
let
cfg = config.modules.shell.rust;
user = config.user.name;
toolchain = pkgs.symlinkJoin {
name = "rust-toolchain";
paths = with pkgs; [
@@ -16,14 +18,12 @@ let
rustfmt
];
};
user = config.user.name;
in
{
options.modules.shell.rust.enable =
lib.mkEnableOption "Rust tools for the primary user's shell environment";
options.modules.shell.rust.enable = lib.mkEnableOption "Rust tools for the primary user's shell environment";
config = lib.mkIf config.modules.shell.rust.enable {
registry.env.${config.networking.hostName}.RUST_TOOLCHAIN_ROOT = "${toolchain}";
config = lib.mkIf cfg.enable {
registry.env-var.${config.networking.hostName}.RUST_TOOLCHAIN_ROOT = "${toolchain}";
home-manager.users.${user} = {
home.packages = [

View File

@@ -6,12 +6,13 @@
# tmux for the primary user, configured through home-manager.
# Settings without a home-manager option are read from ./extra.conf.
let
cfg = config.modules.shell.tmux;
user = config.user.name;
in
{
options.modules.shell.tmux.enable = lib.mkEnableOption "tmux, configured via home-manager";
config = lib.mkIf config.modules.shell.tmux.enable {
config = lib.mkIf cfg.enable {
registry.shell-shortcut.${config.networking.hostName}.tmx = "tmux new-session -A -s";
home-manager.users.${user}.programs.tmux = {

View File

@@ -6,12 +6,13 @@
}:
# Yazi, a terminal file manager.
let
cfg = config.modules.shell.yazi;
user = config.user.name;
in
{
options.modules.shell.yazi.enable = lib.mkEnableOption "Yazi, a terminal file manager";
config = lib.mkIf config.modules.shell.yazi.enable {
config = lib.mkIf cfg.enable {
home-manager.users.${user}.programs.yazi = {
enable = true;
enableFishIntegration = true;

View File

@@ -0,0 +1,72 @@
{
config,
lib,
...
}:
# The host-level ZFS pool import: durable service state a host mounts, never rebuilds.
let
cfg = config.modules.zfs;
in
{
options.modules.zfs = {
enable = lib.mkEnableOption "importing durable ZFS pools that hold service state";
hostId = lib.mkOption {
type = lib.types.strMatching "[0-9a-f]{8}";
example = "deadbeef";
description = ''
This host's 8-hex-digit ZFS host id, written to `networking.hostId`. ZFS
stamps an imported pool with the importing host's id, so a pool still
held by another machine is refused rather than silently dual-mounted. It
must be fixed for the machine and distinct across machines that can reach
the same pool.
'';
};
pools = lib.mkOption {
type = lib.types.attrsOf (lib.types.attrsOf lib.types.path);
default = { };
example = lib.literalExpression ''
{
tank = {
media = "/srv/media";
downloads = "/srv/downloads";
};
}
'';
description = ''
The ZFS pools to import at boot, keyed by pool name, each pool mapping a
dataset path relative to it to that dataset's mountpoint. A pool is
durable state imported as it stands, never created or destroyed by a
rebuild, so a service's data survives any rebuild or reimage. A pool
with an empty map is still imported, leaving each dataset to its own ZFS
`mountpoint` property.
'';
};
};
config = lib.mkIf cfg.enable {
# The ZFS stack in the kernel and boot, needed even where the root filesystem is another kind.
boot.supportedFilesystems = [ "zfs" ];
# ZFS refuses to import a pool without a host id to stamp its ownership onto.
networking.hostId = cfg.hostId;
# The declared pools are imported at boot, distinct from any pool backing the root filesystem.
boot.zfs.extraPools = lib.attrNames cfg.pools;
# Each declared dataset is mounted at its host path as a native ZFS filesystem.
fileSystems = lib.mkMerge (
lib.mapAttrsToList (
pool: mounts:
lib.mapAttrs' (
dataset: mountpoint:
lib.nameValuePair mountpoint {
device = "${pool}/${dataset}";
fsType = "zfs";
}
) mounts
) cfg.pools
);
};
}

View File

@@ -1,10 +1,10 @@
{ lib, util, ... }:
{ lib, ... }:
{
type = lib.types.str;
checks = [
{
assertion = util.registry.predicates.uniqueValue (entry: entry.value);
assertion = lib.registry.predicates.uniqueValue (entry: entry.value);
message = "Address registry values must be unique.";
}
];

View File

@@ -1,45 +0,0 @@
{ lib, ... }:
{
type = lib.types.submodule {
options = {
tab = lib.mkOption {
type = lib.types.str;
example = "Apps";
description = "Groups the entry in a Homepage tab.";
};
section = lib.mkOption {
type = lib.types.str;
example = "Media";
description = "Groups the entry in a Homepage section.";
};
name = lib.mkOption {
type = lib.types.str;
example = "Navidrome";
description = "Sets the display name in Homepage.";
};
description = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "Music";
description = "Sets the description in Homepage.";
};
icon = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "navidrome.png";
description = "Sets the icon in Homepage.";
};
href = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "https://music.alexion.dev";
description = "Overrides the generated Homepage link.";
};
};
};
}

View File

@@ -1,112 +0,0 @@
{ lib, util, ... }:
{
checks = [
{
assertion = util.registry.predicates.uniquePerHost (entry: entry.value.keys);
message = "Desktop keybinds must use unique key chords per host.";
}
{
assertion =
entries:
lib.all (
entry:
(entry.value.action.kind == "exec" && entry.value.action.command != null)
|| (
builtins.elem entry.value.action.kind [
"focus"
"move-window"
]
&& lib.xor (entry.value.action.direction != null) (entry.value.action.workspace != null)
)
|| (
entry.value.action.kind == "resize-window"
&& entry.value.action.direction != null
&& entry.value.action.workspace == null
)
|| (
builtins.elem entry.value.action.kind [
"toggle-floating"
"toggle-fullscreen"
"close-window"
]
&& entry.value.action.command == null
&& entry.value.action.direction == null
&& entry.value.action.workspace == null
)
) entries;
message = "Desktop keybind actions must set the fields their kind requires.";
}
];
type = lib.types.submodule {
options = {
keys = lib.mkOption {
description = "Sets the key chord for this desktop keybind.";
type = lib.types.str;
example = "SUPER + Return";
};
repeating = lib.mkOption {
description = "Repeats the keybind action while the key chord is held.";
type = lib.types.bool;
default = false;
example = true;
};
locked = lib.mkOption {
description = "Allows the keybind action while the session is locked.";
type = lib.types.bool;
default = false;
example = true;
};
action = lib.mkOption {
description = "Declares the action this keybind runs.";
type = lib.types.submodule {
options = {
kind = lib.mkOption {
description = "Selects the keybind action kind.";
type = lib.types.enum [
"exec"
"focus"
"move-window"
"resize-window"
"toggle-floating"
"toggle-fullscreen"
"close-window"
];
};
command = lib.mkOption {
description = "Command executed by an exec keybind.";
type = lib.types.nullOr lib.types.str;
default = null;
example = "ghostty";
};
direction = lib.mkOption {
description = "Direction targeted by a keybind action.";
type = lib.types.nullOr (
lib.types.enum [
"down"
"left"
"right"
"up"
]
);
default = null;
example = "left";
};
workspace = lib.mkOption {
description = "Workspace targeted by a keybind action.";
type = lib.types.nullOr lib.types.int;
default = null;
example = 1;
};
};
};
};
};
};
}

View File

@@ -1,40 +1,17 @@
{ lib, util, ... }:
{ lib, ... }:
{
checks = [
{
assertion = util.registry.predicates.uniqueValue (entry: entry.value.domain);
assertion = lib.registry.predicates.uniqueValue (entry: entry.value.domain);
message = "Route domains must be unique.";
}
{
assertion =
entries:
util.registry.predicates.uniqueValue (entry: entry.value) (
lib.concatMap (entry: map (port: entry // { value = port; }) entry.value.ports) entries
assertion = entries:
lib.registry.predicates.uniqueValue (entry: entry.value) (
lib.concatMap (entry: map (port: entry // { value = port; }) entry.value.udp.ports) entries
);
message = "Route UDP ports must be unique.";
}
{
assertion =
entries:
lib.all (entry: entry.value.port != null && entry.value.ports == [ ]) (
lib.filter (entry: entry.value.protocol != "udp") entries
);
message = "HTTP and HTTPS routes must set port and not ports.";
}
{
assertion =
entries:
lib.all (entry: entry.value.port == null && entry.value.ports != [ ]) (
lib.filter (entry: entry.value.protocol == "udp") entries
);
message = "UDP routes must set ports and not port.";
}
{
assertion =
entries:
lib.all (entry: entry.value.protocol != "udp" || entry.value.clientMaxBodySize == null) entries;
message = "UDP routes must not set clientMaxBodySize.";
}
];
type = lib.types.submodule {
@@ -42,40 +19,24 @@
domain = lib.mkOption {
type = lib.types.str;
example = "files.alexion.dev";
description = "Records the public domain for this route.";
};
protocol = lib.mkOption {
type = lib.types.enum [
"http"
"https"
"udp"
];
description = "Selects the public protocol for this route.";
description = "Public domain served by this route entry.";
};
port = lib.mkOption {
type = lib.types.nullOr lib.types.port;
default = null;
example = 3923;
description = "Sets the HTTP backend port for an HTTP or HTTPS route.";
description = "Backend TCP port served by the matching address registry entry.";
};
ports = lib.mkOption {
udp.ports = lib.mkOption {
type = lib.types.listOf lib.types.port;
default = [ ];
example = [
2456
2457
];
description = "Sets the UDP ports for a UDP route.";
};
clientMaxBodySize = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "1g";
description = "Maximum HTTP request body the reverse proxy accepts.";
description = "UDP ports served by the matching address registry entry.";
};
};
};

View File

@@ -1,20 +1,21 @@
{ lib, ... }:
{
type = lib.types.coercedTo lib.types.str (command: { inherit command; }) (
lib.types.submodule {
options = {
command = lib.mkOption {
type = lib.types.str;
example = "nvim";
description = "Sets the command expanded by this shortcut.";
};
abbreviation = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Uses a Fish abbreviation instead of an alias.";
};
let
entryType = lib.types.submodule {
options = {
command = lib.mkOption {
type = lib.types.str;
example = "nvim";
description = "Command expanded by this shell shortcut.";
};
}
);
abbreviation = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether fish realizes this shell shortcut as an abbreviation instead of an alias.";
};
};
};
in
{
type = lib.types.coercedTo lib.types.str (command: { inherit command; }) entryType;
}

View File

@@ -1,25 +1,31 @@
{ lib, ... }:
let
duplicateUids =
entries:
lib.filterAttrs (_uid: entryNames: builtins.length entryNames > 1) (entryNamesByUid entries);
inherit (lib)
filterAttrs
groupBy
mapAttrs
unique
;
entryNamesByUid =
entries:
lib.mapAttrs (_uid: uidEntries: lib.unique (map (entry: entry.entry) uidEntries)) (
lib.groupBy (entry: toString entry.value) entries
serviceNamesByUid = entries:
mapAttrs (_uid: uidEntries: unique (map (entry: entry.entry) uidEntries)) (
groupBy (entry: toString entry.value) entries
);
duplicateUids = entries:
filterAttrs (_uid: serviceNames: builtins.length serviceNames > 1) (serviceNamesByUid entries);
in
{
# Next stable UID: 20005
# Next stable UID: 20003
replicate = true;
type = lib.types.ints.positive;
checks = [
{
assertion = entries: duplicateUids entries == { };
message = "Multiple entries use the same stable UID.";
message = "Stable UID allocations must be unique.";
}
];
}

View File

@@ -1,29 +0,0 @@
{ lib, ... }:
{
type = lib.types.submodule {
options = {
position = lib.mkOption {
type = lib.types.enum [
"left"
"center"
"right"
];
default = "right";
description = "Selects the Waybar section that shows the module.";
};
order = lib.mkOption {
type = lib.types.int;
default = 100;
example = 50;
description = "Sorts the module within its Waybar section.";
};
settings = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
default = { };
description = "Sets the Waybar module configuration.";
};
};
};
}

View File

@@ -1,19 +0,0 @@
{ lib, ... }:
{
type = lib.types.submodule {
options = {
class = lib.mkOption {
description = "Matches the window class reported by the compositor.";
type = lib.types.str;
example = ".*[Ff]irefox.*";
};
icon = lib.mkOption {
description = "Sets the icon shown for matching windows.";
type = lib.types.str;
example = "";
};
};
};
}

View File

@@ -1,11 +1,6 @@
ssh-host-ed25519-key: ENC[AES256_GCM,data:nxj7nMDzeqr0CitIjns9ugFh7Sur0pVpBr8SgWbxQMpIeNvBp+ugmnAxaUq2HRoYCo68uh/YKJZgMCBPSWrEyIhUX0rXmiZFmusrviJSXrYmy+n2XvLPjuv4vPbJEfjudRdXkHPazB3JJbeVRhGgK7y7smhNZlr8lwPGo0Ui0jVqWB4MPQRP6dLKVfV5shIusOqdhg7y2JSkZLYIjOBOJjknCxGmr5+vF2qwpDJQWEyzuJv2QJ48IlU7QyHF76//6xOchteL70/lyXI4gLiGl2fEczx3KKIXE+DNetKVAQxh4QHwzz9JK8qIJMYVry2qFdGGgF8eN7YEJYA60r1pL++1l8Uq++C1Bsc9jCO91ayQb+spXK0uM3G/ZJka7brRQbvcr54MJEyH6+0uBscXw/wMJ1EBX3Np6dumQhKuALnsAIXE89bKJg1wyPwaCy8uMZMcjQhvNkG0FPSBatHz2bB7bnNuNiXyT+scYyN0huE4Ccq7/H3bHWqbipV51YBH/XZKmjDiCwu3i0t5MHmp,iv:9P6Pa3hBe0/jbacjvzV3VGJJ4+OLRarEJEkLMOftqPw=,tag:zA6ewbj2ovP9+2WcAXa9qA==,type:str]
ssh-host-rsa-key: ENC[AES256_GCM,data:C/xmdAeQlQou4wbP1EBqZ8G5/dRpmGYprj/z1g68WfCO7L+gzIgOryaEhBHugv3USAN8E3Ak4Ry+aUZCzDuIM2OeWdUm2AVT6r5o1RPYFtHSdsJUbKKB+9T3bR9GgFBRyRInSOjQhqR3lYuFLeA1xWhV8UG3Vv1tJwjLV1/JTUQf9j/NcYUr8tqjqD6hB8Fa5IEHPAUviX40R50o+EoiPIkB+qKbGru0Eg7p5K+/6d858nZc2lNgXIFnh72D0hXdWFs1BvcwGaY2WL3i7nJmOBUc3zM3/bYlU9EIscl04V6aUSXmF2sqI13Psa8lApV08MdAyUoFSHlYxlFxqe0IJ3nm0ofbJdwM6oDR6E8yQeU9vpirnYOPI6CWx6Y83LFWAVlhOZQKoLPBbiKImjM8oyoBGiIdqUrIXggoZ1u4F2/SwJtd9MwNr0+UDQWAQsYoAoBfI9LSUvEj52C2RiF28MDw2mEE8zmuHWM96KRUB7E8sGXHJrfBzDC5JuVdwmNCAOgck7ACJnHh8ZbKQYv8iGjbwVU/9z52BIUTLjJrdllSmIGWjVqOSVAUZVl7R3AK/xAaUuNNLaNzR4kk/+lQhPTuOLeFBfWbu3kryfeyJNyuHHtS21tgTcwc/bMYVq8LM8ttPn3b/BLQMl49qlBNmMk4TZ4hzBC5BMW6TgSHJRXZ7eajV8x+TM+aGu34xU0TiIy95XgTdi1ktQ0kAzB94iLTsMVhWpwoOguOlGLPGDKuDlym44T4cFRHySSHy0xlroPD1FPSvlSP7uY++55HsxX/88M8HNtCQ9h2P7jFRBYL+CpYbwgWNqtIWq8/daxCxScKZZ2/sAvFL5Ttqe//wYwNp6F3WbIxOk7yS/Ov+ADOt+RnxB2P11dkWNN7kHscGO7/sBNhDbzyEB8kHdfbEYKAl+OwUUsYqiGzdKF8E8aNa/0AAld3whrVgsUmIbFeoAkvJO6TkiZ3uuJAHdF9L8XfpWLEbu+geb6HG4+Bf5QOxxKVkSihMCimTI5vPGHuXj0/vwMihsMvhcaTmfSh2cZRAQhwIPQamEEeC+vqU2Igwcm57Cg81gMJYORLj+SaZSbCbrUxqElE/1OxUtF1KEh9njXjNK/grKnZbDDfRrhZwm3Nri1yxQxk/haHVoZttwXKo2vsWsYvpaHPJuUoQ0r/Wo8RffH2s04qauphLgWi2BUpfs+nNFBGRu6S92o/dktNQYgVeAGo7fYrM4xQX26fjQcOR7TD5bQIR2HcfHzyfH6A4c8MuInUeuf7kZFOoR1DdUFXYbvNcIgbmB/ScImkP3q18KaH+JRsVJbEEB7X4YOQ6lvHTtk0QVLv50V22BA9naFlpKGMWjq0oT5Gf40c6+opDlQWKrgwISwDvm9QJznvquj1EUlFJwVf0SJBHfeomFITZZMma2no2EIV0s/n9LvaU0b1Aim9EAqxUTgMrryLzE16dqSQKugbsn2jWMy7By3Xf8g0kstbyht5EOUZpThrwthEcGRN3xRHHke6ILckAVqEvl2wMuNMV8sd904pdYUL1wQAHKtg+fRbzu5cMY8itEs6YPsWob+HaD+n2tfHf4ByR1KBZ92nCyAWWjW6jrSDCzYCRsxvMSdsBJz9U4szSExC2qm9kQxGeedOl8/qECRZQQArBQZdavbwECGVUT3LRWQOu6bDyFgzoTzUlRxK05wr+Rpir/NmiGURGCZhmy+J2QnejlK0zOidORZAkzFcuGUPiUDGvJ99y43oSYAEYAI4UMKu42zimdVz50PbD2jpSJS6OmxUKfPfyy6CKC1lmYSB7PVGHzQ2aIw0RVMND9JNcntD5qgS2/g1CFhh9z9QyVtkOqiPICXTRXmVEQ5PW04tyzKBRkiSPoXxpC1zq77QPUAz0ZfeElqppdGyfejRiRl6m5JXJ9Pwl3/pgMEqGs0gpM/+TuT3rrYr6v602wPNq6/1XWpizReVPJZNzMrmxQwiIO285w1kvBlZ1VQnbAUiJR+pZuc9PiegSqEbfdkC8SQaGj+2M9tSDRzDGDE1y8pOLDi8Wczbn313JM+QwYDAXGccXxCEq0d3fVjb26u0ZNq2nn5zDy002aMWro+mrsLlWOf4MoAwvnyxgwEewrBEFIk1n14huFd1YpVomZuY3Y9XX1b1XoiTlhbmlP2HopobgQCD4Gt4zFb9RovNxBib+g4j2N94uoiuXOyBTT1B/HWHr6DnMxNvtHIyMOUPzR+6hwO9ntmdhPh+UZIB2b6FWZWLec55yBewzmvWn44dOdo7Z8Tp4d7vfSamyQDO49LA7Bd0CpDan7lbpw5rD4iejRCqZ/yJHTRyExSrZtcLQsScaorGCPlxHL4MUlRMynkyjPSUDRzqdJs+OnJDfUxJ566z8978rCppuYGRAGCqnoEHN9vcGsncMt9LuyUFlDaNKoOFZlMthvfQnUy47JnrP5u18Ph8qjn6A2Wh6MowsMvM67EBSlx8ZdHJC1iWqlHULW7FKPtcvKnDB/GToOqi2y9+EuMVWpoDJmTTyZFr9fmMlA0mL9tXWWG5h3G4LeLrT+LmRmk5tgL1TUJz5XDRQIQZRXxP+X7yt1bDh5PQK0vyupnWbs8KGDM+/pO8GwkwPnehBmJkkfkA6W2DIWzqRZHstdGmk1vtBqE6yAmwBOuXL+vm0vN86ulN+87LNR3OpCBRyorqundmw82vg/RdPNuA+Zxqg+VErsuTTdJmgZxN1rDU+8AiJPC8ZK7mH74lLYX2Ad86ZDriPcAi7wyYLc1qrSSHFCofb2FuM3/cJXcadSjZ//HxZh6qVOC5FW3b/YnFLZeqUgai6PO5rvzgyXusb2qHK60sEyS6pfw+ybOyZxWAc1TYqFXtOhUje8SW3R4cCG4zyCh5kUjZLiCNoNU2MIvVHjSfe9KAvTO2pcmuDIuVv9CKoko8tgoFqS80UDeP0pn99AmnTMXbhUR7eYjR9qAABF0IJJGUUIR1SbwiJrdEXil1qNcgciBI1epIXc1uBRXhcEnHI8VxVwYxF5wISjfuvp4cqJk03AkZVa4E2yydUATOCtk6fkaxVJIkitQaVgc0tqS4pX+9d35NF/njFHXksT/wMBfVJYmcgfIHJFK46igB/rwwC3PpOM+YNM8aJpLyZbbUB9CEWMtT9P5LqLWFoUaC2brIKG7vrfy2OSNOEWxiEDAzn7kY1+zpOb93J8FURpDNV+K4YUznPr1z2BxEGAKQXXx2PHQpAYYvdane5uWsZWMTsK5qQunX2UHPscr3T0A384pzXNdgnR6OrZfbcIpmvTAjxH9qzQLj49kJQ98czuutJWxgrp9J7abKeIdWGObTra5gA6bJ5kw9FZjW0xiyrOLAok/Vbmen8WCIVxToMJnXR91uuq58GxrcuZFke0NpCfEu3bpdPeKlb+sy0FREiFCpaDUNFFn5DqTNwOiW5KjGw/fG6dqC/NUhH5xdeod4dR1C4NEpYKzbWolUFf+rvFtGrRnQ0VQMXav+fpnBFiRA/ez/7oAzzwNv8Y7s3QpCUSUjsPRGWG/4pDlRSaz7rwkCfRxpkbOmPruYx3fcHigBnvIMLtKAPCx4jE7EsS6BoHNhgCMRyzkdl+MZFLKZ7JgFvgPdNcieFlptax2KaEwBIvhgQ4rimLRCv9cCM1qmmNIGB0UovqZ0Ye8tRG6LteFTgsfb3bNQ5sShaHQiuh2m9JGnI41q8CH2Gn6Mz/ftxn4yDWL/SlkjhtnRLoYvD5ro/Tf4q+exobXC0fbA1sLOnaX3atqHeQWK4Sk9L7zrDbwrJHzgmFD2PUPUhpfg9r1QMtJVkUckhQgyjrrIBfyZ4qqPmpYbH1w7cfE3C/rdprNZFKujYlA0ol8Ok1n0gyRFTeNZvd5EdrQ3D+hIKc0ID7/FmfzIccrI6x+wOE1ePQPzHay2mM8qRcjgTiQOTMaFRYjVy28ovqgixs/bylGvw5VNMGJTIqcu6fC54i3HOBhmnH1Ab/5a7PmwXUbAajw+mKddtrr0wQ7cXRncFnoJtld2AIOs7aD51C1Zkr1UqxliWObCsXJmaQoOpBScHdiKNp1mKQ1Oddu3mj0Su1e4gQR+YAwOyQ1zBU5WvNBbudUmRIsjXDS2Y6XaVN50neQ0DZ5V71259xPyawyVvrdiiUg7HOXgGFGcWbFU+p7MZaq0KE3aXgwCC+/mhFgaKgIVWir5pElNaXLUMDF+1jnblaKyHyAk0HPBgegAphVqWF3Hqp3b15jTSdWsmOugGcCO9hLk2OD99nZzuEVBLbDg0yGISPgzVNFg3+Pqcu8FqmGxLwAWNTRc0+ov7VL5Y4fJoD4QOPj19+nZyvXze/FOYo1Hp+U7PBZDIKVswcdauZG+Z2t2bEd0OxXj1NoazpycIVI/TmiTVyDsK+ijXztNnWXGG24WW1KZ3O5+MmDm7F7RDi1vCOfOOg3Y1yPVprOvltyn1uPo5Q/pPTD1FmAGpJkmdT3+dKkb9N5u6RNvRBEzFY8HCqcvKX1HulpJpz72,iv:vfjzz6U6ey9wGs6Ia3Vd5HaGBvpSq4akp4e3eLC/A3M=,tag:ar1+i09oOEusEuQnr44ASw==,type:str]
ssh-user-ed25519-key: ENC[AES256_GCM,data:uHZADD2tq8vqlOAOWYniEnj1sl2IUqepf4YvrmRX/0nTksVnoethW6Yb2VNTgu5FsKCxvO65qMZ9KYupDkw/+SCZP2VH4TyhByQSr4SYxrEiNOJLdvmL2VnOWlNNZKaswgBePQR7TBG4mSIiTXCjEAcxeHAhBxLPnwiFxK/Zd6yQhq61x1N+ZtOj0q0QpADP3vioUohdkE5ODoEOUUoQJkYpjdJl2j4PhjCq7yHMEZpeMkqjZhVX5+i8blBu43BM4bKS2tBIJW2IvPV9sHOH8/tsR6p5q0pnpcJAOyOquBSo+eR0egY+liXjSv+Vo/ZDPapl7rKs2SoJ5huUGFbWjxbXY2+t2SYyiVA8lufrVUu94RCwOOJ8UyGYzAGGrChUImVDBsgk/GWwpOvWxcqydF8d6mDdxK9Iek9HtYsS+IKXLrF8GAI9OLB9jKgHVZgyQYSnC146cwCLt99TOFVocE1lfzwpFQTwKvxkep5jBEWiyqRaRUngti0JuF0ih1BWo+LKqxIqq9+V3I39xM8PfTjgHiifnxj4wuhS,iv:32zDKpNPYMiP6Rx7L9TAhxrMwcmphIGni+0jcESoLqg=,tag:YWGE62WQNm2Cer+eG+S5Xg==,type:str]
services:
navidrome:
lastfm:
api-key: ENC[AES256_GCM,data:Zb0I5TpdEqOxLAD6ed2XywFGZp7dfY69vcGiWOUJkJQ=,iv:pO53pHr09jWfzJyTsQbTHtzTmiQ9+RQkDwZJmInIiX8=,tag:io9jkSN7TF2I8+qlSymaYw==,type:str]
secret: ENC[AES256_GCM,data:rKhd9i0/tbJlXhc9Wf81IDydjpD8khSdwJEEMK260E4=,iv:btBXWBKJp9br5a3yZsRVegN8Ytzc45Qbk2iNlPbENkQ=,tag:LUbu+6gxfqQvVpddZto7Tg==,type:str]
sops:
age:
- enc: |
@@ -26,7 +21,7 @@ sops:
dyjzy0/9Ui1IIXH36AImO5hiEZcrGePV2qPLLQc7ZLngC1jlJuEKJA==
-----END AGE ENCRYPTED FILE-----
recipient: age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
lastmodified: "2026-09-12T22:54:25Z"
mac: ENC[AES256_GCM,data:4R3a0ytf4i8aM8iBQ9vRC/q+hvtjLrCTNgbpCB5DbqBMBYmhtGsBmO/0i1yT0N4tydyLaGOMkNfuuuVi3ASLEOKnViy9w3uD9IN0F79W4f2vguWUVIp/T8skvN9wbV9o9xd0LkJzmeVVgzb1H6HKw+rTGjbquleoIHEBDcDDM+k=,iv:0NlckMXlS+idY6UGBF+F0KFLt+pzD7Q/YEQOdXraAYg=,tag:JBBQduV8uhnKz9gPf6HfAQ==,type:str]
lastmodified: "2026-08-03T01:26:32Z"
mac: ENC[AES256_GCM,data:w4i6+dwN/t/SvFTrP/YiapxOY6ecT7+cJ0v/5lYMe4m1vL2w4mJw9ZrxWifcCcTEfaa766Wc1NE+GxGrBZtlORExbtQCa4tg2GuIQCRqTEc5WSqhYrgMb4elWuYe+r3/SlAf1ldNxz+cmLVtbDpSs96bvkGsKdw/i+q9n2URJYE=,iv:MFWgMtIdpn1v0T6FPhTgBMxi+6kzf2ZAnOGaisRT0d0=,tag:qBX32/zUHSPagXU+u8G8tA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.3

Some files were not shown because too many files have changed in this diff Show More