Author SHA1 Message Date
moons-14 99878e741b shells: guard platform-specific pcsc tools 2026-08-06 20:38:52 +09:00
moons-14 b9a4bf72fa style: format validation workflow 2026-08-06 20:31:44 +09:00
moons-14 6d223029df style: format flake update workflow 2026-08-06 20:30:54 +09:00
moons-14 e37a0b9e41 style: format validation checks 2026-08-06 20:30:18 +09:00
moons-14 102393819d style: format registry validation 2026-08-06 20:30:01 +09:00
moons-14 26e59ad5f7 ci: capture formatter diff 2026-08-06 20:26:18 +09:00
moons-14 d191f06cc7 ci: show formatter changes on failure 2026-08-06 20:23:59 +09:00
moons-14 483d343f48 flake: add coding-agent validation workflow 2026-08-06 20:16:17 +09:00
moons-14 5ebcbb4abf labwc 2026-08-06 17:22:00 +09:00
moons-14 f8fd8a3d99 wallpaper engine 2026-08-06 17:13:02 +09:00
moons-14 16742d2fd7 window overlay 2026-08-05 06:21:26 +09:00
moons-14 15da7affaa window-overview 2026-08-05 05:53:23 +09:00
moons-14 548647fec7 window switch 2026-08-05 05:27:40 +09:00
moons-14 bcbd08c225 wallpaper vicinae 2026-08-05 05:04:22 +09:00
moons-14 a0ae83d24e feat 2026-08-05 04:37:32 +09:00
moons-14 33e09f8e93 normcap-translate 2026-08-05 03:56:16 +09:00
moons-14 eff32fddcd background-opacity 2026-08-05 03:56:16 +09:00
moons-14 8ce3a6082a dns 2026-08-05 03:56:16 +09:00
moons-14 a8246261ad noctalia: add taskbar overview command hook (#61) 2026-08-05 03:54:14 +09:00
moons-14 8b51b5c55f noctalia taskbar 2026-08-05 02:55:46 +09:00
moons-14 e24d85da56 echo cancel 2026-08-05 02:10:48 +09:00
moons-14 01ead9a385 labwc suspend 2026-08-05 02:10:36 +09:00
moons-14 328f11d0ed screencast 2026-08-05 01:40:35 +09:00
moons-14 d877ffc76c nh 2026-08-05 01:40:18 +09:00
moons-14 fe40adaeee activity watch 2026-08-05 00:59:32 +09:00
moons-14 991dde5305 noctalia patch 2026-08-05 00:40:02 +09:00
moons-14 96ce4d768c nani wayland 2026-08-05 00:16:12 +09:00
moons-14 30b1480e50 hazkey 2026-08-04 23:06:25 +09:00
moons-14 71011d7bd1 thunderbird 2026-08-04 23:06:11 +09:00
moons-14 9cced59e58 thunderbird 2026-08-04 23:06:01 +09:00
moons-14 20a601402e rate 2026-08-04 17:03:03 +09:00
moons-14 1b4a5fa5a2 wallpaper engine 2026-08-04 16:58:40 +09:00
moons-14 5fb55f2e6a open ghostty 2026-08-04 16:49:56 +09:00
moons-14 2a3f7ee6ff nani 2026-08-04 16:16:32 +09:00
moons-14 247db71f2d nani 2026-08-04 16:04:44 +09:00
moons-14 a44a83a587 handy 2026-08-04 15:43:12 +09:00
moons-14 1f1d46ea2a find-cursor 2026-08-04 15:26:43 +09:00
moons-14 29c4815b88 screenshot 2026-08-04 14:59:21 +09:00
moons-14 28a46d9990 skill 2026-08-04 14:42:31 +09:00
80 changed files with 4045 additions and 202 deletions
+13
View File
@@ -0,0 +1,13 @@
[mcp_servers.nixos]
command = "mcp-nixos"
startup_timeout_sec = 30
tool_timeout_sec = 60
required = false
[mcp_servers.github]
url = "https://api.githubcopilot.com/mcp/"
bearer_token_env_var = "GITHUB_PERSONAL_ACCESS_TOKEN"
http_headers = { X-MCP-Readonly = "true", X-MCP-Toolsets = "repos,pull_requests,actions" }
startup_timeout_sec = 30
tool_timeout_sec = 60
required = false
+8 -20
View File
@@ -1,27 +1,15 @@
# Reference: https://github.com/ryoppippi/dotfiles/blob/main/.github/workflows/nix-build.yaml
name: Check NixOS configurations
description: Build every NixOS configuration and the Registry tests
name: Build Linux checks
description: Build every x86_64-linux flake check in parallel
runs:
using: composite
steps:
- name: Build every NixOS configuration
- name: Build all Linux checks
shell: bash
run: |
set -euo pipefail
mapfile -t hosts < <(
nix eval --raw .#nixosConfigurations \
--apply 'configs: builtins.concatStringsSep "\n" (builtins.attrNames configs)'
)
installables=(.#checks.x86_64-linux.registry)
for host in "${hosts[@]}"; do
installables+=(".#nixosConfigurations.${host}.config.system.build.toplevel")
done
nix build \
--keep-going \
--no-link \
--print-build-logs \
--show-trace \
"${installables[@]}"
nix run .#nix-fast-build -- \
--flake .#checks.x86_64-linux \
--skip-cached \
--no-nom \
--no-link
+1
View File
@@ -20,3 +20,4 @@ runs:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}-
gc-max-store-size-linux: 4G
gc-max-store-size-macos: 4G
+163 -10
View File
@@ -1,36 +1,50 @@
# Reference: https://github.com/ryoppippi/dotfiles/blob/main/.github/workflows/nix-build.yaml
name: "CI: NixOS"
name: "CI: Nix"
on:
push:
branches:
- main
paths:
- .gitignore
- AGENTS.md
- README.md
- "docs/**"
- flake.nix
- flake.lock
- ".codex/**"
- ".github/**"
- ".vscode/**"
- "flake/**"
- "hosts/**"
- "libs/**"
- "modules/**"
- "opencode.json"
- "overlays/**"
- "scripts/**"
- "shells/**"
- "skills/**"
- "tests/**"
- ".github/actions/check-nixos/**"
- ".github/actions/setup-nix/**"
- ".github/workflows/nixos.yaml"
pull_request:
paths:
- .gitignore
- AGENTS.md
- README.md
- "docs/**"
- flake.nix
- flake.lock
- ".codex/**"
- ".github/**"
- ".vscode/**"
- "flake/**"
- "hosts/**"
- "libs/**"
- "modules/**"
- "opencode.json"
- "overlays/**"
- "scripts/**"
- "shells/**"
- "skills/**"
- "tests/**"
- ".github/actions/check-nixos/**"
- ".github/actions/setup-nix/**"
- ".github/workflows/nixos.yaml"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -38,14 +52,153 @@ concurrency:
permissions:
contents: read
jobs:
check:
name: Check all NixOS configurations
validate:
name: Plan, lint, and evaluate
runs-on: ubuntu-latest
timeout-minutes: 120
outputs:
build_linux: ${{ steps.plan.outputs.build_linux }}
build_darwin: ${{ steps.plan.outputs.build_darwin }}
nix_validation: ${{ steps.plan.outputs.nix_validation }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Setup Nix
uses: ./.github/actions/setup-nix
- name: Check NixOS configurations
- name: Plan validation
id: plan
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BASE_SHA: ${{ github.event.before }}
shell: bash
run: |
set -euo pipefail
case "${{ github.event_name }}" in
pull_request)
plan="$(nix run .#check -- plan --base "$PR_BASE_SHA" --json)"
;;
push)
plan="$(nix run .#check -- plan --base "$PUSH_BASE_SHA" --json)"
;;
*)
plan="$(nix run .#check -- plan --all-files --all-hosts --json)"
;;
esac
printf '%s\n' "$plan"
nix_validation="$(jq -r '.requiresNixValidation' <<<"$plan")"
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
build_linux="$(jq -r '.nativeBuildSystems["x86_64-linux"] // false' <<<"$plan")"
build_darwin="$(jq -r '.nativeBuildSystems["aarch64-darwin"] // false' <<<"$plan")"
else
build_linux="$nix_validation"
build_darwin="$nix_validation"
fi
{
echo "nix_validation=$nix_validation"
echo "build_linux=$build_linux"
echo "build_darwin=$build_darwin"
} >> "$GITHUB_OUTPUT"
- name: Run fast checks
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BASE_SHA: ${{ github.event.before }}
shell: bash
run: |
set +e
set -uo pipefail
case "${{ github.event_name }}" in
pull_request)
nix run .#check -- fast --base "$PR_BASE_SHA"
;;
push)
nix run .#check -- fast --base "$PUSH_BASE_SHA"
;;
*)
nix run .#check -- fast --all-files
;;
esac
status=$?
if (( status != 0 )); then
git diff -- .
exit "$status"
fi
- name: Evaluate configurations
if: steps.plan.outputs.nix_validation == 'true' || github.event_name == 'workflow_dispatch'
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BASE_SHA: ${{ github.event.before }}
shell: bash
run: |
set -euo pipefail
case "${{ github.event_name }}" in
pull_request)
nix run .#check -- eval --base "$PR_BASE_SHA"
;;
push)
nix run .#check -- eval --base "$PUSH_BASE_SHA" --all-systems
;;
*)
nix run .#check -- eval --all-hosts --all-systems
;;
esac
build-linux:
name: Build Linux checks
needs: validate
if: needs.validate.outputs.build_linux == 'true'
runs-on: ubuntu-latest
timeout-minutes: 180
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Setup Nix
uses: ./.github/actions/setup-nix
- name: Build affected Linux checks
if: github.event_name == 'pull_request'
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
shell: bash
run: |
set -euo pipefail
nix run .#check -- build --base "$PR_BASE_SHA"
- name: Build all Linux checks
if: github.event_name != 'pull_request'
uses: ./.github/actions/check-nixos
build-darwin:
name: Build Darwin checks
needs: validate
if: needs.validate.outputs.build_darwin == 'true'
runs-on: macos-15
timeout-minutes: 180
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Setup Nix
uses: ./.github/actions/setup-nix
- name: Build affected Darwin checks
if: github.event_name == 'pull_request'
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
shell: bash
run: |
set -euo pipefail
nix run .#check -- build --base "$PR_BASE_SHA"
- name: Build all Darwin checks
if: github.event_name != 'pull_request'
shell: bash
run: |
set -euo pipefail
nix run .#nix-fast-build -- \
--flake .#checks.aarch64-darwin \
--skip-cached \
--no-nom \
--no-link
+66 -5
View File
@@ -12,9 +12,11 @@ permissions:
pull-requests: write
jobs:
update:
name: Update and validate flake inputs
name: Update and check Linux
runs-on: ubuntu-latest
timeout-minutes: 120
timeout-minutes: 180
outputs:
changed: ${{ steps.update.outputs.changed }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -22,18 +24,76 @@ jobs:
uses: ./.github/actions/setup-nix
- name: Update flake inputs
id: update
shell: bash
run: |
set -euo pipefail
nix flake update
if git diff --quiet -- flake.lock; then
echo 'changed=false' >> "$GITHUB_OUTPUT"
else
echo 'changed=true' >> "$GITHUB_OUTPUT"
fi
- name: Check updated NixOS configurations
- name: Evaluate every flake system
if: steps.update.outputs.changed == 'true'
shell: bash
run: |
set -euo pipefail
nix flake check \
--no-build \
--all-systems \
--keep-going \
--show-trace
- name: Build Linux checks
if: steps.update.outputs.changed == 'true'
uses: ./.github/actions/check-nixos
- name: Create update pull request
- name: Upload updated lock file
if: steps.update.outputs.changed == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: flake-lock
path: flake.lock
if-no-files-found: error
check-darwin:
name: Check Darwin
needs: update
if: needs.update.outputs.changed == 'true'
runs-on: macos-15
timeout-minutes: 180
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Download updated lock file
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: flake-lock
path: .
- name: Setup Nix
uses: ./.github/actions/setup-nix
- name: Build Darwin checks
shell: bash
run: |
set -euo pipefail
nix run .#nix-fast-build -- \
--flake .#checks.aarch64-darwin \
--skip-cached \
--no-nom \
--no-link
pull-request:
name: Create update pull request
needs:
- update
- check-darwin
if: needs.update.outputs.changed == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Download validated lock file
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: flake-lock
path: .
- name: Create update pull request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -45,4 +105,5 @@ jobs:
body: |
Automated update of `flake.lock`.
The updated inputs passed the Registry tests and a build of every NixOS configuration.
The updated inputs passed all-system evaluation and native builds of
the Linux and Darwin check sets.
+8 -1
View File
@@ -1,4 +1,6 @@
/*
**/__pycache__/
*.py[cod]
!.gitignore
!README.md
@@ -6,8 +8,10 @@
!AGENTS.md
!/docs/
!.github/
!/.codex/
!/.github/
!.gitea/
!/.vscode/
!.envrc
@@ -15,7 +19,9 @@
!/flake.nix
!/flake.lock
!/opencode.json
!/scripts/
!/shells/
!/flake/
!/overlays/
@@ -30,3 +36,4 @@
!/modules/
!/tests/
!/skills/
+28
View File
@@ -0,0 +1,28 @@
{
"nix.enableLanguageServer": true,
"nix.serverPath": "nixd",
"nix.serverSettings": {
"nixd": {
"formatting": {
"command": ["nixfmt"]
},
"nixpkgs": {
"expr": "import (builtins.getFlake (builtins.toString ./.)).inputs.nixpkgs { }"
},
"options": {
"nixos": {
"expr": "(builtins.getFlake (builtins.toString ./.)).nixosConfigurations.galleria.options"
},
"home-manager-nixos": {
"expr": "(builtins.getFlake (builtins.toString ./.)).nixosConfigurations.galleria.options.home-manager.users.type.getSubOptions []"
},
"darwin": {
"expr": "(builtins.getFlake (builtins.toString ./.)).darwinConfigurations.m2.options"
},
"home-manager-darwin": {
"expr": "(builtins.getFlake (builtins.toString ./.)).darwinConfigurations.m2.options.home-manager.users.type.getSubOptions []"
}
}
}
}
}
+5
View File
@@ -24,6 +24,11 @@ makes any statement here stale, update `AGENTS.md` in the same change.
| `overlays/` | Package replacements and additions |
| `shells/` | Development shells |
| `flake/` | Supporting flake outputs such as formatters, checks, and Git hooks |
| `skills/` | Repository-specific Codex workflows that enforce this contract for recurring changes |
Before adding or materially extending an application or service, read and
follow `skills/add-application-or-service/SKILL.md`. `AGENTS.md` remains the
authoritative contract when the skill and repository ever disagree.
Use **unit** as the generic internal term for a Registry-managed component and
**profile** for a unit that composes multiple units. Do not introduce a
+4 -1
View File
@@ -1,3 +1,6 @@
# moons14 dotfiles
My NixOS + Home Manager configurations build with flake.
My NixOS, nix-darwin, and Home Manager configurations built with flakes.
See [Coding-agent validation](docs/coding-agents.md) for the non-activating,
change-aware validation workflow used by Codex, OpenCode, and CI.
+86
View File
@@ -0,0 +1,86 @@
# Coding-agent workflow
This repository exposes deterministic validation and narrowly scoped research
tools for Codex, OpenCode, and editor agents. Live system activation is outside
this workflow.
## Validation commands
Use task-owned paths during the edit loop:
```console
nix run .#check -- plan --paths modules/applications/example/home.nix --json
nix run .#check -- fast --paths modules/applications/example/home.nix
nix run .#check -- eval --paths modules/applications/example/home.nix
nix run .#check -- build --paths modules/applications/example/home.nix
nix run .#check -- all --paths modules/applications/example/home.nix
```
For a committed pull-request range, replace `--paths ...` with
`--base <base-sha>`. `full` is reserved for CI, scheduled maintenance, or an
explicit repository-wide audit:
```console
nix run .#check -- full
```
The stages have distinct meanings:
- `plan` maps changed paths to Registry units, reverse `meta.includes`
dependencies, real hosts, and compatible build systems.
- `fast` parses changed Nix files, validates JSON, TOML, Python, and Agent Skill
frontmatter, checks whitespace, and runs configured hooks only for the
selected files.
- `eval` instantiates affected NixOS and nix-darwin configurations. Flake-wide,
validation-tool, shell, overlay, and test changes additionally evaluate every
flake system.
- `build` realizes affected configurations supported by the current platform
with no result link. Incompatible targets remain evaluation-only until a
matching runner handles them.
- `all` performs task-scoped file checks, all-system evaluation, and compatible
targeted builds.
- `full` runs all-file hooks, all-system evaluation, and every check for the
current platform through `nix-fast-build`.
The validation app constructs a filtered temporary `path:` flake from the
committed `HEAD` tree and overlays only task-owned changed paths. This isolates
unrelated worktree changes, makes new untracked Registry fragments visible to
Nix without staging them, and avoids copying ignored state such as `.direnv`.
None of these commands runs `nh os switch`, `nixos-rebuild switch`,
`darwin-rebuild switch`, `home-manager switch`, or another activation command.
The user performs activation separately.
## Agent Skills
Read `AGENTS.md` first. Use the repository skills as follows:
- `validate-nix-change` controls validation scope and evidence.
- `debug-nix-failure` classifies parse, evaluation, build, test, activation-log,
and runtime failures before proposing a correction.
- `test-nixos-service` adds a `pkgs.testers.runNixOSTest` check when a build
cannot prove service behavior.
- `update-flake-input` limits lock-file updates and validates them without
activating a host.
- `add-application-or-service` preserves Registry ownership and delegates
validation to `validate-nix-change`.
## MCP and language-server setup
The development workload installs `mcp-nixos`, `nixd`, `nix-fast-build`, and
`nix-tree`.
Project-local Codex and OpenCode configuration exposes:
- `mcp-nixos` for current NixOS, Home Manager, nix-darwin, package, and Nix
documentation queries;
- GitHub's remote MCP endpoint for Codex and OpenCode in read-only mode,
restricted to repository, pull-request, and Actions toolsets.
Set `GITHUB_PERSONAL_ACCESS_TOKEN` in the launching environment when GitHub MCP
access is needed. Do not commit the token or put it in a Nix expression because
that would expose it through source control or the Nix store.
The workspace VS Code settings use `nixd` and expose option sets for the
`galleria` NixOS configuration, its integrated Home Manager configuration, the
`m2` nix-darwin configuration, and its integrated Home Manager configuration.
Generated
+248 -80
View File
@@ -91,6 +91,32 @@
}
},
"bun2nix": {
"inputs": {
"flake-parts": "flake-parts_2",
"import-tree": "import-tree",
"nixpkgs": [
"handy",
"nixpkgs"
],
"systems": "systems_4",
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1770895533,
"narHash": "sha256-v3QaK9ugy9bN9RXDnjw0i2OifKmz2NnKM82agtqm/UY=",
"owner": "nix-community",
"repo": "bun2nix",
"rev": "c843f477b15f51151f8c6bcc886954699440a6e1",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "2.0.8",
"repo": "bun2nix",
"type": "github"
}
},
"bun2nix_2": {
"inputs": {
"flake-parts": [
"llm-agents",
@@ -129,11 +155,11 @@
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1785771258,
"narHash": "sha256-4qwZF7xxLBokf6gfIBkymFMQiJ36fbJ9SPkOAp5VI4I=",
"lastModified": 1785831553,
"narHash": "sha256-9q7WKbhq6bOGUdeo9Q/e3PmapJew0b2LCpaCio1X17k=",
"owner": "ilysenko",
"repo": "codex-desktop-linux",
"rev": "ab314923b5bf9b786a16cee67605d9eea2433d84",
"rev": "7166d1153fd99647fb080605c8b2a8f22b50b08f",
"type": "github"
},
"original": {
@@ -292,6 +318,24 @@
}
},
"flake-parts_2": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib_2"
},
"locked": {
"lastModified": 1769996383,
"narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-parts_3": {
"inputs": {
"nixpkgs-lib": [
"llm-agents",
@@ -312,7 +356,7 @@
"type": "github"
}
},
"flake-parts_3": {
"flake-parts_4": {
"inputs": {
"nixpkgs-lib": [
"nixvim",
@@ -333,7 +377,7 @@
"type": "github"
}
},
"flake-parts_4": {
"flake-parts_5": {
"inputs": {
"nixpkgs-lib": [
"stylix",
@@ -419,11 +463,11 @@
"zon2nix": "zon2nix"
},
"locked": {
"lastModified": 1785770443,
"narHash": "sha256-VZ9UpfgG5+Dy17eTLJ8ryV5e7Ki0/0JTA5MhWnfSriw=",
"lastModified": 1785853205,
"narHash": "sha256-rHvUeXh4druPSNPg2EMvY9F+av/NSQzjNLsf7s6lNnI=",
"owner": "ghostty-org",
"repo": "ghostty",
"rev": "863fc9531ae0b8e09b7103a9089dfc00319a7d9c",
"rev": "594ee212bc3c048ffa06ba90623eaf207a4d145c",
"type": "github"
},
"original": {
@@ -470,6 +514,27 @@
"type": "gitlab"
}
},
"handy": {
"inputs": {
"bun2nix": "bun2nix",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1785763469,
"narHash": "sha256-YHfOchlPBKbBbeoaT1amrjKBHJTYMXmcR1Gm31E7TLE=",
"owner": "cjpais",
"repo": "Handy",
"rev": "b1b2d9f9072e55902a46ca6cc655e5893ed7a91d",
"type": "github"
},
"original": {
"owner": "cjpais",
"repo": "Handy",
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
@@ -533,6 +598,21 @@
"type": "github"
}
},
"import-tree": {
"locked": {
"lastModified": 1763762820,
"narHash": "sha256-ZvYKbFib3AEwiNMLsejb/CWs/OL/srFQ8AogkebEPF0=",
"owner": "vic",
"repo": "import-tree",
"rev": "3c23749d8013ec6daa1d7255057590e9ca726646",
"type": "github"
},
"original": {
"owner": "vic",
"repo": "import-tree",
"type": "github"
}
},
"lanzaboote": {
"inputs": {
"crane": "crane",
@@ -558,18 +638,18 @@
},
"llm-agents": {
"inputs": {
"bun2nix": "bun2nix",
"flake-parts": "flake-parts_2",
"bun2nix": "bun2nix_2",
"flake-parts": "flake-parts_3",
"nixpkgs": "nixpkgs_4",
"systems": "systems_4",
"treefmt-nix": "treefmt-nix"
"systems": "systems_5",
"treefmt-nix": "treefmt-nix_2"
},
"locked": {
"lastModified": 1785753922,
"narHash": "sha256-cRnp7DSvT4BwRI04YSbA0Ocn1iY6+fN9KTfnsADHm3I=",
"lastModified": 1785826862,
"narHash": "sha256-UOPf9uQHGoNqEMZ2kii8DqlPBd5dYR6uERzdU8wdroQ=",
"owner": "numtide",
"repo": "llm-agents.nix",
"rev": "8a86f04f5cfbffc5b5d0b38ac24795ab4909b46b",
"rev": "fd0508ef842609ad0c6f5af46c6f572214efebd9",
"type": "github"
},
"original": {
@@ -578,11 +658,29 @@
"type": "github"
}
},
"nani-translate-linux": {
"inputs": {
"nixpkgs": "nixpkgs_5"
},
"locked": {
"lastModified": 1785857772,
"narHash": "sha256-JMEQH9x8Pb9cHW4JbWfFBcqxsh4JkPDR5k8dCnphUTU=",
"ref": "refs/heads/main",
"rev": "9d6fec627a620841c27c7e67e8e7b5719ce9b465",
"revCount": 8,
"type": "git",
"url": "https://github.com/zunoser/nani-translate-linux.git"
},
"original": {
"type": "git",
"url": "https://github.com/zunoser/nani-translate-linux.git"
}
},
"niri-flake": {
"inputs": {
"niri-stable": "niri-stable",
"niri-unstable": "niri-unstable",
"nixpkgs": "nixpkgs_5",
"nixpkgs": "nixpkgs_6",
"nixpkgs-stable": "nixpkgs-stable",
"xwayland-satellite-stable": "xwayland-satellite-stable",
"xwayland-satellite-unstable": "xwayland-satellite-unstable"
@@ -698,7 +796,7 @@
},
"nixos-hardware": {
"inputs": {
"nixpkgs": "nixpkgs_6"
"nixpkgs": "nixpkgs_7"
},
"locked": {
"lastModified": 1785232496,
@@ -718,7 +816,7 @@
"nixos-wsl": {
"inputs": {
"flake-compat": "flake-compat_4",
"nixpkgs": "nixpkgs_7"
"nixpkgs": "nixpkgs_8"
},
"locked": {
"lastModified": 1784642409,
@@ -765,6 +863,21 @@
"type": "github"
}
},
"nixpkgs-lib_2": {
"locked": {
"lastModified": 1769909678,
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "72716169fe93074c333e8d0173151350670b824c",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixpkgs-libxml2": {
"locked": {
"lastModified": 1751274312,
@@ -814,6 +927,19 @@
}
},
"nixpkgs_10": {
"locked": {
"lastModified": 1785571196,
"narHash": "sha256-xwSqxTsama0YTtS78+4YyCm6jJg09v78QWfP1cAyoVA=",
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
"type": "tarball",
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.11pre1045728.148bab9c1c3c/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
"url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"
}
},
"nixpkgs_11": {
"locked": {
"lastModified": 1770107345,
"narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=",
@@ -829,7 +955,7 @@
"type": "github"
}
},
"nixpkgs_11": {
"nixpkgs_12": {
"locked": {
"lastModified": 1772542754,
"narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=",
@@ -845,7 +971,7 @@
"type": "github"
}
},
"nixpkgs_12": {
"nixpkgs_13": {
"locked": {
"lastModified": 1778869304,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
@@ -892,11 +1018,11 @@
},
"nixpkgs_4": {
"locked": {
"lastModified": 1785602060,
"narHash": "sha256-z7D96eESRM4CPV/XtwpwFn8IDdfLAmxz6lVWrGYXvR4=",
"lastModified": 1785747939,
"narHash": "sha256-D740uKsMbgsfK2oaDenJLLPIZfq7W0/g4KN/Fls8eKs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a5cbcfe954791221bfffe2307f7d1a1bf61a871e",
"rev": "104240a772428cc2e20d8fd86c9ddbb886bbaff2",
"type": "github"
},
"original": {
@@ -907,6 +1033,22 @@
}
},
"nixpkgs_5": {
"locked": {
"lastModified": 1785454630,
"narHash": "sha256-LQy14TZp77TwbQf40gg1V3jo8FwJG0jGDkAH+zRHqg8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1559d3daa3ecc813a650b79375ea61b6741b8746",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_6": {
"locked": {
"lastModified": 1785692966,
"narHash": "sha256-vUfIeBEfpbAfZ5zjgIkYk7eHBeVfCYVjLbWnMkseYnk=",
@@ -922,7 +1064,7 @@
"type": "github"
}
},
"nixpkgs_6": {
"nixpkgs_7": {
"locked": {
"lastModified": 1767892417,
"narHash": "sha256-8bW3q88CEg2u4hSP66Vf4lpbLonHz7hqDNBMcCY7E9U=",
@@ -935,7 +1077,7 @@
"url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"
}
},
"nixpkgs_7": {
"nixpkgs_8": {
"locked": {
"lastModified": 1783776592,
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
@@ -951,13 +1093,13 @@
"type": "github"
}
},
"nixpkgs_8": {
"nixpkgs_9": {
"locked": {
"lastModified": 1785599192,
"narHash": "sha256-dg4RTtDxnXY13UkJNdhmgTUTl0n/IJBlCigfO7nutZw=",
"lastModified": 1785734586,
"narHash": "sha256-ODZkEK9Gy50yg6h98u7KkitZ3oc/uuTFK00bh1CRdNA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6d65bfc1bcef2ef39a239d38e577e92a89fb0f07",
"rev": "531670d871c0e29724a02f3cbcac170adc65b58c",
"type": "github"
},
"original": {
@@ -967,26 +1109,13 @@
"type": "github"
}
},
"nixpkgs_9": {
"locked": {
"lastModified": 1785571196,
"narHash": "sha256-xwSqxTsama0YTtS78+4YyCm6jJg09v78QWfP1cAyoVA=",
"rev": "148bab9c1c3c53136ecb44a6ea356a0ed5b39b06",
"type": "tarball",
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.11pre1045728.148bab9c1c3c/nixexprs.tar.xz"
},
"original": {
"type": "tarball",
"url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.xz"
}
},
"nixvim": {
"inputs": {
"flake-parts": "flake-parts_3",
"flake-parts": "flake-parts_4",
"nixpkgs": [
"nixpkgs"
],
"systems": "systems_5"
"systems": "systems_6"
},
"locked": {
"lastModified": 1782919967,
@@ -1005,14 +1134,14 @@
},
"noctalia": {
"inputs": {
"nixpkgs": "nixpkgs_9"
"nixpkgs": "nixpkgs_10"
},
"locked": {
"lastModified": 1785767538,
"narHash": "sha256-9foQ31PSKpIu8UWGalt+4G5o4TU21g4a1qwMwJmeOy4=",
"lastModified": 1785809412,
"narHash": "sha256-QbFIduUhpNIucJy/WcwRxsvTQocnwIXlSwnYEjmGMSQ=",
"owner": "noctalia-dev",
"repo": "noctalia",
"rev": "50f0f095053a4b5c10874c4e7080f55dc15d3b38",
"rev": "e41c99439605df6d2fa62409139f96ded6ac7345",
"type": "github"
},
"original": {
@@ -1077,24 +1206,26 @@
"flake-parts": "flake-parts",
"ghostty": "ghostty",
"git-hooks-nix": "git-hooks-nix",
"handy": "handy",
"home-manager": "home-manager_2",
"lanzaboote": "lanzaboote",
"llm-agents": "llm-agents",
"nani-translate-linux": "nani-translate-linux",
"niri-flake": "niri-flake",
"nix-darwin": "nix-darwin",
"nix-hazkey": "nix-hazkey",
"nix-index-database": "nix-index-database",
"nixos-hardware": "nixos-hardware",
"nixos-wsl": "nixos-wsl",
"nixpkgs": "nixpkgs_8",
"nixpkgs": "nixpkgs_9",
"nixpkgs-unstable": "nixpkgs-unstable",
"nixvim": "nixvim",
"noctalia": "noctalia",
"services-flake": "services-flake",
"sops-nix": "sops-nix",
"stylix": "stylix",
"systems": "systems_7",
"treefmt-nix": "treefmt-nix_2",
"systems": "systems_8",
"treefmt-nix": "treefmt-nix_3",
"vicinae": "vicinae",
"vicinae-extensions": "vicinae-extensions"
}
@@ -1157,7 +1288,7 @@
},
"soulver-cpp": {
"inputs": {
"nixpkgs": "nixpkgs_12",
"nixpkgs": "nixpkgs_13",
"nixpkgs-libxml2": "nixpkgs-libxml2"
},
"locked": {
@@ -1181,24 +1312,24 @@
"base16-helix": "base16-helix",
"base16-vim": "base16-vim",
"firefox-gnome-theme": "firefox-gnome-theme",
"flake-parts": "flake-parts_4",
"flake-parts": "flake-parts_5",
"gnome-shell": "gnome-shell",
"nixpkgs": [
"nixpkgs"
],
"nur": "nur",
"systems": "systems_6",
"systems": "systems_7",
"tinted-kitty": "tinted-kitty",
"tinted-schemes": "tinted-schemes",
"tinted-tmux": "tinted-tmux",
"tinted-zed": "tinted-zed"
},
"locked": {
"lastModified": 1785680312,
"narHash": "sha256-e26BuraMqnara0VXVk9nsQLgGC+8nvEgYNc6odv5Yi8=",
"lastModified": 1785794750,
"narHash": "sha256-OqIrGVL7AX462ISyJFAqjbqTc1RZlBkVHCa4dwPEZU4=",
"owner": "nix-community",
"repo": "stylix",
"rev": "04b284060ffa638f080ab1b2d05cf1f236217995",
"rev": "cb5eb3a7343faba61fd694fac8040a326485a339",
"type": "github"
},
"original": {
@@ -1222,6 +1353,21 @@
"type": "github"
}
},
"systems_10": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
@@ -1284,6 +1430,21 @@
}
},
"systems_6": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_7": {
"locked": {
"lastModified": 1774449309,
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
@@ -1299,21 +1460,6 @@
"type": "github"
}
},
"systems_7": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_8": {
"locked": {
"lastModified": 1681028828,
@@ -1409,6 +1555,28 @@
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"handy",
"bun2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1770228511,
"narHash": "sha256-wQ6NJSuFqAEmIg2VMnLdCnUc0b7vslUohqqGGD+Fyxk=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "337a4fe074be1042a35086f15481d763b8ddc0e7",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
},
"treefmt-nix_2": {
"inputs": {
"nixpkgs": [
"llm-agents",
@@ -1429,9 +1597,9 @@
"type": "github"
}
},
"treefmt-nix_2": {
"treefmt-nix_3": {
"inputs": {
"nixpkgs": "nixpkgs_10"
"nixpkgs": "nixpkgs_11"
},
"locked": {
"lastModified": 1785360170,
@@ -1449,16 +1617,16 @@
},
"vicinae": {
"inputs": {
"nixpkgs": "nixpkgs_11",
"nixpkgs": "nixpkgs_12",
"soulver-cpp": "soulver-cpp",
"systems": "systems_8"
"systems": "systems_9"
},
"locked": {
"lastModified": 1785733880,
"narHash": "sha256-Rv4rVFSvxF7ViOQOrv+N8IEvweS5MCqUM2fnaQVIR+w=",
"lastModified": 1785823793,
"narHash": "sha256-iGKyp+peQzXI0T9LzYmRHr2tevkbhF5GfzDlVldVm2Q=",
"owner": "vicinaehq",
"repo": "vicinae",
"rev": "61bd7cd5c9cdd6d8df6e6d34531beb344219ff71",
"rev": "b3130be7b480817d2255b4781476a91593267afb",
"type": "github"
},
"original": {
@@ -1473,7 +1641,7 @@
"nixpkgs": [
"nixpkgs"
],
"systems": "systems_9",
"systems": "systems_10",
"vicinae": "vicinae_2"
},
"locked": {
+9
View File
@@ -38,6 +38,12 @@
url = "github:ghostty-org/ghostty";
};
# Speech to text
handy = {
url = "github:cjpais/Handy";
inputs.nixpkgs.follows = "nixpkgs";
};
# Shell / Launcher
vicinae.url = "github:vicinaehq/vicinae";
@@ -104,6 +110,9 @@
url = "github:nix-community/browser-previews";
inputs.nixpkgs.follows = "nixpkgs";
};
nani-translate-linux.url = "git+https://github.com/zunoser/nani-translate-linux.git";
};
outputs =
+1
View File
@@ -3,5 +3,6 @@
./formatter.nix
./git-hooks.nix
./registry.nix
./validation.nix
];
}
+2
View File
@@ -34,7 +34,9 @@
];
};
actionlint.enable = true;
deadnix.enable = true;
ruff.enable = true;
statix.enable = true;
shellcheck.enable = true;
};
+38 -7
View File
@@ -25,11 +25,42 @@ let
else
{ };
configurations = dotfilesLib.hosts.mkConfigurations hostSpecs;
validationMetadata = {
schemaVersion = 1;
hosts = lib.mapAttrs (
name: spec:
let
isDarwin = lib.hasSuffix "-darwin" spec.system;
in
{
inherit (spec) system user;
kind = if isDarwin then "darwin" else "nixos";
homeManager = spec.homeManager or true;
selectedUnits = dotfilesLib.hosts.selectedUnits spec;
buildAttr =
if isDarwin then
"darwinConfigurations.${name}.system"
else
"nixosConfigurations.${name}.config.system.build.toplevel";
}
) hostSpecs;
units = lib.mapAttrs (_id: unit: {
inherit (unit) id relativePath;
directory = "modules/${lib.concatStringsSep "/" unit.relativePath}";
includes = unit.meta.includes;
fragments = builtins.attrNames (lib.filterAttrs (_: value: value != null) unit.fragments);
}) dotfilesLib.registry.units;
};
in
{
flake = {
inherit (configurations) darwinConfigurations nixosConfigurations;
lib = dotfilesLib;
lib = dotfilesLib // {
inherit validationMetadata;
};
};
perSystem =
@@ -37,11 +68,10 @@ in
let
nixosChecks =
lib.mapAttrs' (name: nixos: lib.nameValuePair "nixos-${name}" nixos.config.system.build.toplevel)
(
lib.filterAttrs (
_: nixos: nixos.pkgs.stdenv.hostPlatform.system == system
) configurations.nixosConfigurations
);
(lib.filterAttrs (name: _: hostSpecs.${name}.system == system) configurations.nixosConfigurations);
darwinChecks = lib.mapAttrs' (name: darwin: lib.nameValuePair "darwin-${name}" darwin.system) (
lib.filterAttrs (name: _: hostSpecs.${name}.system == system) configurations.darwinConfigurations
);
in
{
checks = {
@@ -49,6 +79,7 @@ in
inherit inputs lib pkgs;
};
}
// nixosChecks;
// nixosChecks
// darwinChecks;
};
}
+51
View File
@@ -0,0 +1,51 @@
_: {
perSystem =
{ pkgs, config, ... }:
let
script = pkgs.writeText "dotfiles-check.py" (builtins.readFile ../scripts/dotfiles-check.py);
dotfilesCheck = pkgs.writeShellApplication {
name = "dotfiles-check";
runtimeInputs = [
pkgs.git
pkgs.nix
pkgs.python3
];
text = ''
exec python3 ${script} "$@"
'';
};
in
{
apps = {
check = {
type = "app";
program = "${dotfilesCheck}/bin/dotfiles-check";
};
nix-fast-build = {
type = "app";
program = "${pkgs.nix-fast-build}/bin/nix-fast-build";
};
};
packages = {
dotfiles-check = dotfilesCheck;
inherit (pkgs) nix-fast-build;
};
devShells.validation = config.pre-commit.devShell;
checks = {
validation-tool =
pkgs.runCommand "dotfiles-check-self-test"
{
nativeBuildInputs = [ dotfilesCheck ];
}
''
dotfiles-check self-test
touch "$out"
'';
dotnix-shell = config.devShells.dotnix;
};
};
}
+1
View File
@@ -104,6 +104,7 @@
"interface.cli"
"interface.labwc"
"interface.niri"
"interface.wallpaperengine"
"platform.intel-nvidia-desktop"
"security.secrets"
"security.secure-boot"
@@ -0,0 +1,8 @@
{
homebrew.casks = [ "activitywatch" ];
launchd.agents.activitywatch = {
command = "/usr/bin/open -gja ActivityWatch";
serviceConfig.RunAtLoad = true;
};
}
@@ -0,0 +1,11 @@
{ pkgs, ... }:
{
services.activitywatch = {
enable = true;
watchers.aw-awatcher = {
package = pkgs.awatcher;
executable = "awatcher";
};
};
}
@@ -0,0 +1,3 @@
{
description = "ActivityWatch automated time tracker";
}
+7 -2
View File
@@ -1,7 +1,12 @@
{ pkgs, ... }:
{ inputs, pkgs, ... }:
{
services.hazkey.enable = true;
services.hazkey = {
enable = true;
server.package =
inputs.nix-hazkey.packages.${pkgs.stdenv.hostPlatform.system}.hazkey-server.override
{ enableVulkan = true; };
};
i18n.inputMethod = {
enable = true;
@@ -6,6 +6,7 @@ _: {
theme = "dracula";
background-blur-radius = 20;
background-opacity = 0.9;
background-opacity-cells = true;
font-family = "BlexMono Nerd Font Mono";
mouse-hide-while-typing = true;
window-decoration = "auto";
+8
View File
@@ -0,0 +1,8 @@
{
homebrew.casks = [ "handy" ];
launchd.agents.handy = {
command = "/usr/bin/open -gja Handy --args --start-hidden";
serviceConfig.RunAtLoad = true;
};
}
+74
View File
@@ -0,0 +1,74 @@
{
config,
inputs,
lib,
pkgs,
...
}:
let
handy = inputs.handy.packages.${pkgs.stdenv.hostPlatform.system}.handy;
settingsFile = "${config.xdg.configHome}/com.pais.handy/settings_store.json";
in
{
home.packages = [
handy
pkgs.wtype
];
# Handy has no disabled-shortcut setting. Empty bindings are rejected before
# registration, leaving niri and labwc as the sole owners of Ctrl+Space.
home.activation.disableHandyGlobalShortcuts = {
after = [ "writeBoundary" ];
before = [ ];
data = ''
settingsFile=${lib.escapeShellArg settingsFile}
mkdir -p "$(dirname "$settingsFile")"
if [ -f "$settingsFile" ]; then
${lib.getExe pkgs.jq} \
'.settings.bindings.transcribe.current_binding = ""
| .settings.bindings.transcribe_with_post_process.current_binding = ""' \
"$settingsFile" > "$settingsFile.tmp"
else
${lib.getExe pkgs.jq} -n '
{
settings: {
bindings: {
transcribe: {
id: "transcribe",
name: "Transcribe",
description: "Converts your speech into text.",
default_binding: "ctrl+space",
current_binding: ""
},
transcribe_with_post_process: {
id: "transcribe_with_post_process",
name: "Transcribe with Post-Processing",
description: "Converts your speech into text and applies AI post-processing.",
default_binding: "ctrl+shift+space",
current_binding: ""
}
}
}
}
' > "$settingsFile.tmp"
fi
mv "$settingsFile.tmp" "$settingsFile"
'';
};
systemd.user.services.handy = {
Unit = {
Description = "Handy speech-to-text";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
};
Service = {
ExecStart = "${lib.getExe handy} --start-hidden";
Restart = "on-failure";
RestartSec = 5;
};
Install.WantedBy = [ "graphical-session.target" ];
};
}
+3
View File
@@ -0,0 +1,3 @@
{
description = "Handy offline speech-to-text";
}
+10 -9
View File
@@ -1,7 +1,5 @@
{ lib, pkgs, ... }:
let
screenshot = import ./screenshot.nix { inherit pkgs; };
systemctl = lib.getExe' pkgs.systemd "systemctl";
keybind = key: actionAttrs: {
@@ -62,8 +60,6 @@ let
};
in
{
home.packages = [ screenshot ];
wayland.windowManager.labwc = {
enable = true;
# NixOS owns the package so Home Manager only generates the user config.
@@ -106,8 +102,8 @@ in
(action "W-q" "Close")
(action "W-f" "ToggleMaximize")
(action "W-c" "Iconify")
(action "W-Tab" "NextWindow")
(action "W-S-Tab" "PreviousWindow")
(execute "W-Tab" "window-overview")
(execute "W-S-Tab" "window-overview --reverse")
(action "W-Up" "Lower")
(action "W-Down" "Raise")
(action "W-Left" "NextWindow")
@@ -117,9 +113,9 @@ in
(snapToEdge "W-C-Up" "up")
(snapToEdge "W-C-Down" "down")
(confirmAction "W-S-e" "Exit labwc?" "Exit")
(execute "Print" "labwc-screenshot region")
(execute "C-Print" "labwc-screenshot output")
(execute "A-Print" "labwc-screenshot all")
(execute "Print" "screenshot region")
(execute "C-Print" "screenshot output")
(execute "A-Print" "screenshot all")
# spawn applications (sync with niri modules/applications/niri/home.nix)
(execute "W-t" "ghostty")
@@ -128,8 +124,13 @@ in
(execute "W-e" "nautilus --new-window")
(execute "W-l" "loginctl lock-session")
(execute "W-v" "vicinae vicinae://launch/clipboard/history?toggle=true")
(execute "W-j" "nani-translate-primary")
(execute "W-S-j" "nani-translate-ocr")
(execute "W-C-j" "${lib.getExe' pkgs.xdg-utils "xdg-open"} naniapp://translate")
(execute "W-space" "ghostty +toggle-quick-terminal")
(execute "W-p" "wdisplays")
(execute "W-z" "wl-find-cursor -c 0xCCFF453A -s 160 -d 1200")
(execute "C-space" "handy --toggle-transcription")
# Function keys (sync with niri modules/applications/niri/home.nix)
(execute "XF86AudioRaiseVolume" "noctalia msg volume-up")
+5 -1
View File
@@ -1,5 +1,9 @@
{
description = "labwc Wayland stacking compositor";
includes = [ "systems.wayland" ];
includes = [
"systems.wayland"
"applications.screenshot"
"applications.wl-find-cursor"
];
}
+26
View File
@@ -1,10 +1,36 @@
{
inputs,
lib,
pkgs,
...
}:
let
unstable = inputs.nixpkgs-unstable.legacyPackages.${pkgs.stdenv.hostPlatform.system};
labwc = unstable.labwc.overrideAttrs (oldAttrs: {
patches = (oldAttrs.patches or [ ]) ++ [
./patches/remove-ext-workspace-output-on-destroy.patch
];
});
in
{
programs.labwc = {
enable = true;
package = labwc;
};
xdg.portal.config.labwc.default = [
"wlr"
"gtk"
];
# xdg-desktop-portal-wlr implements screencasting for wlroots compositors.
# Keep it with Labwc: Niri uses xdg-desktop-portal-gnome instead.
xdg.portal.wlr = {
enable = true;
settings.screencast = {
chooser_type = "dmenu";
chooser_cmd = "${pkgs.fuzzel}/bin/fuzzel --dmenu";
};
};
# NixOS decides whether a graphical session manages graphical-session.target
@@ -0,0 +1,103 @@
From: Codex <[email protected]>
Subject: [PATCH] output: detach destroyed outputs from protocol handles
The DRM backend destroys and recreates outputs when a session is paused and
resumed. Remove the output from the ext-workspace group and the foreign
toplevel handles before wlroots finishes it. Both protocols attach bind
listeners to the output; leaving either listener behind makes
wlr_output_finish() abort.
---
src/foreign-toplevel/foreign.c | 14 ++++++++++++++
src/output.c | 6 ++++++
include/foreign-toplevel/foreign.h | 2 ++
3 files changed, 22 insertions(+)
diff --git a/src/output.c b/src/output.c
index 2eab8ec..6f6e7ef 100644
--- a/src/output.c
+++ b/src/output.c
@@ -27,8 +27,9 @@
#include "common/macros.h"
#include "common/mem.h"
#include "common/scene-helpers.h"
#include "common/string-helpers.h"
#include "config/rcxml.h"
+#include "foreign-toplevel/foreign.h"
#include "labwc.h"
#include "layers.h"
#include "node.h"
@@ -276,7 +277,15 @@ handle_output_destroy(struct wl_listener *listener, void *data)
struct output *output = wl_container_of(listener, output, destroy);
struct seat *seat = &server.seat;
regions_evacuate_output(output);
regions_destroy(seat, &output->regions);
+ wlr_ext_workspace_group_handle_v1_output_leave(
+ server.workspaces.ext_group, output->wlr_output);
+
+ struct view *view;
+ wl_list_for_each(view, &server.views, link) {
+ foreign_toplevel_remove_output(view->foreign_toplevel, output->wlr_output);
+ }
+
if (seat->overlay.active.output == output) {
overlay_finish(seat);
}
@@ -297,7 +305,6 @@ handle_output_destroy(struct wl_listener *listener, void *data)
output->workspace_osd = NULL;
}
- struct view *view;
wl_list_for_each(view, &server.views, link) {
if (view->output == output) {
view_on_output_destroy(view);
diff --git a/include/foreign-toplevel/foreign.h b/include/foreign-toplevel/foreign.h
index 69a340a..d8ec9b4 100644
--- a/include/foreign-toplevel/foreign.h
+++ b/include/foreign-toplevel/foreign.h
@@ -3,11 +3,14 @@
#define LABWC_FOREIGN_TOPLEVEL_H
struct view;
+struct wlr_output;
struct foreign_toplevel;
struct foreign_toplevel *foreign_toplevel_create(struct view *view);
void foreign_toplevel_set_parent(struct foreign_toplevel *toplevel,
struct foreign_toplevel *parent);
+void foreign_toplevel_remove_output(struct foreign_toplevel *toplevel,
+ struct wlr_output *output);
void foreign_toplevel_destroy(struct foreign_toplevel *toplevel);
#endif /* LABWC_FOREIGN_TOPLEVEL_H */
diff --git a/src/foreign-toplevel/foreign.c b/src/foreign-toplevel/foreign.c
index 7f1ec8d..0f628a3 100644
--- a/src/foreign-toplevel/foreign.c
+++ b/src/foreign-toplevel/foreign.c
@@ -1,4 +1,5 @@
// SPDX-License-Identifier: GPL-2.0-only
#include "foreign-toplevel/foreign.h"
+#include <wlr/types/wlr_foreign_toplevel_management_v1.h>
#include <assert.h>
#include "common/mem.h"
@@ -34,6 +34,18 @@ foreign_toplevel_set_parent(struct foreign_toplevel *toplevel, struct foreign_to
parent ? &parent->wlr_toplevel : NULL);
}
+void
+foreign_toplevel_remove_output(struct foreign_toplevel *toplevel,
+ struct wlr_output *output)
+{
+ if (!toplevel || !toplevel->wlr_toplevel.handle) {
+ return;
+ }
+
+ wlr_foreign_toplevel_handle_v1_output_leave(
+ toplevel->wlr_toplevel.handle, output);
+}
+
void
foreign_toplevel_destroy(struct foreign_toplevel *toplevel)
{
--
2.51.0
-50
View File
@@ -1,50 +0,0 @@
{ pkgs }:
pkgs.writeShellApplication {
name = "labwc-screenshot";
runtimeInputs = with pkgs; [
coreutils
grim
slurp
wl-clipboard
xdg-user-dirs
];
text = ''
mode="''${1:-region}"
pictures_dir="$(xdg-user-dir PICTURES)"
if [[ -z "$pictures_dir" ]]; then
pictures_dir="$HOME/Pictures"
fi
output_dir="$pictures_dir/Screenshots"
output_file="$output_dir/Screenshot from $(date '+%Y-%m-%d %H-%M-%S').png"
mkdir -p "$output_dir"
case "$mode" in
region)
geometry="$(slurp)" || exit 0
grim -g "$geometry" "$output_file"
;;
output)
geometry="$(slurp -o)" || exit 0
grim -g "$geometry" "$output_file"
;;
all)
grim "$output_file"
;;
*)
echo "Unknown screenshot mode: $mode" >&2
exit 2
;;
esac
wl-copy --type image/png < "$output_file"
'';
}
@@ -0,0 +1,167 @@
# shellcheck shell=bash
set -o errexit -o nounset -o pipefail
playlist_file="$1"
default_id="$2"
configuration_id="$3"
shift 3
state_dir="${XDG_RUNTIME_DIR:?}/linux-wallpaperengine"
state_file="$state_dir/current"
usage() {
printf '%s\n' \
'Usage: wallpaperengine-wallpaper COMMAND [ID]' \
'' \
'Commands:' \
' select ID Select a declaratively configured ID for this session' \
' next Select the next configured ID' \
' previous Select the previous configured ID' \
' list List configured IDs and mark the selected one' \
' current Print the selected ID' \
' restart Restart the selected wallpaper' \
' stop Stop Wallpaper Engine for this session'
}
load_playlist() {
mapfile -t wallpaper_ids < <(awk '/^[0-9]+$/ && !seen[$0]++' "$playlist_file")
if [ "${#wallpaper_ids[@]}" -eq 0 ]; then
echo "wallpaperengine-wallpaper: no wallpaper IDs are configured in settings.nix" >&2
exit 1
fi
}
contains_id() {
local candidate="$1"
local id
for id in "${wallpaper_ids[@]}"; do
[ "$id" = "$candidate" ] && return 0
done
return 1
}
current_id() {
local saved_configuration=""
local saved_id=""
local -a saved_state=()
if [ -s "$state_file" ]; then
mapfile -t saved_state <"$state_file"
saved_configuration="${saved_state[0]:-}"
saved_id="${saved_state[1]:-}"
fi
if [ "$saved_configuration" = "$configuration_id" ] && contains_id "$saved_id"; then
printf '%s\n' "$saved_id"
else
printf '%s\n' "$default_id"
fi
}
select_id() {
local id="$1"
if ! contains_id "$id"; then
echo "wallpaperengine-wallpaper: ID is not declared in settings.nix: $id" >&2
exit 2
fi
mkdir -p "$state_dir"
printf '%s\n%s\n' "$configuration_id" "$id" >"$state_file"
systemctl --user restart linux-wallpaperengine.service
}
cycle() {
local step="$1"
local current
local index=0
local next_index
current=$(current_id)
for i in "${!wallpaper_ids[@]}"; do
if [ "${wallpaper_ids[$i]}" = "$current" ]; then
index="$i"
break
fi
done
next_index=$(((index + step + ${#wallpaper_ids[@]}) % ${#wallpaper_ids[@]}))
select_id "${wallpaper_ids[$next_index]}"
}
load_playlist
command="${1:-}"
if [ -z "$command" ]; then
usage
exit 2
fi
shift
case "$command" in
select)
[ "$#" -eq 1 ] || {
usage >&2
exit 2
}
select_id "$1"
;;
next)
[ "$#" -eq 0 ] || {
usage >&2
exit 2
}
cycle 1
;;
previous)
[ "$#" -eq 0 ] || {
usage >&2
exit 2
}
cycle -1
;;
list)
[ "$#" -eq 0 ] || {
usage >&2
exit 2
}
current=$(current_id)
for id in "${wallpaper_ids[@]}"; do
if [ "$id" = "$current" ]; then
printf '* %s\n' "$id"
else
printf ' %s\n' "$id"
fi
done
;;
current)
[ "$#" -eq 0 ] || {
usage >&2
exit 2
}
current_id
;;
restart)
[ "$#" -eq 0 ] || {
usage >&2
exit 2
}
systemctl --user restart linux-wallpaperengine.service
;;
stop)
[ "$#" -eq 0 ] || {
usage >&2
exit 2
}
systemctl --user stop linux-wallpaperengine.service
;;
help | -h | --help)
usage
;;
*)
usage >&2
exit 2
;;
esac
@@ -0,0 +1,57 @@
# shellcheck shell=bash
set -o errexit -o nounset -o pipefail
playlist_file="$1"
default_id="$2"
configuration_id="$3"
assets_dir="$4"
workshop_dir="$5"
scaling="$6"
shift 6
state_file="${XDG_RUNTIME_DIR:?}/linux-wallpaperengine/current"
wallpaper_id="$default_id"
saved_configuration=""
saved_id=""
saved_state=()
outputs=()
if [ -s "$state_file" ]; then
mapfile -t saved_state <"$state_file"
saved_configuration="${saved_state[0]:-}"
saved_id="${saved_state[1]:-}"
if [ "$saved_configuration" = "$configuration_id" ] &&
awk -v id="$saved_id" '$0 == id { found = 1 } END { exit !found }' "$playlist_file"; then
wallpaper_id="$saved_id"
fi
fi
wallpaper_path="$workshop_dir/$wallpaper_id"
if [ ! -d "$wallpaper_path" ]; then
echo "linux-wallpaperengine: missing declared wallpaper: $wallpaper_path" >&2
exit 1
fi
if [ ! -d "$assets_dir" ]; then
echo "linux-wallpaperengine: missing Wallpaper Engine assets: $assets_dir" >&2
exit 1
fi
mapfile -t outputs < <(wlr-randr --json | jq -r '.[] | select(.enabled == true) | .name')
if [ "${#outputs[@]}" -eq 0 ]; then
echo "linux-wallpaperengine: no enabled Wayland outputs were detected" >&2
exit 1
fi
engine_args=("$@" --assets-dir "$assets_dir")
for output in "${outputs[@]}"; do
engine_args+=(
--screen-root "$output"
--bg "$wallpaper_path"
--scaling "$scaling"
)
done
exec linux-wallpaperengine "${engine_args[@]}"
@@ -0,0 +1,229 @@
{
config,
lib,
pkgs,
...
}:
let
settings = import ../settings.nix;
inherit (settings)
assetsDirectory
fps
mute
scaling
selectedWallpaperId
switchIntervalSeconds
wallpaperIds
workshopDirectory
;
resolveHomePath =
path:
if lib.hasPrefix "~/" path then
"${config.home.homeDirectory}/${lib.removePrefix "~/" path}"
else if lib.hasPrefix "/" path then
path
else
"${config.home.homeDirectory}/${path}";
hasWallpapers = wallpaperIds != [ ];
defaultWallpaperId =
if selectedWallpaperId != null then
selectedWallpaperId
else if hasWallpapers then
lib.head wallpaperIds
else
"";
configurationId = builtins.hashString "sha256" (builtins.toJSON settings);
resolvedAssetsDirectory = resolveHomePath assetsDirectory;
resolvedWorkshopDirectory = resolveHomePath workshopDirectory;
playlist = pkgs.writeText "linux-wallpaperengine-playlist" (
lib.concatMapStringsSep "\n" (id: id) wallpaperIds + "\n"
);
engineArguments = lib.optional mute "--silent" ++ [
"--fps"
(toString fps)
];
controller = pkgs.writeShellApplication {
name = "wallpaperengine-wallpaper";
runtimeInputs = [
pkgs.coreutils
pkgs.gawk
pkgs.systemd
];
text = ''
exec ${lib.getExe pkgs.bash} ${./controller.sh} \
${lib.escapeShellArg playlist} \
${lib.escapeShellArg defaultWallpaperId} \
${lib.escapeShellArg configurationId} \
"$@"
'';
};
mkVicinaeScript =
{
name,
title,
description,
command,
mode ? "compact",
message ? null,
}:
{
name = "vicinae/scripts/wallpaper-engine/${name}";
value = {
executable = true;
text = ''
#!${lib.getExe pkgs.bash}
# @vicinae.schemaVersion 1
# @vicinae.title ${title}
# @vicinae.description ${description}
# @vicinae.mode ${mode}
# @vicinae.icon 🖼️
${lib.getExe controller} ${lib.escapeShellArg command}
${lib.optionalString (message != null) "printf '%s\\n' ${lib.escapeShellArg message}"}
'';
};
};
launcher = pkgs.writeShellApplication {
name = "linux-wallpaperengine-launcher";
runtimeInputs = [
pkgs.coreutils
pkgs.gawk
pkgs.jq
pkgs.linux-wallpaperengine
pkgs.wlr-randr
];
text = ''
exec ${lib.getExe pkgs.bash} ${./launcher.sh} \
${lib.escapeShellArg playlist} \
${lib.escapeShellArg defaultWallpaperId} \
${lib.escapeShellArg configurationId} \
${lib.escapeShellArg resolvedAssetsDirectory} \
${lib.escapeShellArg resolvedWorkshopDirectory} \
${lib.escapeShellArg scaling} \
${lib.escapeShellArgs engineArguments}
'';
};
in
assert lib.assertMsg (lib.all (
id: builtins.isString id && builtins.match "[0-9]+" id != null
) wallpaperIds) "linux-wallpaperengine: wallpaperIds must contain only numeric strings";
assert lib.assertMsg (
lib.unique wallpaperIds == wallpaperIds
) "linux-wallpaperengine: wallpaperIds must not contain duplicates";
assert lib.assertMsg (
selectedWallpaperId == null || builtins.elem selectedWallpaperId wallpaperIds
) "linux-wallpaperengine: selectedWallpaperId must be null or a member of wallpaperIds";
assert lib.assertMsg (
switchIntervalSeconds == null || (builtins.isInt switchIntervalSeconds && switchIntervalSeconds > 0)
) "linux-wallpaperengine: switchIntervalSeconds must be null or a positive integer";
assert lib.assertMsg (
builtins.isInt fps && fps > 0
) "linux-wallpaperengine: fps must be a positive integer";
assert lib.assertMsg (
builtins.isString assetsDirectory && assetsDirectory != ""
) "linux-wallpaperengine: assetsDirectory must be a non-empty string";
assert lib.assertMsg (
builtins.isString workshopDirectory && workshopDirectory != ""
) "linux-wallpaperengine: workshopDirectory must be a non-empty string";
assert lib.assertMsg (builtins.elem scaling [
"default"
"fill"
"fit"
"stretch"
]) "linux-wallpaperengine: scaling must be default, fill, fit, or stretch";
{
home.packages = [
controller
pkgs.linux-wallpaperengine
];
xdg.dataFile = builtins.listToAttrs [
(mkVicinaeScript {
name = "reload";
title = "Reload Wallpaper Engine";
description = "Restart the active Wallpaper Engine background";
command = "restart";
message = "Wallpaper Engine reloaded";
})
(mkVicinaeScript {
name = "next";
title = "Next Wallpaper Engine Wallpaper";
description = "Switch to the next configured Wallpaper Engine background";
command = "next";
message = "Switched to the next Wallpaper Engine wallpaper";
})
(mkVicinaeScript {
name = "previous";
title = "Previous Wallpaper Engine Wallpaper";
description = "Switch to the previous configured Wallpaper Engine background";
command = "previous";
message = "Switched to the previous Wallpaper Engine wallpaper";
})
(mkVicinaeScript {
name = "list";
title = "List Wallpaper Engine Wallpapers";
description = "Show the configured Wallpaper Engine backgrounds";
command = "list";
mode = "fullOutput";
})
(mkVicinaeScript {
name = "current";
title = "Current Wallpaper Engine Wallpaper";
description = "Show the active Wallpaper Engine background";
command = "current";
})
(mkVicinaeScript {
name = "stop";
title = "Stop Wallpaper Engine";
description = "Stop Wallpaper Engine for this session";
command = "stop";
message = "Wallpaper Engine stopped";
})
];
systemd.user.services = lib.optionalAttrs hasWallpapers {
linux-wallpaperengine = {
Unit = {
Description = "Linux Wallpaper Engine";
After = [ "graphical-session.target" ];
PartOf = [ "graphical-session.target" ];
};
Service = {
ExecStart = lib.getExe launcher;
Restart = "on-abnormal";
};
Install.WantedBy = [ "graphical-session.target" ];
};
linux-wallpaperengine-switch = {
Unit.Description = "Switch Linux Wallpaper Engine wallpaper";
Service = {
Type = "oneshot";
ExecStart = "${lib.getExe controller} next";
};
};
};
systemd.user.timers =
lib.optionalAttrs
(hasWallpapers && builtins.length wallpaperIds > 1 && switchIntervalSeconds != null)
{
linux-wallpaperengine-switch = {
Unit = {
Description = "Periodically switch Linux Wallpaper Engine wallpaper";
PartOf = [ "graphical-session.target" ];
};
Timer = {
OnActiveSec = "${toString switchIntervalSeconds}s";
OnUnitActiveSec = "${toString switchIntervalSeconds}s";
Unit = "linux-wallpaperengine-switch.service";
};
Install.WantedBy = [ "graphical-session.target" ];
};
};
}
@@ -0,0 +1,3 @@
{
description = "Wallpaper Engine backgrounds for Linux";
}
@@ -0,0 +1,23 @@
{
assetsDirectory = "~/.local/share/Steam/steamapps/common/wallpaper_engine/assets";
workshopDirectory = "~/.local/share/Steam/steamapps/workshop/content/431960";
wallpaperIds = [
"2833810156"
"2834355367"
"2836628501"
"2845095254"
"2859848175"
"2861661119"
"2982896307"
];
# null selects the first ID above.
selectedWallpaperId = null;
# Set a number such as 300 to rotate through multiple IDs.
switchIntervalSeconds = 300;
fps = 30;
mute = true;
scaling = "fill";
}
+6
View File
@@ -0,0 +1,6 @@
{
homebrew = {
enable = true;
casks = [ "nani" ];
};
}
+78
View File
@@ -0,0 +1,78 @@
{ pkgs, ... }:
let
tessdataBest = pkgs.runCommand "tessdata-best-jpn-eng-chi-sim" { } ''
mkdir -p "$out"
ln -s ${
pkgs.fetchurl {
url = "https://github.com/tesseract-ocr/tessdata_best/raw/main/jpn.traineddata";
hash = "sha256-Nr35rII/WRHmJMMNBVPokLirx8MaZbPvFNqUNljEC3k=";
}
} "$out/jpn.traineddata"
ln -s ${
pkgs.fetchurl {
url = "https://github.com/tesseract-ocr/tessdata_best/raw/main/eng.traineddata";
hash = "sha256-goCu0Hgv4nJXpo6hD+fvMkyg+Nhb0v0UXRwrVgvLZro=";
}
} "$out/eng.traineddata"
ln -s ${
pkgs.fetchurl {
url = "https://github.com/tesseract-ocr/tessdata_best/raw/main/chi_sim.traineddata";
hash = "sha256-T+8tEwbI6HYW1NPkxsZ/r11EvjNCKQz48vD246p+c1s=";
}
} "$out/chi_sim.traineddata"
'';
tesseract = pkgs.tesseract5.override {
tessdata = tessdataBest;
};
naniTranslatePrimary = pkgs.writeShellApplication {
name = "nani-translate-primary";
runtimeInputs = with pkgs; [
jq
wl-clipboard
xdg-utils
];
text = ''
selected_text="$(wl-paste --primary --no-newline)" || exit 0
[ -n "$selected_text" ] || exit 0
encoded_text="$(printf '%s' "$selected_text" | jq -sRr @uri)"
exec xdg-open "naniapp://translate?source=$encoded_text"
'';
};
naniTranslateOcr = pkgs.writeShellApplication {
name = "nani-translate-ocr";
runtimeInputs = with pkgs; [
grim
jq
slurp
tesseract
xdg-utils
];
text = ''
geometry="$(slurp)" || exit 0
captured_text="$(
grim -g "$geometry" - \
| tesseract stdin stdout -l jpn+eng+chi_sim --oem 1 --psm 6
)"
[ -n "$captured_text" ] || exit 0
encoded_text="$(printf '%s' "$captured_text" | jq -sRr @uri)"
exec xdg-open "naniapp://translate?source=$encoded_text"
'';
};
in
{
programs.naniTranslateLinux.enable = true;
xdg.mimeApps.defaultApplications."x-scheme-handler/naniapp" = "nani.desktop";
home.packages = [
naniTranslatePrimary
naniTranslateOcr
];
}
+8
View File
@@ -0,0 +1,8 @@
{ inputs, ... }:
{
description = "Nani Translate application";
imports.home = [
inputs.nani-translate-linux.homeManagerModules.default
];
}
@@ -25,6 +25,7 @@ in
systemd.user.sessionVariables.NAUTILUS_4_EXTENSION_DIR = nautilusExtensionDir;
xdg.dataFile."nautilus-python/extensions/open-in-editor.py".text = ''
import os
import subprocess
from gi.repository import GObject, Nautilus
@@ -75,6 +76,12 @@ in
return paths
@staticmethod
def get_working_directory(paths):
path = paths[0]
return path if os.path.isdir(path) else os.path.dirname(path)
@staticmethod
def launch(_item, command):
subprocess.Popen(
@@ -101,6 +108,22 @@ in
items.append(item)
item = Nautilus.MenuItem(
name=f"OpenInEditor::{context}::ghostty",
label="Ghostty で開く",
)
item.connect(
"activate",
self.launch,
[
"${lib.getExe pkgs.ghostty}",
f"--working-directory={self.get_working_directory(paths)}",
],
)
items.append(item)
return items
def get_file_items(self, files):
+34
View File
@@ -5,4 +5,38 @@
NH_FLAKE = "${config.home.homeDirectory}/dotfiles";
NH_SHOW_ACTIVATION_LOGS = "1";
};
programs.zsh.initContent = ''
export SUDO_PROMPT=$'\a[sudo] authenticate for %u: '
nh() {
local notify=false
local argument
case "$1:$2" in
os:switch | os:build) notify=true ;;
esac
for argument in "$@"; do
if [[ "$argument" == "--update" ]]; then
notify=true
break
fi
done
command nh "$@"
local status=$?
if [[ "$notify" == true ]]; then
printf '\a'
if ((status == 0)); then
print -P "%F{green}nh completed%f"
else
print -P "%F{red}nh failed (exit $status)%f"
fi
fi
return "$status"
}
'';
}
@@ -1,3 +1,4 @@
# niri のデフォルトキーバインド。編集しないこと。
{
"Mod+Shift+Slash".action.show-hotkey-overlay = { };
+68
View File
@@ -41,6 +41,37 @@ in
hotkey-overlay.title = "Move Window to Monitor Down";
};
"Mod+Tab" = {
repeat = false;
action.spawn = [
"window-overview"
"--hold"
];
hotkey-overlay.title = "Window Overview: Next";
};
"Mod+Shift+Tab" = {
repeat = false;
action.spawn = [
"window-overview"
"--hold"
"--reverse"
];
hotkey-overlay.title = "Window Overview: Previous";
};
"Print".action.spawn = [
"screenshot"
"region"
];
"Ctrl+Print".action.spawn = [
"screenshot"
"output"
];
"Alt+Print".action.spawn = [
"screenshot"
"all"
];
# spawn applications (sync with labwc modules/applications/labwc/home.nix)
"Mod+T" = {
action.spawn = "ghostty";
@@ -83,6 +114,24 @@ in
];
hotkey-overlay.title = "Clipboard History";
};
"Mod+J" = {
repeat = false;
action.spawn = [ "nani-translate-primary" ];
hotkey-overlay.title = "Translate Primary Selection";
};
"Mod+Shift+J" = {
repeat = false;
action.spawn = [ "nani-translate-ocr" ];
hotkey-overlay.title = "OCR and Translate with Nani";
};
"Mod+Ctrl+J" = {
repeat = false;
action.spawn = [
(lib.getExe' pkgs.xdg-utils "xdg-open")
"naniapp://translate"
];
hotkey-overlay.title = "Open Nani Translate";
};
"Mod+Space" = {
action.spawn = [
"ghostty"
@@ -94,6 +143,25 @@ in
action.spawn = "wdisplays";
hotkey-overlay.title = "Display Settings: wdisplays";
};
"Mod+Z" = {
action.spawn = [
"wl-find-cursor"
"-c"
"0xCCFF453A"
"-s"
"160"
"-d"
"1200"
];
hotkey-overlay.title = "Find Cursor";
};
"Ctrl+Space" = {
action.spawn = [
"handy"
"--toggle-transcription"
];
hotkey-overlay.title = "Toggle Handy Transcription";
};
# Function keys (sync with labwc modules/applications/labwc/home.nix)
"XF86AudioRaiseVolume".action.spawn = [
+5 -1
View File
@@ -2,7 +2,11 @@
{
description = "niri Wayland compositor";
includes = [ "systems.wayland" ];
includes = [
"systems.wayland"
"applications.screenshot"
"applications.wl-find-cursor"
];
imports = {
nixos = [
+15 -2
View File
@@ -1,6 +1,7 @@
{
lib,
pkgs,
inputs,
...
}:
let
@@ -12,6 +13,15 @@ let
--replace-fail "@codexbarPath@" "${lib.getExe codexbar}"
'';
noctaliaPatched =
inputs.noctalia.packages.${pkgs.stdenv.hostPlatform.system}.default.overrideAttrs
(oldAttrs: {
patches = (oldAttrs.patches or [ ]) ++ [
./patches/window-switcher-labwc.patch
./patches/taskbar-overview-hook.patch
];
});
wallpapers = pkgs.fetchFromGitHub {
owner = "moons-14";
repo = "wallpapers";
@@ -41,6 +51,8 @@ in
programs.noctalia = {
enable = true;
package = noctaliaPatched;
systemd.enable = true;
settings = {
@@ -171,10 +183,11 @@ in
"org.gnome.Nautilus"
"org.gnome.TextEditor"
"com.mitchellh.ghostty"
"thunderbird"
"google-chrome"
"code"
"dev.zed.Zed"
"codex-desktop.desktop"
"codex-desktop"
"vesktop"
];
show_all_outputs = true;
@@ -193,7 +206,7 @@ in
};
wallpaper = {
enabled = true;
enabled = lib.mkDefault true;
directory = "~/.wallpapers";
fill_mode = "crop";
automation = {
+4
View File
@@ -2,6 +2,10 @@
{
description = "Noctalia Wayland desktop shell";
includes = [
"applications.window-overview"
];
imports = {
nixos = [ inputs.noctalia.nixosModules.default ];
home = [ inputs.noctalia.homeModules.default ];
@@ -0,0 +1,43 @@
diff --git a/src/shell/bar/widgets/taskbar_widget.cpp b/src/shell/bar/widgets/taskbar_widget.cpp
--- a/src/shell/bar/widgets/taskbar_widget.cpp
+++ b/src/shell/bar/widgets/taskbar_widget.cpp
@@ -5,6 +5,7 @@
#include "compositors/workspace_backend.h"
#include "config/config_service.h"
#include "core/deferred_call.h"
+#include "core/process/process.h"
#include "i18n/i18n.h"
#include "render/core/color.h"
#include "render/core/renderer.h"
@@ -36,6 +37,18 @@
namespace {
+ [[nodiscard]] bool launchTaskbarOverview(const std::string& appId) {
+ constexpr const char* command = "noctalia-taskbar-overview";
+ if (appId.empty() || !process::commandExists(command)) {
+ return false;
+ }
+ return process::runAsync({
+ command,
+ "--app-id",
+ appId.c_str(),
+ });
+ }
+
// Integer centering; optional odd spare pixel on the end side (right/bottom).
[[nodiscard]] float centeredOffset(float extent, float content, float inset = 0.0F, bool oddSpareOnEnd = true) {
const float inner = std::max(0.0F, extent - inset * 2.0F);
@@ -373,6 +386,12 @@ void TaskbarWidget::activateOrLaunchPinned(const TaskModel& task) {
return;
}
+ const std::string overviewAppId =
+ !task.appId.empty() ? task.appId : (!task.desktopEntryId.empty() ? task.desktopEntryId : task.idLower);
+ if (launchTaskbarOverview(overviewAppId)) {
+ return;
+ }
+
const std::string cycleKey = !task.desktopEntryId.empty() ? task.desktopEntryId : task.idLower;
std::size_t& cursor = m_groupedAppCycleCursor[cycleKey];
if (cursor >= windows.size()) {
@@ -0,0 +1,26 @@
diff --git a/src/shell/switcher/window_switcher.cpp b/src/shell/switcher/window_switcher.cpp
--- a/src/shell/switcher/window_switcher.cpp
+++ b/src/shell/switcher/window_switcher.cpp
@@ -286,12 +286,19 @@ indexLiveToplevelsByWindowId(const CompositorPlatform& platform, std::unordered_
continue;
}
- const auto mappedId = platform.compositorWindowIdForToplevelInfo(info);
- if (!mappedId.has_value() || mappedId->empty()) {
- continue;
+ std::string key;
+ if (const auto mappedId = platform.compositorWindowIdForToplevelInfo(info);
+ mappedId.has_value() && !mappedId->empty()) {
+ key = canonicalWindowId(*mappedId);
}
- const std::string key = canonicalWindowId(*mappedId);
+
+ // Generic wlroots compositors such as labwc do not provide a
+ // compositor-specific window ID. The wlr toplevel handle is stable
+ // for the lifetime of the window and is sufficient for switcher identity.
+ if (key.empty() && wlrHandle != 0) {
+ key = "toplevel:" + std::to_string(wlrHandle);
+ }
if (key.empty()) {
continue;
}
+53
View File
@@ -0,0 +1,53 @@
{ pkgs, ... }:
{
home.packages = [
(pkgs.writeShellApplication {
name = "screenshot";
runtimeInputs = with pkgs; [
coreutils
grim
slurp
wl-clipboard
xdg-user-dirs
];
text = ''
mode="''${1:-region}"
pictures_dir="$(xdg-user-dir PICTURES)"
if [[ -z "$pictures_dir" ]]; then
pictures_dir="$HOME/Pictures"
fi
output_dir="$pictures_dir/Screenshots"
output_file="$output_dir/Screenshot from $(date '+%Y-%m-%d %H-%M-%S').png"
mkdir -p "$output_dir"
case "$mode" in
region)
geometry="$(slurp)" || exit 0
grim -g "$geometry" "$output_file"
;;
output)
geometry="$(slurp -o)" || exit 0
grim -g "$geometry" "$output_file"
;;
all)
grim "$output_file"
;;
*)
echo "Unknown screenshot mode: $mode" >&2
exit 2
;;
esac
wl-copy --type image/png < "$output_file"
'';
})
];
}
+3
View File
@@ -0,0 +1,3 @@
{
description = "Wayland screenshot command";
}
@@ -0,0 +1,24 @@
{ pkgs, ... }:
{
programs.thunderbird = {
enable = true;
package = pkgs.thunderbird;
languagePacks = [ "ja" ];
policies = {
DisableTelemetry = true;
DisableAppUpdate = true;
InAppNotification_Disabled = true;
};
settings = {
"mailnews.start_page.enabled" = false;
"mail.shell.checkDefaultClient" = false;
};
profiles.default = {
isDefault = true;
};
};
}
@@ -0,0 +1,10 @@
_: {
xdg.mimeApps = {
enable = true;
defaultApplications = {
"x-scheme-handler/mailto" = "thunderbird.desktop";
"message/rfc822" = "thunderbird.desktop";
};
};
}
+1 -1
View File
@@ -118,7 +118,7 @@ let
];
userSettings = {
"nix.enableLanguageServer" = true;
"nix.serverPath" = "nil";
"nix.serverPath" = "nixd";
"nix.serverSettings" = {
nixd = {
formatting.command = [ "nixfmt" ];
@@ -0,0 +1,7 @@
{ pkgs, ... }:
let
windowOverview = pkgs.callPackage ../package.nix { };
in
{
home.packages = [ windowOverview ];
}
@@ -0,0 +1,7 @@
{
description = "Fullscreen Wayland window overview with captured previews";
includes = [
"systems.wayland"
];
}
@@ -0,0 +1,77 @@
{
lib,
fetchCrate,
libgbm,
libglvnd,
libxkbcommon,
jq,
makeWrapper,
pkg-config,
rustPlatform,
wayland,
wl-clipboard,
}:
rustPlatform.buildRustPackage rec {
pname = "window-overview";
version = "1.5.0";
src = fetchCrate {
pname = "wlr-chooser";
inherit version;
hash = "sha256-Jb59m1z+2G5istcbC0sg9jRVcC/bQh/OY0ny9OdTsdw=";
};
patches = [
./patches/window-overview.patch
./patches/niri-backend.patch
];
cargoHash = "sha256-KBLgtS8ULrmsOf6BN5SlkVQyXB12utvr/KonnKnCTCM=";
nativeBuildInputs = [
makeWrapper
pkg-config
];
buildInputs = [
libgbm
libglvnd
libxkbcommon
wayland
];
cargoBuildFlags = [
"--bin"
"wlr-switcher"
];
postInstall = ''
mv "$out/bin/wlr-switcher" "$out/bin/.window-overview-wrapped"
makeWrapper "$out/bin/.window-overview-wrapped" "$out/bin/window-overview" \
--add-flags "--layout grid" \
--prefix PATH : "${
lib.makeBinPath [
jq
wl-clipboard
]
}" \
--prefix LD_LIBRARY_PATH : "${
lib.makeLibraryPath [
libgbm
libglvnd
]
}:/run/opengl-driver/lib"
ln -s window-overview "$out/bin/noctalia-taskbar-overview"
'';
meta = {
description = "Fullscreen Wayland window overview with captured previews";
homepage = "https://github.com/sjourdois/wlr-utils";
license = with lib.licenses; [
asl20
mit
];
mainProgram = "window-overview";
platforms = lib.platforms.linux;
};
}
@@ -0,0 +1,431 @@
--- a/src/chooser_cli.rs
+++ b/src/chooser_cli.rs
@@ -81,6 +81,7 @@
initial_cycle: None,
snapshot: false,
cycle_socket: None,
+ niri_backend: false,
};
match run_overlay(opts, t0) {
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -65,7 +65,14 @@
// must connect, enumerate, and open sessions before any thumbnail appears.
let (tx, rx) = mpsc::channel();
let snapshot = opts.snapshot;
- std::thread::spawn(move || ui::capture_thread(tx, snapshot));
+ let niri_backend = opts.niri_backend;
+ std::thread::spawn(move || {
+ if niri_backend {
+ ui::niri_capture_thread(tx);
+ } else {
+ ui::capture_thread(tx, snapshot);
+ }
+ });
shell::tlog(t0, "capture-thread spawned");
let out: ui::Outcome = Arc::new(Mutex::new(None));
--- a/src/switcher_cli.rs
+++ b/src/switcher_cli.rs
@@ -88,6 +88,7 @@
let t0 = Instant::now();
let cli = Cli::parse();
i18n::init();
+ let niri_backend = std::env::var_os("NIRI_SOCKET").is_some();
if cli.doctor {
if let Err(e) = wlr_capture::doctor::report("wlr-switcher", env!("CARGO_PKG_VERSION")) {
@@ -129,32 +130,50 @@
initial_cycle: Some(!cli.reverse),
snapshot: true,
cycle_socket: Some(cycle_socket),
+ niri_backend,
};
// Pre-flight: wlr-switcher switches *windows*, which need the foreign-toplevel
// capture source (wlroots >= 0.20 / Sway >= 1.12). On older compositors connect()
// now succeeds for screen-only capture, but there are no windows to offer — so say
// so clearly and exit, instead of showing an empty dimmed overlay (issue #1).
- match wl::Client::connect() {
- Ok(client) if !client.can_capture_windows() => {
- eprintln!("{}", tr!("capture-no-window"));
- std::process::exit(2);
- }
- Ok(_) => {}
- Err(e) => {
- eprintln!("{}", tr!("error", error = format!("{e:#}")));
- std::process::exit(2);
+ if !niri_backend {
+ match wl::Client::connect() {
+ Ok(client) if !client.can_capture_windows() => {
+ eprintln!("{}", tr!("capture-no-window"));
+ std::process::exit(2);
+ }
+ Ok(_) => {}
+ Err(e) => {
+ eprintln!("{}", tr!("error", error = format!("{e:#}")));
+ std::process::exit(2);
+ }
}
}
match run_overlay(opts, t0) {
Ok(Some(sel)) => {
// Focus the picked window (outputs aren't focusable, so ignore them).
- if sel.is_window
- && let Err(e) = wl::activate_window(&sel.app_id, &sel.title, sel.dup_index)
- {
- eprintln!("{}", tr!("error", error = format!("{e:#}")));
- std::process::exit(2);
+ if sel.is_window {
+ let result: anyhow::Result<()> = if niri_backend {
+ let output = std::process::Command::new("niri")
+ .args(["msg", "action", "focus-window", "--id", &sel.identifier])
+ .output();
+ match output {
+ Ok(output) if output.status.success() => Ok(()),
+ Ok(output) => Err(anyhow::anyhow!(
+ "{}",
+ String::from_utf8_lossy(&output.stderr).trim()
+ )),
+ Err(e) => Err(e.into()),
+ }
+ } else {
+ wl::activate_window(&sel.app_id, &sel.title, sel.dup_index).map_err(Into::into)
+ };
+ if let Err(e) = result {
+ eprintln!("{}", tr!("error", error = format!("{e:#}")));
+ std::process::exit(2);
+ }
}
}
Ok(None) => std::process::exit(1), // cancelled
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -6,7 +6,9 @@
use crate::tr;
use std::collections::{HashMap, HashSet};
+use std::io::Write;
use std::path::PathBuf;
+use std::process::{Command, Stdio};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
@@ -366,6 +368,288 @@
}
}
+#[derive(Clone)]
+struct NiriWindow {
+ id: String,
+ app_id: String,
+ title: String,
+}
+
+enum ClipboardSnapshot {
+ Empty,
+ Content { mime: String, data: Vec<u8> },
+ Unavailable,
+}
+
+fn save_clipboard() -> ClipboardSnapshot {
+ let Ok(types) = Command::new("wl-paste").arg("--list-types").output() else {
+ return ClipboardSnapshot::Unavailable;
+ };
+ if !types.status.success() {
+ return ClipboardSnapshot::Empty;
+ }
+ let types = String::from_utf8_lossy(&types.stdout);
+ let offered: Vec<&str> = types.lines().filter(|line| !line.is_empty()).collect();
+ let Some(mime) = offered
+ .iter()
+ .copied()
+ .find(|mime| *mime == "text/plain;charset=utf-8")
+ .or_else(|| offered.first().copied())
+ else {
+ return ClipboardSnapshot::Empty;
+ };
+ let Ok(data) = Command::new("wl-paste")
+ .args(["--type", mime])
+ .output()
+ else {
+ return ClipboardSnapshot::Unavailable;
+ };
+ if !data.status.success() {
+ return ClipboardSnapshot::Unavailable;
+ }
+ ClipboardSnapshot::Content {
+ mime: mime.to_owned(),
+ data: data.stdout,
+ }
+}
+
+fn restore_clipboard(snapshot: ClipboardSnapshot) {
+ match snapshot {
+ ClipboardSnapshot::Empty => {
+ let _ = Command::new("wl-copy").arg("--clear").status();
+ }
+ ClipboardSnapshot::Content { mime, data } => {
+ let Ok(mut copy) = Command::new("wl-copy")
+ .args(["--type", &mime])
+ .stdin(Stdio::piped())
+ .spawn()
+ else {
+ return;
+ };
+ if let Some(mut stdin) = copy.stdin.take() {
+ let _ = stdin.write_all(&data);
+ }
+ let _ = copy.wait();
+ }
+ ClipboardSnapshot::Unavailable => {}
+ }
+}
+
+/// Decode one field emitted by jq's `@tsv` formatter. It escapes the only
+/// characters that would otherwise make the line-oriented transport ambiguous.
+fn unescape_tsv(value: &str) -> String {
+ let mut decoded = String::with_capacity(value.len());
+ let mut chars = value.chars();
+ while let Some(ch) = chars.next() {
+ if ch != '\\' {
+ decoded.push(ch);
+ continue;
+ }
+ match chars.next() {
+ Some('t') => decoded.push('\t'),
+ Some('r') => decoded.push('\r'),
+ Some('n') => decoded.push('\n'),
+ Some('\\') => decoded.push('\\'),
+ Some(other) => {
+ decoded.push('\\');
+ decoded.push(other);
+ }
+ None => decoded.push('\\'),
+ }
+ }
+ decoded
+}
+
+/// Ask niri for its toplevel list. niri does not implement the
+/// ext-image-copy-capture window protocol, so its IPC is the source of both the
+/// stable window id and the metadata used by this backend.
+fn niri_windows() -> Result<Vec<NiriWindow>, String> {
+ let response = Command::new("niri")
+ .args(["msg", "-j", "windows"])
+ .output()
+ .map_err(|e| format!("could not run niri msg: {e}"))?;
+ if !response.status.success() {
+ return Err(String::from_utf8_lossy(&response.stderr).trim().to_owned());
+ }
+
+ let mut jq = Command::new("jq")
+ .args([
+ "-r",
+ ".[] | [(.id | tostring), (.app_id // \"\"), (.title // \"\")] | @tsv",
+ ])
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .map_err(|e| format!("could not run jq: {e}"))?;
+ jq.stdin
+ .take()
+ .ok_or_else(|| String::from("jq stdin was unavailable"))?
+ .write_all(&response.stdout)
+ .map_err(|e| format!("could not pass niri window list to jq: {e}"))?;
+ let output = jq
+ .wait_with_output()
+ .map_err(|e| format!("could not read jq output: {e}"))?;
+ if !output.status.success() {
+ return Err(String::from_utf8_lossy(&output.stderr).trim().to_owned());
+ }
+
+ let text = String::from_utf8(output.stdout)
+ .map_err(|e| format!("niri window list was not UTF-8: {e}"))?;
+ let mut windows = Vec::new();
+ for line in text.lines() {
+ let mut fields = line.splitn(3, '\t');
+ let Some(id) = fields.next() else { continue };
+ let Some(app_id) = fields.next() else { continue };
+ let Some(title) = fields.next() else { continue };
+ windows.push(NiriWindow {
+ id: id.to_owned(),
+ app_id: unescape_tsv(app_id),
+ title: unescape_tsv(title),
+ });
+ }
+ windows.sort_by(|a, b| {
+ a.app_id
+ .to_lowercase()
+ .cmp(&b.app_id.to_lowercase())
+ .then_with(|| a.title.to_lowercase().cmp(&b.title.to_lowercase()))
+ });
+ Ok(windows)
+}
+
+fn niri_window_source(w: &NiriWindow, dup_index: usize) -> Source {
+ let is_system = w.app_id.is_empty();
+ let (title, subtitle) = if is_system {
+ (w.title.clone(), String::new())
+ } else {
+ (w.app_id.clone(), w.title.clone())
+ };
+ Source {
+ key: w.id.clone(),
+ token: format!("Window: {}", w.id),
+ filter: format!("{} {}", w.app_id, w.title).to_lowercase(),
+ title,
+ subtitle,
+ is_window: true,
+ is_system,
+ app_id: w.app_id.clone(),
+ win_title: w.title.clone(),
+ dup_index,
+ }
+}
+
+/// Capture a static overview through niri's IPC. This is deliberately separate
+/// from the generic Wayland backend: niri can render any toplevel by id (even an
+/// occluded one), but does not advertise ext-image-copy-capture.
+pub fn niri_capture_thread(tx: Sender<Msg>) {
+ let windows = match niri_windows() {
+ Ok(windows) => windows,
+ Err(e) => {
+ eprintln!("niri overview backend: {e}");
+ return;
+ }
+ };
+
+ let mut dup: HashMap<(String, String), usize> = HashMap::new();
+ let mut current = Vec::with_capacity(windows.len());
+ for window in windows {
+ let e = dup
+ .entry((window.app_id.clone(), window.title.clone()))
+ .or_insert(0);
+ let source = niri_window_source(&window, *e);
+ *e += 1;
+ current.push((source, window));
+ }
+ if tx
+ .send(Msg::Sources(
+ current.iter().map(|(source, _)| source.clone()).collect(),
+ ))
+ .is_err()
+ {
+ return;
+ }
+
+ let capture_dir = wlr_capture::paths::runtime_dir().join(format!(
+ "window-overview-niri-{}",
+ std::process::id()
+ ));
+ if let Err(e) = std::fs::create_dir_all(&capture_dir) {
+ eprintln!("niri overview backend: could not create capture directory: {e}");
+ return;
+ }
+ // niri's screenshot action also sets the clipboard. Preserve the existing
+ // selection so opening the overview does not unexpectedly replace it.
+ let clipboard = save_clipboard();
+
+ for (source, window) in current {
+ if let Some(path) = icons::resolve(&window.app_id)
+ && let Some((w, h, rgba)) = icons::load(&path, 128)
+ && tx
+ .send(Msg::Icon {
+ key: source.key.clone(),
+ w: w as usize,
+ h: h as usize,
+ rgba,
+ })
+ .is_err()
+ {
+ break;
+ }
+
+ let path = capture_dir.join(format!("{}.png", window.id));
+ let command = Command::new("niri")
+ .args(["msg", "action", "screenshot-window", "--id"])
+ .arg(&window.id)
+ .args(["--write-to-disk", "true", "--show-pointer", "false", "--path"])
+ .arg(&path)
+ .output();
+ let Ok(output) = command else {
+ continue;
+ };
+ if !output.status.success() {
+ eprintln!(
+ "niri overview backend: could not capture window {}: {}",
+ window.id,
+ String::from_utf8_lossy(&output.stderr).trim()
+ );
+ continue;
+ }
+
+ // niri encodes PNGs off the compositor thread, after acknowledging the
+ // IPC action. Wait briefly for that completion without delaying the UI.
+ let mut bytes = None;
+ for _ in 0..200 {
+ match std::fs::read(&path) {
+ Ok(data) => {
+ bytes = Some(data);
+ break;
+ }
+ Err(_) => std::thread::sleep(Duration::from_millis(10)),
+ }
+ }
+ let Some(bytes) = bytes else { continue };
+ let Ok(image) = image::load_from_memory_with_format(&bytes, image::ImageFormat::Png) else {
+ continue;
+ };
+ let image = image.into_rgba8();
+ let (w, h, rgba) = thumbnail_rgba(image.width(), image.height(), image.into_raw());
+ if tx
+ .send(Msg::Thumb {
+ key: source.key,
+ w,
+ h,
+ rgba,
+ })
+ .is_err()
+ {
+ break;
+ }
+ let _ = std::fs::remove_file(path);
+ }
+ let _ = std::fs::remove_dir(capture_dir);
+ restore_clipboard(clipboard);
+}
+
/// Cheap content fingerprint of a frame (subsampled FNV-1a), to tell whether a
/// capture actually changed between rounds — used by the headless bench.
fn quick_hash(rgba: &[u8]) -> u64 {
@@ -515,11 +799,14 @@
/// Downscale a capture to a thumbnail (max side `THUMB_MAX`), never upscaling.
fn thumbnail(img: wl::CapturedImage) -> (usize, usize, Vec<u8>) {
- let (w, h) = (img.width, img.height);
+ thumbnail_rgba(img.width, img.height, img.rgba)
+}
+
+fn thumbnail_rgba(w: u32, h: u32, rgba: Vec<u8>) -> (usize, usize, Vec<u8>) {
let scale = (THUMB_MAX as f32 / w as f32)
.min(THUMB_MAX as f32 / h as f32)
.min(1.0);
- let src = match image::RgbaImage::from_raw(w, h, img.rgba) {
+ let src = match image::RgbaImage::from_raw(w, h, rgba) {
Some(s) => s,
None => return (0, 0, Vec::new()),
};
@@ -557,6 +844,9 @@
pub snapshot: bool,
/// Receives next/previous commands from repeated compositor keybind launches.
pub cycle_socket: Option<std::os::unix::net::UnixDatagram>,
+ /// Use niri's IPC for window enumeration and snapshots instead of the
+ /// ext-image-copy-capture protocol that niri does not implement.
+ pub niri_backend: bool,
}
pub struct App {
@@ -0,0 +1,336 @@
--- a/src/chooser_cli.rs
+++ b/src/chooser_cli.rs
@@ -79,0 +80,4 @@
+ app_id_filter: None,
+ initial_cycle: None,
+ snapshot: false,
+ cycle_socket: None,
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -33,3 +33,3 @@
-/// Returns the held lock file (keep it alive), or `None` if another instance owns
-/// it — sway processes its own keybinding even over our exclusive keyboard grab,
-/// so re-pressing the bind would otherwise stack overlays.
+/// Returns the held lock and command socket for the first instance. Later
+/// invocations forward their requested cycle direction through the socket and
+/// return `None`, which also supports compositors that keep handling the binding.
@@ -36 +36,3 @@
-pub fn acquire_switch_lock() -> Option<std::fs::File> {
+pub fn acquire_switch_lock(
+ forward: bool,
+) -> Option<(std::fs::File, std::os::unix::net::UnixDatagram)> {
@@ -38,0 +41 @@
+ let socket_path = dir.join("wlr-switcher.sock");
@@ -45,2 +49,10 @@
- flock(&f, FlockOperation::NonBlockingLockExclusive).ok()?;
- Some(f)
+ if flock(&f, FlockOperation::NonBlockingLockExclusive).is_err() {
+ let socket = std::os::unix::net::UnixDatagram::unbound().ok()?;
+ let command = if forward { b"next" } else { b"prev" };
+ let _ = socket.send_to(command, socket_path);
+ return None;
+ }
+ let _ = std::fs::remove_file(&socket_path);
+ let socket = std::os::unix::net::UnixDatagram::bind(socket_path).ok()?;
+ socket.set_nonblocking(true).ok()?;
+ Some((f, socket))
@@ -56 +68,2 @@
- std::thread::spawn(move || ui::capture_thread(tx));
+ let snapshot = opts.snapshot;
+ std::thread::spawn(move || ui::capture_thread(tx, snapshot));
--- a/src/shell.rs
+++ b/src/shell.rs
@@ -617,0 +618,3 @@
+ Keysym::plus | Keysym::KP_Add => Key::Plus,
+ Keysym::equal => Key::Equals,
+ Keysym::minus | Keysym::KP_Subtract => Key::Minus,
--- a/src/switcher_cli.rs
+++ b/src/switcher_cli.rs
@@ -75,0 +76,6 @@
+ /// Show only windows whose Wayland app-id exactly matches this value.
+ #[arg(long)]
+ app_id: Option<String>,
+ /// Select the previous window when the overview first opens.
+ #[arg(long)]
+ reverse: bool,
@@ -96,2 +102,2 @@
- let _lock = match acquire_switch_lock() {
- Some(lock) => lock,
+ let (_lock, cycle_socket) = match acquire_switch_lock(!cli.reverse) {
+ Some(instance) => instance,
@@ -121,0 +128,4 @@
+ app_id_filter: cli.app_id,
+ initial_cycle: Some(!cli.reverse),
+ snapshot: true,
+ cycle_socket: Some(cycle_socket),
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -8,0 +9 @@
+use std::path::PathBuf;
@@ -41,0 +43,32 @@
+const DEFAULT_OVERVIEW_SCALE: f32 = 0.85;
+const MIN_OVERVIEW_SCALE: f32 = 0.55;
+const MAX_OVERVIEW_SCALE: f32 = 1.0;
+
+fn overview_scale_path() -> Option<PathBuf> {
+ if let Some(path) = std::env::var_os("XDG_STATE_HOME") {
+ return Some(PathBuf::from(path).join("window-overview/scale"));
+ }
+ std::env::var_os("HOME")
+ .map(PathBuf::from)
+ .map(|path| path.join(".local/state/window-overview/scale"))
+}
+
+fn load_overview_scale() -> f32 {
+ overview_scale_path()
+ .and_then(|path| std::fs::read_to_string(path).ok())
+ .and_then(|value| value.trim().parse::<f32>().ok())
+ .unwrap_or(DEFAULT_OVERVIEW_SCALE)
+ .clamp(MIN_OVERVIEW_SCALE, MAX_OVERVIEW_SCALE)
+}
+
+fn save_overview_scale(scale: f32) {
+ let Some(path) = overview_scale_path() else {
+ return;
+ };
+ let Some(parent) = path.parent() else {
+ return;
+ };
+ if std::fs::create_dir_all(parent).is_ok() {
+ let _ = std::fs::write(path, format!("{scale:.2}\n"));
+ }
+}
@@ -177 +210 @@
-pub fn capture_thread(tx: Sender<Msg>) {
+pub fn capture_thread(tx: Sender<Msg>, snapshot: bool) {
@@ -189,0 +223 @@
+ let mut captured: HashSet<String> = HashSet::new();
@@ -236,0 +271 @@
+ captured.retain(|key| present.contains(key.as_str()));
@@ -255 +290 @@
- if sessions.contains_key(&s.key) {
+ if sessions.contains_key(&s.key) || (snapshot && captured.contains(&s.key)) {
@@ -295 +330,3 @@
- let Some(key) = by_id.get(&id) else { continue };
+ let Some(key) = by_id.get(&id).cloned() else {
+ continue;
+ };
@@ -313,0 +351,6 @@
+ if snapshot {
+ captured.insert(key.clone());
+ sessions.remove(&key);
+ by_id.remove(&id);
+ client.close_session(&id);
+ }
@@ -508,0 +552,8 @@
+ /// Exact Wayland app-id filter, used by taskbar launches.
+ pub app_id_filter: Option<String>,
+ /// Initial cycle direction. `None` leaves the first item selected.
+ pub initial_cycle: Option<bool>,
+ /// Capture each window once instead of continuously refreshing previews.
+ pub snapshot: bool,
+ /// Receives next/previous commands from repeated compositor keybind launches.
+ pub cycle_socket: Option<std::os::unix::net::UnixDatagram>,
@@ -533,0 +585,5 @@
+ app_id_filter: Option<String>,
+ initial_forward: bool,
+ overview_scale: f32,
+ cycle_socket: Option<std::os::unix::net::UnixDatagram>,
+ queued_cycles: isize,
@@ -567 +623 @@
- pending_initial_select: false,
+ pending_initial_select: opts.initial_cycle.is_some(),
@@ -571,0 +628,5 @@
+ app_id_filter: opts.app_id_filter,
+ initial_forward: opts.initial_cycle.unwrap_or(true),
+ overview_scale: load_overview_scale(),
+ cycle_socket: opts.cycle_socket,
+ queued_cycles: 0,
@@ -604 +665,4 @@
- return; // nothing to cycle yet; keep the pending initial jump
+ self.queued_cycles = self
+ .queued_cycles
+ .saturating_add(if forward { 1 } else { -1 });
+ return;
@@ -639,0 +704,12 @@
+ loop {
+ let command = self.cycle_socket.as_ref().and_then(|socket| {
+ let mut buf = [0_u8; 8];
+ socket.recv(&mut buf).ok().map(|len| (buf, len))
+ });
+ match command {
+ Some((buf, len)) if &buf[..len] == b"next" => self.cycle(true),
+ Some((buf, len)) if &buf[..len] == b"prev" => self.cycle(false),
+ Some(_) => {}
+ None => break,
+ }
+ }
@@ -698,0 +775,5 @@
+ .filter(|s| {
+ self.app_id_filter
+ .as_ref()
+ .is_none_or(|app_id| s.app_id.eq_ignore_ascii_case(app_id))
+ })
@@ -713,2 +793,0 @@
- // Exposé covers the whole screen: dim almost to opaque so the real windows
- // behind are hidden (a client can't move them; this hides them instead).
@@ -716 +795 @@
- c[3] = c[3].max(0.96);
+ c[3] = 0.5;
@@ -734 +813,5 @@
- self.selected = if n > 1 { 1 } else { 0 };
+ self.selected = if n > 1 {
+ if self.initial_forward { 1 } else { n - 1 }
+ } else {
+ 0
+ };
@@ -737,0 +821,8 @@
+ if self.queued_cycles != 0 {
+ let n = self.visible().len();
+ if n > 0 {
+ self.selected = (self.selected as isize + self.queued_cycles)
+ .rem_euclid(n as isize) as usize;
+ self.queued_cycles = 0;
+ }
+ }
@@ -745 +836 @@
- let (esc, next, prev, enter) = ctx.input(|i| {
+ let (esc, next, prev, enter, zoom_in, zoom_out, wheel_zoom) = ctx.input(|i| {
@@ -746,0 +838,8 @@
+ let wheel_zoom = i
+ .events
+ .iter()
+ .filter_map(|event| match event {
+ egui::Event::MouseWheel { delta, .. } => Some(delta.y),
+ _ => None,
+ })
+ .sum::<f32>();
@@ -755,0 +855,3 @@
+ i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals),
+ i.key_pressed(egui::Key::Minus),
+ wheel_zoom,
@@ -771,0 +874,12 @@
+ if self.view == View::Grid && (zoom_in || zoom_out || wheel_zoom != 0.0) {
+ let delta = if zoom_in {
+ 0.05
+ } else if zoom_out {
+ -0.05
+ } else {
+ wheel_zoom.signum() * 0.05
+ };
+ self.overview_scale =
+ (self.overview_scale + delta).clamp(MIN_OVERVIEW_SCALE, MAX_OVERVIEW_SCALE);
+ save_overview_scale(self.overview_scale);
+ }
@@ -1020 +1134,5 @@
- let area = ctx.content_rect().shrink(24.0);
+ let full_area = ctx.content_rect().shrink(24.0);
+ let area = egui::Rect::from_center_size(
+ full_area.center(),
+ full_area.size() * self.overview_scale,
+ );
@@ -1048,0 +1167 @@
+ let selected = *i == self.selected;
@@ -1062,9 +1181,3 @@
- rect.size() * (0.86 + 0.14 * ease),
- );
- self.paint_expose_tile(
- ui,
- s,
- scaled,
- *i == self.selected,
- resp.hovered(),
- ease,
+ rect.size()
+ * (0.86 + 0.14 * ease)
+ * if selected { 1.04 } else { 1.0 },
@@ -1071,0 +1185 @@
+ self.paint_expose_tile(ui, s, scaled, selected, resp.hovered(), ease);
@@ -1077,0 +1192,8 @@
+ ui.painter().text(
+ egui::pos2(full_area.right(), full_area.top()),
+ egui::Align2::RIGHT_TOP,
+ format!("− {:.0}% +", self.overview_scale * 100.0),
+ egui::FontId::proportional(14.0),
+ self.theme.text_dim,
+ );
+
@@ -1107 +1229,14 @@
- let white = egui::Color32::WHITE.gamma_multiply(a);
+ let accent = if s.is_window {
+ t.window_accent
+ } else {
+ t.screen_accent
+ };
+ let content_alpha = if selected || hovered { 1.0 } else { 0.55 };
+ let white = egui::Color32::WHITE.gamma_multiply(a * content_alpha);
+ if selected {
+ p.rect_filled(
+ rect.expand(8.0),
+ radius + 8.0,
+ fade(accent).gamma_multiply(0.35),
+ );
+ }
@@ -1127,0 +1263,7 @@
+ if !selected && !hovered {
+ p.rect_filled(
+ rect,
+ radius,
+ egui::Color32::from_black_alpha(72).gamma_multiply(a),
+ );
+ }
@@ -1136 +1278,5 @@
- egui::Color32::from_black_alpha(160).gamma_multiply(a),
+ if selected {
+ fade(accent).gamma_multiply(0.9)
+ } else {
+ egui::Color32::from_black_alpha(160).gamma_multiply(a)
+ },
@@ -1175,9 +1321,22 @@
- let accent = if s.is_window {
- t.window_accent
- } else {
- t.screen_accent
- };
- let (sw, col) = if selected {
- (3.0, accent)
- } else if hovered {
- (2.0, accent)
+ if selected {
+ p.rect_stroke(
+ rect.expand(4.0),
+ radius + 4.0,
+ egui::Stroke::new(3.0, fade(egui::Color32::WHITE)),
+ egui::StrokeKind::Inside,
+ );
+ p.rect_stroke(
+ rect,
+ radius,
+ egui::Stroke::new(6.0, fade(accent)),
+ egui::StrokeKind::Inside,
+ );
+ let marker = egui::pos2(rect.right() - 18.0, rect.top() + 18.0);
+ p.circle_filled(marker, 13.0, fade(accent));
+ p.text(
+ marker,
+ egui::Align2::CENTER_CENTER,
+ "✓",
+ egui::FontId::proportional(18.0),
+ fade(egui::Color32::WHITE),
+ );
@@ -1185,8 +1344,7 @@
- (1.0, t.thumb)
- };
- p.rect_stroke(
- rect,
- radius,
- egui::Stroke::new(sw, fade(col)),
- egui::StrokeKind::Inside,
- );
+ p.rect_stroke(
+ rect,
+ radius,
+ egui::Stroke::new(if hovered { 2.0 } else { 1.0 }, fade(accent)),
+ egui::StrokeKind::Inside,
+ );
+ }
@@ -0,0 +1,7 @@
{ inputs, pkgs, ... }:
let
unstable = inputs.nixpkgs-unstable.legacyPackages.${pkgs.stdenv.hostPlatform.system};
in
{
home.packages = [ unstable.wl-find-cursor ];
}
@@ -0,0 +1,3 @@
{
description = "Wayland cursor locator";
}
+16
View File
@@ -30,6 +30,7 @@ required on every supported host.
| `interface.linux-desktop` | NixOS with Home Manager |
| `interface.labwc` | NixOS with Home Manager |
| `interface.niri` | NixOS with Home Manager |
| `interface.wallpaperengine` | NixOS with Home Manager |
| `platform.nixos` | NixOS |
| `platform.desktop` | Physical NixOS desktop |
| `platform.intel-nvidia-desktop` | Intel/NVIDIA physical NixOS desktop |
@@ -58,11 +59,26 @@ selects labwc. A daily-use macOS development machine can combine
`interface.macos`, `workload.development`, and `workload.personal`. Hardware
support does not implicitly select an interface or workload.
`interface.wallpaperengine` is an opt-in Linux desktop appearance profile. It
enables Wallpaper Engine and disables Noctalia's wallpaper surface, so hosts
without this profile retain Noctalia's configured wallpaper.
`workload.personal` provides Pear Desktop on both NixOS and macOS. Home Manager
enables performance improvements, synced lyrics, tracker blocking, the album
color theme, and custom output-device selection while preserving user-owned
settings such as the selected device.
`workload.personal` also provides Handy for offline speech-to-text. It starts
hidden when the graphical session begins. On niri and labwc, `Ctrl+Space` is
owned by the compositor and invokes Handy's `--toggle-transcription` command;
Handy's own shortcut bindings are disabled on NixOS so it does not monitor
keyboard events under Wayland. Handy uses the Homebrew cask and starts at login
on macOS.
`workload.personal` provides ActivityWatch for local activity tracking. On
NixOS, its server and Wayland-compatible watcher run as Home Manager user
services. On macOS, the Homebrew cask starts at login.
On NixOS, `workload.game` provides Steam with Valve Proton and Proton-GE,
Protontricks, Wayland-compatible Steam Input, GameMode, Gamescope, and MangoHud.
Enabling Steam also activates the 32-bit graphics and PipeWire support required
@@ -2,8 +2,6 @@
{
environment.systemPackages = with pkgs; [
alacritty
grim
slurp
wf-recorder
];
}
@@ -1,7 +1,9 @@
{ pkgs, ... }: {
{ pkgs, ... }:
{
home.packages = [
pkgs.playerctl
];
xdg.userDirs = {
enable = true;
createDirectories = true;
@@ -12,6 +12,7 @@
"applications.gtk"
"applications.loupe"
"applications.nautilus"
"applications.nani"
"applications.papers"
"applications.qalculate-gtk"
"applications.resources"
@@ -2,8 +2,6 @@
{
environment.systemPackages = with pkgs; [
alacritty
grim
slurp
wf-recorder
];
}
@@ -0,0 +1,3 @@
{
programs.noctalia.settings.wallpaper.enabled = false;
}
@@ -0,0 +1,7 @@
{
description = "Wallpaper Engine backgrounds for Linux desktop sessions";
includes = [
"applications.linux-wallpaperengine"
];
}
@@ -3,7 +3,10 @@
home.packages = with pkgs; [
bind
bun
nil
mcp-nixos
nix-fast-build
nix-tree
nixd
python312
uv
];
@@ -3,13 +3,17 @@
includes = [
"applications.1password"
"applications.activitywatch"
"applications.chrome"
"applications.discord"
"applications.gnome-text-editor"
"applications.handy"
"applications.kde"
"applications.moonlight"
"applications.slack"
"applications.zoom"
"applications.obs"
"applications.nani"
"applications.thunderbird"
];
}
+24
View File
@@ -21,5 +21,29 @@ in
pulse.enable = true;
jack.enable = true;
wireplumber.enable = true;
extraConfig.pipewire."90-echo-cancel.conf" = {
"context.modules" = [
{
name = "libpipewire-module-echo-cancel";
args = {
"library.name" = "aec/libspa-aec-webrtc";
"monitor.mode" = true;
"capture.props" = {
"node.name" = "echo_cancel_capture";
"node.description" = "Echo Cancel Capture";
};
"source.props" = {
"node.name" = "echo_cancel_source";
"node.description" = "Echo Cancelled Microphone";
};
};
}
];
};
};
}
+92 -6
View File
@@ -1,12 +1,98 @@
{ lib, ... }:
{
networking.nameservers = lib.mkDefault [
"1.1.1.1"
"1.0.0.1"
let
dohPort = 5300;
dohLocalUpstream = "127.0.0.1#${toString dohPort}";
internalDns = [
"10.50.80.53"
"10.50.80.54"
# "fd00:50:80::53"
# "fd00:50:80::54"
];
services.resolved.enable = lib.mkDefault false;
security.pki.certificateFiles = [ ./root_ca.crt ];
internalZones = [
"app.homelabs.run"
];
internalDnsServers = lib.concatMap (zone: map (dns: "/${zone}/${dns}") internalDns) internalZones;
in
{
services.resolved.enable = false;
networking.networkmanager.dns = "none";
services.dnscrypt-proxy = {
enable = true;
upstreamDefaults = true;
settings = {
listen_addresses = [
"127.0.0.1:${toString dohPort}"
];
server_names = [
"cloudflare"
"cloudflare-ipv6"
];
ipv4_servers = true;
ipv6_servers = true;
dnscrypt_servers = false;
doh_servers = true;
odoh_servers = false;
cache = false;
block_ipv6 = false;
ignore_system_dns = true;
bootstrap_resolvers = [
"9.9.9.11:53"
"149.112.112.11:53"
"[2620:fe::11]:53"
"[2620:fe::fe:11]:53"
];
netprobe_address = "1.1.1.1:443";
timeout = 5000;
keepalive = 30;
};
};
services.dnsmasq = {
enable = true;
resolveLocalQueries = true;
settings = {
no-resolv = true;
local-service = "host";
server = [ dohLocalUpstream ] ++ internalDnsServers;
all-servers = true;
cache-size = 10000;
dns-loop-detect = true;
domain-needed = true;
bogus-priv = true;
};
};
systemd.services.dnsmasq = {
wants = [ "dnscrypt-proxy.service" ];
after = [ "dnscrypt-proxy.service" ];
};
security.pki.certificateFiles = [
./root_ca.crt
];
}
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"nixos": {
"type": "local",
"command": ["mcp-nixos"],
"enabled": true,
"timeout": 30000
},
"github": {
"type": "remote",
"url": "https://api.githubcopilot.com/mcp/",
"enabled": true,
"oauth": false,
"headers": {
"Authorization": "Bearer {env:GITHUB_PERSONAL_ACCESS_TOKEN}",
"X-MCP-Readonly": "true",
"X-MCP-Toolsets": "repos,pull_requests,actions"
},
"timeout": 30000
}
}
}
+526
View File
@@ -0,0 +1,526 @@
#!/usr/bin/env python3
"""Change-aware, non-activating validation for this Nix dotfiles repository."""
from __future__ import annotations
import argparse
from collections import defaultdict, deque
from contextlib import contextmanager
import io
import json
import os
from pathlib import Path, PurePosixPath
import shutil
import subprocess
import sys
import tarfile
import tempfile
import tomllib
from typing import Any, Iterator, Sequence
GLOBAL_FILES = {"flake.nix", "flake.lock", "hosts/default.nix"}
GLOBAL_PREFIXES = ("flake/", "libs/", "overlays/")
FULL_EVAL_PREFIXES = GLOBAL_PREFIXES + ("scripts/", "shells/", "tests/")
FULL_BUILD_PREFIXES = GLOBAL_PREFIXES + ("scripts/", "shells/", "tests/")
DOC_SUFFIXES = (".md", ".png", ".jpg", ".jpeg", ".webp")
class ValidationError(RuntimeError):
pass
def command_text(command: Sequence[str]) -> str:
return " ".join(json.dumps(part) if any(c.isspace() for c in part) else part for part in command)
def run(
command: Sequence[str],
*,
cwd: Path,
capture: bool = False,
check: bool = True,
input_text: str | None = None,
) -> subprocess.CompletedProcess[str]:
print(f"+ {command_text(command)}", file=sys.stderr)
return subprocess.run(
list(command),
cwd=cwd,
check=check,
text=True,
input=input_text,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None,
)
def git(root: Path, *args: str, check: bool = True) -> str:
return run(["git", *args], cwd=root, capture=True, check=check).stdout
def repo_root() -> Path:
return Path(run(["git", "rev-parse", "--show-toplevel"], cwd=Path.cwd(), capture=True).stdout.strip()).resolve()
def normalize(root: Path, raw: str) -> str:
candidate = Path(raw)
absolute = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
try:
return absolute.relative_to(root).as_posix()
except ValueError as error:
raise ValidationError(f"path escapes repository root: {raw}") from error
def nonempty_lines(value: str) -> list[str]:
return [line for line in value.splitlines() if line]
def tracked_paths(root: Path) -> set[str]:
return set(nonempty_lines(git(root, "ls-files")))
def untracked_paths(root: Path) -> set[str]:
return set(nonempty_lines(git(root, "ls-files", "--others", "--exclude-standard")))
def expand_paths(root: Path, raw_paths: Sequence[str], available: set[str]) -> list[str]:
result: set[str] = set()
for raw in raw_paths:
relative = normalize(root, raw)
target = root / relative
if target.is_dir():
prefix = f"{relative.rstrip('/')}/" if relative else ""
result.update(path for path in available if path.startswith(prefix))
else:
result.add(relative)
return sorted(result)
def collect_paths(root: Path, args: argparse.Namespace) -> tuple[list[str], set[str]]:
tracked = tracked_paths(root)
untracked = untracked_paths(root)
if args.paths:
paths = expand_paths(root, args.paths, tracked | untracked)
elif args.all_files:
paths = sorted(tracked)
elif args.base:
paths = nonempty_lines(
git(root, "diff", "--name-only", "--no-renames", "--diff-filter=ACMRTUXBD", f"{args.base}...HEAD", "--")
)
else:
paths = nonempty_lines(git(root, "diff", "--name-only", "--no-renames", "--diff-filter=ACMRTUXBD", "HEAD", "--"))
paths += sorted(untracked)
return sorted(set(paths)), untracked
def remove_path(path: Path) -> None:
if path.is_symlink() or path.is_file():
path.unlink()
elif path.is_dir():
shutil.rmtree(path)
@contextmanager
def flake_reference(root: Path, changed: Sequence[str]) -> Iterator[str]:
"""Create a clean HEAD snapshot and overlay only task-owned worktree paths."""
with tempfile.TemporaryDirectory(prefix="dotfiles-flake-") as temporary:
source = Path(temporary) / "source"
source.mkdir()
archive = subprocess.run(
["git", "archive", "--format=tar", "HEAD"],
cwd=root,
check=True,
stdout=subprocess.PIPE,
).stdout
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as tar:
tar.extractall(source, filter="data")
for relative in changed:
src, dst = root / relative, source / relative
remove_path(dst)
if src.is_symlink():
dst.parent.mkdir(parents=True, exist_ok=True)
dst.symlink_to(os.readlink(src))
elif src.is_file():
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
yield f"path:{source}"
def load_metadata(root: Path, reference: str) -> dict[str, Any]:
result = run(
["nix", "eval", "--json", "--show-trace", f"{reference}#lib.validationMetadata"],
cwd=root,
capture=True,
)
metadata = json.loads(result.stdout)
if metadata.get("schemaVersion") != 1:
raise ValidationError("unsupported validation metadata schema")
return metadata
def reverse_dependencies(units: dict[str, Any]) -> dict[str, set[str]]:
result: dict[str, set[str]] = defaultdict(set)
for unit_id, unit in units.items():
for dependency in unit.get("includes", []):
result[dependency].add(unit_id)
return result
def users_of(unit_id: str, reverse: dict[str, set[str]]) -> set[str]:
result, queue = {unit_id}, deque([unit_id])
while queue:
for dependent in reverse.get(queue.popleft(), set()):
if dependent not in result:
result.add(dependent)
queue.append(dependent)
return result
def owner_for(path: str, units: dict[str, Any]) -> str | None:
matches = []
for unit_id, unit in units.items():
directory = unit["directory"].rstrip("/")
if path == directory or path.startswith(f"{directory}/"):
matches.append((len(directory), unit_id))
return max(matches)[1] if matches else None
def path_classes(path: str, unit: dict[str, Any]) -> set[str]:
directory = unit["directory"].rstrip("/")
relative = path[len(directory) :].lstrip("/")
if relative == "nixos.nix" or relative == "home/nixos.nix":
return {"nixos"}
if relative == "darwin.nix" or relative == "home/darwin.nix":
return {"darwin"}
if relative == "meta.nix":
return {"nixos", "darwin"}
if relative in {"common.nix", "home.nix", "home/common.nix"}:
return {"nixos", "darwin"}
fragments = set(unit.get("fragments", []))
classes: set[str] = set()
if fragments & {"common", "nixos", "home", "homeCommon", "homeNixos"}:
classes.add("nixos")
if fragments & {"common", "darwin", "home", "homeCommon", "homeDarwin"}:
classes.add("darwin")
return classes or {"nixos", "darwin"}
def is_docs_only(path: str) -> bool:
return path.endswith(DOC_SUFFIXES)
def plan(metadata: dict[str, Any], paths: Sequence[str], *, all_hosts: bool = False) -> dict[str, Any]:
hosts: dict[str, Any] = metadata["hosts"]
units: dict[str, Any] = metadata["units"]
reverse = reverse_dependencies(units)
affected_units: set[str] = set()
unit_classes: dict[str, set[str]] = defaultdict(set)
affected_hosts = {"nixos": set(), "darwin": set()}
global_change = all_hosts
requires_full_eval = False
requires_full_build = False
requires_nix = False
for path in paths:
if path in GLOBAL_FILES or path.startswith(GLOBAL_PREFIXES):
global_change = True
requires_nix = True
if path.startswith(FULL_EVAL_PREFIXES) or path in GLOBAL_FILES:
requires_full_eval = True
if path.startswith(FULL_BUILD_PREFIXES) or path in {"flake.nix", "flake.lock"}:
requires_full_build = True
if path.endswith(".nix") or path == "flake.lock":
requires_nix = True
if path == "scripts/dotfiles-check.py":
requires_nix = requires_full_eval = requires_full_build = True
if path.startswith("hosts/") and path != "hosts/default.nix":
parts = PurePosixPath(path).parts
if len(parts) > 1 and parts[1] in hosts:
affected_hosts[hosts[parts[1]]["kind"]].add(parts[1])
requires_nix = True
owner = owner_for(path, units)
if owner:
classes = path_classes(path, units[owner])
for unit_id in users_of(owner, reverse):
affected_units.add(unit_id)
unit_classes[unit_id].update(classes)
requires_nix = requires_nix or not is_docs_only(path)
elif path.startswith("modules/") and not is_docs_only(path):
global_change = True
requires_nix = True
if global_change:
for name, host in hosts.items():
affected_hosts[host["kind"]].add(name)
else:
for name, host in hosts.items():
selected = set(host["selectedUnits"])
if any(unit_id in selected and host["kind"] in unit_classes[unit_id] for unit_id in affected_units):
affected_hosts[host["kind"]].add(name)
synthetic = {"nixos": set(), "darwin": set()}
for unit_id in affected_units:
for kind in unit_classes[unit_id]:
if not any(unit_id in hosts[name]["selectedUnits"] for name in affected_hosts[kind]):
synthetic[kind].add(unit_id)
systems = sorted({host["system"] for host in hosts.values()})
native_systems = {system: False for system in systems}
if requires_full_build:
native_systems = {system: requires_nix for system in systems}
else:
for kind, names in affected_hosts.items():
for name in names:
native_systems[hosts[name]["system"]] = True
for kind, unit_ids in synthetic.items():
if unit_ids:
candidates = sorted(host["system"] for host in hosts.values() if host["kind"] == kind)
if candidates:
native_systems[candidates[0]] = True
return {
"schemaVersion": 1,
"paths": sorted(paths),
"affectedUnits": sorted(affected_units),
"affectedHosts": {kind: sorted(names) for kind, names in affected_hosts.items()},
"syntheticUnits": {kind: sorted(names) for kind, names in synthetic.items()},
"requiresNixValidation": requires_nix,
"requiresFullEvaluation": requires_full_eval,
"requiresFullNativeBuild": requires_full_build,
"nativeBuildSystems": native_systems,
}
def render_plan(value: dict[str, Any], reference: str) -> None:
print(f"flake: {reference}")
print("paths:", ", ".join(value["paths"]) or "(none)")
print("units:", ", ".join(value["affectedUnits"]) or "(none)")
for kind in ("nixos", "darwin"):
print(f"{kind} hosts:", ", ".join(value["affectedHosts"][kind]) or "(none)")
print(f"{kind} synthetic:", ", ".join(value["syntheticUnits"][kind]) or "(none)")
print("full evaluation:", value["requiresFullEvaluation"])
print("full native build:", value["requiresFullNativeBuild"])
def validate_skill(path: Path) -> None:
text = path.read_text()
if not text.startswith("---\n"):
raise ValidationError(f"missing Skill frontmatter: {path}")
try:
frontmatter = text.split("---\n", 2)[1]
name = next(line.split(":", 1)[1].strip() for line in frontmatter.splitlines() if line.startswith("name:"))
except (IndexError, StopIteration) as error:
raise ValidationError(f"invalid Skill frontmatter: {path}") from error
if name != path.parent.name:
raise ValidationError(f"Skill name {name!r} does not match directory {path.parent.name!r}")
def static_checks(root: Path, paths: Sequence[str], untracked: set[str]) -> None:
for relative in paths:
path = root / relative
if not path.is_file():
continue
if relative.endswith(".nix"):
run(["nix-instantiate", "--parse", str(path)], cwd=root, capture=True)
elif relative.endswith(".json"):
json.loads(path.read_text())
elif relative.endswith(".toml"):
tomllib.loads(path.read_text())
elif relative.endswith(".py"):
compile(path.read_text(), relative, "exec")
if path.name == "SKILL.md":
validate_skill(path)
if relative in untracked:
for number, line in enumerate(path.read_text(errors="replace").splitlines(), 1):
if line.rstrip() != line:
raise ValidationError(f"trailing whitespace: {relative}:{number}")
diff_paths = [path for path in paths if path not in untracked]
if diff_paths:
run(["git", "diff", "--check", "HEAD", "--", *diff_paths], cwd=root)
def run_fast(root: Path, reference: str, paths: Sequence[str], untracked: set[str], *, all_files: bool) -> None:
static_checks(root, paths, untracked)
command = ["nix", "develop", f"{reference}#validation", "--command", "pre-commit", "run"]
command += ["--all-files"] if all_files else (["--files", *paths] if paths else [])
if paths or all_files:
run(command, cwd=root)
def selection_module_expr(unit_id: str) -> str:
quoted = json.dumps(unit_id)
return f"(flake.lib.registry.mkSelectionModule [ {quoted} ])"
def synthetic_expr(reference: str, host_name: str, host: dict[str, Any], unit_id: str, *, drv_path: bool) -> str:
selection = selection_module_expr(unit_id)
modules = [selection]
if host.get("homeManager", True):
user = json.dumps(host["user"])
modules.append(f"{{ home-manager.users.{user}.imports = [ {selection} ]; }}")
base = f"flake.{('darwinConfigurations' if host['kind'] == 'darwin' else 'nixosConfigurations')}.{json.dumps(host_name)}"
output = "extended.system" if host["kind"] == "darwin" else "extended.config.system.build.toplevel"
if drv_path:
output += ".drvPath"
return f'''let
flake = builtins.getFlake {json.dumps(reference)};
base = {base};
extended = base.extendModules {{ modules = [ {' '.join(modules)} ]; }};
in {output}'''
def representative_host(metadata: dict[str, Any], kind: str, current_system: str | None = None) -> tuple[str, dict[str, Any]] | None:
candidates = [(name, host) for name, host in metadata["hosts"].items() if host["kind"] == kind]
if current_system:
candidates = [item for item in candidates if item[1]["system"] == current_system]
return sorted(candidates)[0] if candidates else None
def host_drv_attr(reference: str, host: dict[str, Any]) -> str:
return f"{reference}#{host['buildAttr']}.drvPath"
def run_eval(
root: Path,
reference: str,
metadata: dict[str, Any],
value: dict[str, Any],
*,
all_systems: bool,
) -> None:
if all_systems or value["requiresFullEvaluation"]:
run(["nix", "flake", "check", reference, "--no-build", "--all-systems", "--keep-going", "--show-trace"], cwd=root)
return
for kind in ("nixos", "darwin"):
for name in value["affectedHosts"][kind]:
run(["nix", "eval", "--raw", "--show-trace", host_drv_attr(reference, metadata["hosts"][name])], cwd=root)
representative = representative_host(metadata, kind)
if representative:
name, host = representative
for unit_id in value["syntheticUnits"][kind]:
run(["nix", "eval", "--raw", "--impure", "--show-trace", "--expr", synthetic_expr(reference, name, host, unit_id, drv_path=True)], cwd=root)
def current_system(root: Path) -> str:
return run(["nix", "eval", "--raw", "--impure", "--expr", "builtins.currentSystem"], cwd=root, capture=True).stdout.strip()
def run_full_native(root: Path, reference: str, system: str) -> None:
run(
["nix", "run", f"{reference}#nix-fast-build", "--", "--flake", f"{reference}#checks.{system}", "--skip-cached", "--no-nom", "--no-link"],
cwd=root,
)
def run_build(root: Path, reference: str, metadata: dict[str, Any], value: dict[str, Any]) -> None:
system = current_system(root)
if value["requiresFullNativeBuild"]:
run_full_native(root, reference, system)
return
installables: list[str] = []
for kind in ("nixos", "darwin"):
for name in value["affectedHosts"][kind]:
host = metadata["hosts"][name]
if host["system"] == system:
installables.append(f"{reference}#{host['buildAttr']}")
else:
print(f"skip incompatible build: {name} ({host['system']})", file=sys.stderr)
for check in ("registry", "validation-tool"):
installables.append(f"{reference}#checks.{system}.{check}")
if installables:
run(["nix", "build", "--no-link", "--keep-going", "--print-build-logs", *sorted(set(installables))], cwd=root)
for kind in ("nixos", "darwin"):
representative = representative_host(metadata, kind, system)
if representative:
name, host = representative
for unit_id in value["syntheticUnits"][kind]:
run(["nix", "build", "--no-link", "--impure", "--expr", synthetic_expr(reference, name, host, unit_id, drv_path=False)], cwd=root)
def self_test() -> None:
metadata = {
"schemaVersion": 1,
"units": {
"applications.foo": {"directory": "modules/applications/foo", "includes": [], "fragments": ["home", "homeNixos"]},
"applications.dormant": {"directory": "modules/applications/dormant", "includes": [], "fragments": ["home"]},
"profiles.workload.dev": {"directory": "modules/profiles/workload/dev", "includes": ["applications.foo"], "fragments": ["meta"]},
},
"hosts": {
"linux": {"kind": "nixos", "system": "x86_64-linux", "user": "test", "homeManager": True, "selectedUnits": ["profiles.workload.dev"], "buildAttr": "nixosConfigurations.linux.config.system.build.toplevel"},
"mac": {"kind": "darwin", "system": "aarch64-darwin", "user": "test", "homeManager": True, "selectedUnits": ["profiles.workload.dev"], "buildAttr": "darwinConfigurations.mac.system"},
},
}
nixos = plan(metadata, ["modules/applications/foo/home/nixos.nix"])
assert nixos["affectedHosts"] == {"nixos": ["linux"], "darwin": []}
assert nixos["affectedUnits"] == ["applications.foo", "profiles.workload.dev"]
common = plan(metadata, ["modules/applications/foo/home.nix"])
assert common["affectedHosts"] == {"nixos": ["linux"], "darwin": ["mac"]}
dormant = plan(metadata, ["modules/applications/dormant/home.nix"])
assert dormant["syntheticUnits"] == {"nixos": ["applications.dormant"], "darwin": ["applications.dormant"]}
global_value = plan(metadata, ["flake.nix"])
assert global_value["requiresFullEvaluation"] and global_value["requiresFullNativeBuild"]
docs = plan(metadata, ["modules/profiles/README.md"])
assert not docs["requiresNixValidation"]
print("dotfiles-check self-test passed")
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(description=__doc__)
result.add_argument("command", choices=("plan", "fast", "eval", "build", "all", "full", "self-test"))
result.add_argument("--paths", nargs="+", help="Explicit task-owned repository paths")
result.add_argument("--base", help="Compare BASE...HEAD")
result.add_argument("--all-files", action="store_true", help="Check every tracked file")
result.add_argument("--all-hosts", action="store_true", help="Validate every registered host")
result.add_argument("--all-systems", action="store_true", help="Evaluate every flake system")
result.add_argument("--json", action="store_true", help="Emit the plan as JSON")
return result
def main() -> int:
args = parser().parse_args()
if args.command == "self-test":
self_test()
return 0
selectors = sum(bool(value) for value in (args.paths, args.base, args.all_files))
if selectors > 1:
raise ValidationError("use only one of --paths, --base, or --all-files")
if args.command == "full":
if selectors or args.all_hosts or args.all_systems:
raise ValidationError("full is exhaustive and accepts no scope flags")
args.all_files = args.all_hosts = args.all_systems = True
root = repo_root()
paths, untracked = collect_paths(root, args)
with flake_reference(root, paths) as reference:
metadata = load_metadata(root, reference)
value = plan(metadata, paths, all_hosts=args.all_hosts)
if args.command == "plan":
print(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)) if args.json else render_plan(value, reference)
return 0
if args.command in {"fast", "all", "full"}:
run_fast(root, reference, paths, untracked, all_files=args.all_files)
if args.command in {"eval", "all", "full"} and value["requiresNixValidation"]:
run_eval(root, reference, metadata, value, all_systems=args.all_systems or args.command in {"all", "full"})
if args.command in {"build", "all"} and value["requiresNixValidation"]:
run_build(root, reference, metadata, value)
if args.command == "full":
run_full_native(root, reference, current_system(root))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except ValidationError as error:
print(f"error: {error}", file=sys.stderr)
raise SystemExit(2) from error
except subprocess.CalledProcessError as error:
if error.stdout:
print(error.stdout, file=sys.stderr, end="")
if error.stderr:
print(error.stderr, file=sys.stderr, end="")
raise SystemExit(error.returncode) from error
+9
View File
@@ -3,15 +3,22 @@ _: {
{
pkgs,
config,
lib,
...
}:
{
devShells.dotnix = pkgs.mkShell {
packages = [
config.treefmt.build.wrapper
config.packages.dotfiles-check
pkgs.actionlint
pkgs.git
pkgs.gitleaks
pkgs.mcp-nixos
pkgs.nix-fast-build
pkgs.nix-tree
pkgs.nixd
pkgs.pre-commit
# sops-nix / age
@@ -22,6 +29,8 @@ _: {
# YubiKey for sops editing
pkgs.age-plugin-yubikey
pkgs.yubikey-manager
]
++ lib.optionals (lib.meta.availableOn pkgs.stdenv.hostPlatform pkgs.pcsc-tools) [
pkgs.pcsc-tools
];
+144
View File
@@ -0,0 +1,144 @@
---
name: add-application-or-service
description: Add, install, configure, or enable an application or long-running service in this NixOS, nix-darwin, and Home Manager flake while preserving its Registry architecture and quality bar. Use for new GUI or CLI applications, packages, daemons, background services, application-service pairs, cross-platform installations, profile adoption, or substantial extensions to an existing application or service unit.
---
# Add Application or Service
Add the smallest complete Registry unit change that has a clear owner, an
explicit dependency path, and evidence that every affected host class
evaluates. Treat `AGENTS.md` as the authoritative repository contract; never
replace it with generic Nix conventions.
## Follow the workflow
### 1. Establish the baseline
1. Read `AGENTS.md` completely before editing.
2. Run `git status --short`. Preserve all pre-existing user changes and identify
which later diffs belong to this task.
3. Translate the request into observable outcomes: package or program, desired
configuration, supported host classes, required daemon or permissions, and
the profile or user intent that should select it.
4. Inspect the nearest existing units, relevant profiles, `hosts/default.nix`,
and Registry implementation. Prefer repository evidence over memory.
5. Verify current package names, module options, external module exports, and
Homebrew cask names from the locked inputs or authoritative upstream
documentation. Do not guess an option path.
6. Read [references/review-checklist.md](references/review-checklist.md) before
choosing files or dependencies.
### 2. Choose ownership before code
Classify each concern independently:
- Put the user-facing program and its settings in
`modules/applications/<name>/`.
- Put a daemon, long-running process, firewall rule, permission, or user/group
membership in `modules/services/<name>/`.
- Split an application and independently meaningful daemon into two units.
Let the application include the service only when the service is a technical
requirement of that application.
- Put adoption of otherwise independent units in the narrowest coherent
`modules/profiles/` composition.
- Use another documented owner when the request is actually a system,
hardware, user, overlay, or host concern. Do not force it into an application
or service directory merely because this skill was invoked.
Choose only the reserved fragments that contain real configuration. Use
`common.nix`, `nixos.nix`, and `darwin.nix` for system-side configuration; use
`home.nix` or `home/{common,nixos,darwin}.nix` for Home Manager. Use `meta.nix`
only for description, fully qualified `includes`, and external module imports.
Before editing, formulate a short implementation contract containing:
- the unit ID and owner;
- each file to create or change and why;
- technical dependencies versus profile-level choices;
- supported and affected host classes;
- the evaluations or builds that will prove the change.
Rework the design if an ordinary addition appears to require Registry changes,
new global `specialArgs`, `_module.args`, direct host selection, or an overlay.
Use those mechanisms only with concrete evidence that the documented extension
points cannot express the requirement.
### 3. Implement the minimum complete change
1. Return configuration directly from every reserved fragment. Do not add
top-level `imports`, `options`, or `config`, and do not reproduce Registry
`mkEnableOption`, `cfg`, or `mkIf` boilerplate.
2. Put upstream NixOS, nix-darwin, or Home Manager modules in
`meta.imports.<class>`. Import ordinary helper files explicitly from the
fragment that uses them.
3. Declare unit-to-unit technical dependencies only through fully qualified
`meta.includes`. Never enable another unit by assigning its
`my.<path>.enable` option inside a fragment.
4. Add an independent application or service to an existing coherent profile,
or create a justified profile when no existing one expresses the user
intent. Do not use `hosts/default.nix` application or unit escape hatches for
normal composition.
5. Keep cross-platform purpose shared and installation differences in the
owning unit. Do not create thin `*-linux` profiles.
6. Use existing module arguments and standard options. Do not inject a
dependency through global arguments, Registry internals, import ordering, or
`lib.mkForce`. Use explicit module priorities only when a real ownership
boundary requires them and make that reason visible in the code or handoff.
7. Avoid speculative abstraction. Create a helper only when it separates
meaningful configuration or prevents real duplication. Do not add empty
fragments, compatibility aliases, unused options, redundant comments, or
copied boilerplate.
8. Update `modules/profiles/README.md`, `AGENTS.md`, profile selections, or
other contract documentation whenever the change makes an existing
statement stale. Do not edit them performatively when their meaning remains
accurate.
### 4. Prove the change
Invoke the `validate-nix-change` skill and use the task-owned files as its
explicit path set. At minimum:
1. Inspect `nix run .#check -- plan --paths <task-path>... --json` and confirm
the reported units, host classes, and real hosts are correct.
2. Run `nix run .#check -- fast --paths <task-path>...` during the edit loop.
3. Inspect the complete task diff for accidental files, duplication, leaked
secrets, forced values, direct enable assignments, and unrelated rewrites.
4. Run `nix run .#check -- all --paths <task-path>...` after the structure is
complete. This evaluates every flake system and builds affected targets for
the current platform without activation.
5. Verify selection as well as syntax: confirm that the expected package,
program, service, group, cask, or external module appears in the resulting
configuration.
6. Add a `pkgs.testers.runNixOSTest` check through the `test-nixos-service`
skill when service startup or another runtime contract cannot be proved by
evaluation and a system build.
Use `nix run .#check -- full` only for CI, scheduled maintenance, or an explicit
repository-wide audit. These commands never activate the live system. Do not
run `nh os switch`, `nixos-rebuild switch`, `darwin-rebuild switch`,
`home-manager switch`, or an equivalent activation command as validation.
If a command is unavailable, blocked by the environment, or fails for a
pre-existing reason, invoke the `debug-nix-failure` skill, diagnose it, and
report the exact gap. Never silently skip a required check or weaken the
implementation to make a check pass.
### 5. Audit before completion
Reject the change until all of the following are true:
- Every line has one clear owner and is required by the requested behavior.
- Every dependency is either technical and declared in `meta.includes`, or a
user choice owned by a profile.
- The unit is reachable from the intended profile or has an explicit reason to
remain independently selectable.
- No host, Registry, flake root, global argument, or overlay was changed as a
shortcut.
- Reserved fragments, metadata, and profile documentation satisfy the current
repository contract.
- Validation covers every affected host class and all failures are resolved or
explicitly reported.
Conclude with the owner and selection rationale, affected hosts or profiles,
validation commands and results, and any manual activation or runtime check
that remains. Do not claim runtime behavior that was only evaluated.
@@ -0,0 +1,4 @@
interface:
display_name: "Add Application or Service"
short_description: "Add clean, validated Registry units"
default_prompt: "Use $add-application-or-service to add an application or service cleanly and verify every affected host class."
@@ -0,0 +1,132 @@
# Application and Service Review Checklist
Use this reference during design and again during final review.
## Ownership and fragment matrix
| Concern | Owner | Typical fragment |
| -------------------------------------------------------- | ----------------------------------- | ----------------------------------------- |
| User-facing GUI, CLI, editor, or compositor | `modules/applications/<name>/` | `home*.nix`, `nixos.nix`, or `darwin.nix` |
| User-scoped package and program settings | Application unit | `home.nix` or `home/<class>.nix` |
| macOS Homebrew package or cask | Owning application or service | `darwin.nix` |
| Daemon or long-running service | `modules/services/<name>/` | `nixos.nix` or `darwin.nix` |
| Firewall, group, permission, or service account | Owning service | `nixos.nix` or `darwin.nix` |
| Upstream module defining options | Owning unit metadata | `meta.nix` under `imports.<class>` |
| Technical prerequisite unit | Owning unit metadata | Fully qualified `meta.includes` |
| A set of independent tools chosen for one purpose | Narrowest coherent profile | Profile `meta.includes` |
| Machine fact such as UUID, monitor ID, or static address | `hosts/<name>/` | Normal host module |
| Missing or replaced package | `overlays/` only after proving need | Overlay definition |
Use `home/common.nix` when Home Manager configuration is truly shared between
NixOS and Darwin. Use `home/nixos.nix` or `home/darwin.nix` for class-specific
Home Manager behavior. Root `common.nix` is system-side and never Home Manager.
Do not create an unused counterpart for symmetry.
## Dependency review
For every edge from unit A to unit B, answer these questions:
1. Does A fail to work without B? If yes, put the fully qualified ID of B in
A's `meta.includes`.
2. Are A and B merely useful together for a particular workflow? If yes, let a
profile include both.
3. Does the dependency exist only on one host? Keep the machine fact in the
host, but keep reusable behavior in its unit or profile.
4. Is a new `specialArgs`, `_module.args`, Registry field, or direct
`my.*.enable` assignment being proposed? Reject it unless the repository's
normal module and `meta.includes` mechanisms provably cannot model the
requirement.
5. Would adding the dependency make the depended-on application select a
compositor, desktop, personal workload, or unrelated tool? Reverse or remove
the edge; application metadata contains technical requirements, not taste.
Accept no circular dependency, shortened unit ID, duplicate include, stale
unit ID, or ordering-dependent override.
## Minimality and code-quality review
Reject any of these patterns:
- Empty or placeholder reserved fragments.
- Hand-written enable options or guards already generated by the Registry.
- Top-level `imports`, `options`, or `config` in a configuration fragment.
- External module imports hidden in a guarded configuration fragment.
- Helper files assumed to be auto-imported.
- A helper abstraction used once without reducing meaningful complexity.
- Configuration duplicated across fragments when a shared fragment can express
it cleanly.
- Direct host application or unit selection where a profile expresses the
concern.
- A new flake input or overlay when the locked package set already provides the
package and required module.
- Global argument injection for a value owned by one unit.
- `lib.mkForce` used to win an ordering fight instead of resolving ownership.
- Secret material, generated state, machine IDs, or mutable user preferences
committed as reusable configuration.
- Comments that repeat the code, compatibility aliases, dead options, or
opportunistic unrelated cleanup.
Prefer standard upstream module options over hand-written service definitions.
Prefer existing repository arguments and helpers over new plumbing. Preserve
user-owned mutable state unless the requested policy explicitly owns it.
## Validation matrix
Use the `validate-nix-change` skill and run checks from the repository root.
Keep exact results for the handoff. The validation app never switches or
activates a live system.
### Always
1. Run `nix run .#check -- plan --paths <task-path>... --json` and inspect the
affected units and hosts.
2. Run `nix run .#check -- fast --paths <task-path>...` during implementation.
3. Review `git status --short`, `git diff --stat`, and the complete task diff.
4. Run `nix run .#check -- all --paths <task-path>...` before handoff. It runs
all-system evaluation and compatible targeted builds without activation.
5. Reserve `nix run .#check -- full` for CI, scheduled maintenance, or an
explicit repository-wide audit.
Do not run `nh os switch`, `nixos-rebuild switch`, `darwin-rebuild switch`,
`home-manager switch`, or an equivalent activation command.
### NixOS or Home Manager on NixOS
- Confirm that the validation plan includes each affected real NixOS host.
- Build affected NixOS configurations with `--no-link` through the validation
app when the current system supports them.
- Inspect the resulting option that proves selection: for example
`environment.systemPackages`, the user's `home.packages`,
`systemd.services`, `users.users.<name>.extraGroups`, or the upstream
`programs`/`services` option.
### nix-darwin or Home Manager on Darwin
- Confirm that every affected Darwin host is evaluated even when running on
Linux.
- Inspect `homebrew.casks` or `homebrew.brews` for Homebrew-backed additions.
- Evaluate the relevant Home Manager program or package option.
- Build a Darwin configuration only on a compatible Darwin runner or builder;
otherwise report the build as an explicit platform gap.
### Profiles and cross-platform changes
- Confirm transitive selection through `meta.includes`, not only direct
mentions. The validation plan computes reverse dependency closure.
- Evaluate every real host selecting the changed profile.
- Evaluate both host classes for a cross-platform profile, even if only one
current fragment changed.
- Re-read `modules/profiles/README.md` and `hosts/default.nix` for stale meaning,
compatibility, or role statements.
- If no real host selects the new unit, construct a non-persistent evaluation
that enables it or explain why the unit is intentionally dormant. Do not add
a fake host or permanent direct selection as a test harness.
### Runtime-dependent behavior
Evaluation and builds cannot prove GUI appearance, credentials, network access,
hardware behavior, successful daemon interaction, or reboot state. For
reusable NixOS behavior, use the `test-nixos-service` skill and add a
`pkgs.testers.runNixOSTest` check. State the precise manual post-activation check
needed for physical hardware or external systems. Never describe evaluation as
a runtime test.
+63
View File
@@ -0,0 +1,63 @@
---
name: debug-nix-failure
description: Diagnose failures from parsing, Nix module evaluation, derivation builds, flake checks, NixOS tests, or Home Manager activation logs without changing the live system. Use when `nix run .#check`, `nix flake check`, `nix build`, CI, or a user-provided activation log fails.
---
# Debug a Nix Failure
Classify the failure before changing code. Preserve the original command,
complete error, first causal frame, and affected attribute. Never run a live
switch or activation to reproduce a validation failure.
## Identify the failing layer
- **Parse or format:** syntax location, malformed string, unmatched delimiter,
or formatter-owned rewrite.
- **Static analysis:** dead binding, suspicious expression, ShellCheck finding,
secret scan, or workflow lint.
- **Module evaluation:** missing option, wrong type, assertion, infinite
recursion, conflicting definitions, Registry selection, or unsupported host
class.
- **Derivation instantiation/build:** missing dependency, hash mismatch, patch
failure, compiler/test failure, sandbox violation, or unsupported platform.
- **NixOS test:** failed unit, timeout, command assertion, network readiness, or
reboot state.
- **Activation/runtime:** filesystem conflict, activation script, systemd unit,
hardware, credential, or external-service behavior. Diagnose only from logs
supplied by the user unless they explicitly request a non-switch inspection
command.
## Reproduce the narrowest failing operation
Start with the stage and paths reported by the validation app:
```sh
nix run .#check -- plan --paths <task-path>... --json
nix run .#check -- fast --paths <task-path>...
nix run .#check -- eval --paths <task-path>...
nix run .#check -- build --paths <task-path>...
```
For a single attribute, use `nix eval --show-trace` on its `drvPath` before a
build. For a failed derivation, retain `--print-build-logs` and inspect
`nix log <drv-path>` when the summary omits the causal lines.
## Read traces selectively
1. Find the first repository-owned frame or option path.
2. Separate the immediate failure from wrapper frames in `modules.nix`,
`lib.evalModules`, or flake-parts.
3. Inspect the option declaration and every definition contributing to it.
4. Confirm package and option names against locked inputs, not memory.
5. Check whether the failure reproduces on the base revision before calling it
task-owned.
Do not respond to a type or ownership error with import-order changes,
`lib.mkForce`, global arguments, or an overlay unless repository evidence shows
that those mechanisms are the correct owner.
## Finish with a bounded diagnosis
Report the failing layer, root cause, minimal correction, rerun command, and any
remaining platform or runtime gap. Include enough of the error to identify it,
but do not paste large unrelated logs.
+64
View File
@@ -0,0 +1,64 @@
---
name: test-nixos-service
description: Add or extend a non-activating NixOS VM or container test for service startup, sockets, timers, permissions, firewall behavior, reboot state, and inter-service dependencies. Use when evaluation and a system build cannot prove the requested runtime behavior.
---
# Test NixOS Runtime Behavior
Prefer `pkgs.testers.runNixOSTest` for reusable NixOS behavior that can be
proved without the user's physical machine. Do not activate the host
configuration and do not substitute a live `nh os switch` for a deterministic
test.
## Define the observable contract
List the runtime facts that must hold, such as:
- a systemd unit reaches `active`;
- a socket or port is listening;
- a timer triggers its service;
- a user can or cannot read a file;
- a group membership grants access;
- a firewall permits one path and blocks another;
- state survives a reboot;
- one service waits for another dependency.
Exclude behavior that requires physical GPU, fingerprint, audio, display,
Secure Boot, TPM, private credentials, or an external provider unless the test
can model it explicitly.
## Implement the smallest useful machine
Create a test under `tests/` and expose it through `checks.<system>`. Import the
owning module or Registry selection instead of copying its implementation into
the test. Use only the packages, users, files, and network peers required by the
contract.
Typical shape:
```nix
pkgs.testers.runNixOSTest {
name = "service-name";
nodes.machine = {
# Enable the owning unit or import the module under test.
};
testScript = ''
machine.start()
machine.wait_for_unit("service-name.service")
machine.succeed("systemctl is-active service-name.service")
'';
}
```
Use `wait_for_unit`, `wait_for_open_port`, `succeed`, `fail`, and explicit
reboots to express outcomes. Avoid arbitrary sleeps when a readiness condition
exists.
## Validate and report
Run the targeted test through its flake check, then run the repository
validation app for the task paths. Report the test attribute and assertions
that passed. State clearly which hardware or external behavior remains outside
the VM/container model.
+58
View File
@@ -0,0 +1,58 @@
---
name: update-flake-input
description: Update one or more pinned flake inputs with bounded lock-file changes and non-activating Linux and Darwin validation. Use for dependency refreshes, input-specific updates, automated lock-file pull requests, or diagnosing a regression introduced by flake.lock.
---
# Update a Flake Input
Keep the update scope explicit and treat `flake.lock` as generated dependency
state. Never activate a host as part of this workflow; do not run `nh os switch`,
`nixos-rebuild switch`, `darwin-rebuild switch`, `home-manager switch`, or an
equivalent command.
## Bound the update
1. Read `AGENTS.md`, inspect `git status --short`, and preserve unrelated work.
2. Record the input names and the behavior or version change being requested.
3. Prefer an input-specific update:
```sh
nix flake update <input-name>
```
Use an unrestricted `nix flake update` only when the task explicitly requests a
full refresh. Do not hand-edit lock nodes.
## Audit the lock diff
Inspect the complete `flake.lock` diff. Confirm that changed nodes are the
requested inputs or unavoidable followers and that source owners, repositories,
reference types, and hashes remain expected. Investigate unexpected node
replacement, disappearing followers, or a large transitive graph rewrite before
validation.
## Validate without activation
Run the common validation workflow against the lock file:
```sh
nix run .#check -- plan --paths flake.lock --json
nix run .#check -- fast --paths flake.lock
nix run .#check -- eval --paths flake.lock
nix run .#check -- build --paths flake.lock
```
A lock-file change requires full evaluation and the complete native check set.
Linux and Darwin builds must run on compatible runners. The scheduled update
workflow uploads the candidate lock file, builds Linux and Darwin checks, and
creates a pull request only after both pass.
When a failure appears only after the update, invoke `debug-nix-failure`, compare
the failing derivation or option with the base lock, and narrow the responsible
input before adding an override or patch.
## Report the result
List requested and transitively changed inputs, validation commands and native
platform results, any package or option migration, and remaining manual runtime
checks. Evaluation or a native build is not activation.
+128
View File
@@ -0,0 +1,128 @@
---
name: validate-nix-change
description: Plan and run efficient, non-activating validation for edits to this NixOS, nix-darwin, and Home Manager flake. Use after changing Nix modules, hosts, profiles, overlays, flake outputs, tests, scripts, CI, or agent configuration; before handing off a task; or when deciding which real hosts must be evaluated or built.
---
# Validate a Nix Change
Use the repository validation app as the source of truth for change impact and
validation commands. It derives affected hosts from Registry ownership,
`meta.includes`, host selections, fragment class, and host-local paths.
Never activate a live configuration as part of this workflow. Do not run
`nh os switch`, `nixos-rebuild switch`, `darwin-rebuild switch`,
`home-manager switch`, or an equivalent activation command. The user owns live
activation separately.
## Establish the validation scope
1. Read `AGENTS.md` and run `git status --short` before editing.
2. Preserve unrelated user changes. Track the paths owned by the current task,
including newly created untracked files.
3. Inspect the plan before expensive checks:
```sh
nix run .#check -- plan --paths <task-path>... --json
```
When validating a committed pull-request range, use:
```sh
nix run .#check -- plan --base <base-sha> --json
```
The app automatically uses a `path:` flake reference when task paths are
untracked, so newly created Registry fragments are visible to Nix without
staging them.
## Run checks in increasing cost order
### Fast edit loop
After each coherent edit, parse Nix files, validate project JSON, TOML, and
skill frontmatter, check whitespace, and run the configured hooks only for
task-owned files:
```sh
nix run .#check -- fast --paths <task-path>...
```
Do not replace this with `pre-commit run --all-files` during the edit loop.
Unrelated repository files must not become part of the task merely because an
existing check fails elsewhere.
### Evaluation
After the implementation is structurally complete, evaluate every affected
NixOS and Darwin derivation plus the supporting checks without realizing or
activating them. Flake-wide paths additionally evaluate every flake system:
```sh
nix run .#check -- eval --paths <task-path>...
```
This proves module evaluation, option types, assertions, Registry selection,
and derivation instantiation. It does not prove a successful build or runtime
behavior.
### Compatible builds
Build affected configurations for the current platform with no result link:
```sh
nix run .#check -- build --paths <task-path>...
```
The app reports incompatible targets as evaluated but skipped for native build.
A Darwin target must be built by a compatible Darwin runner or builder; a Linux
evaluation is not a Darwin build.
### Final task validation
Before handoff, run the cumulative task check. It applies file checks only to
task-owned paths, evaluates every flake system, and builds affected native
targets:
```sh
nix run .#check -- all --paths <task-path>...
```
Use `--all-hosts` only when a deliberate audit must report every registered
host as affected. Use the exhaustive command for CI, scheduled maintenance, or
an explicit repository-wide audit:
```sh
nix run .#check -- full
```
`full` runs hooks over every tracked file and builds every check for the current
platform through `nix-fast-build`.
## Add runtime tests when needed
Evaluation and builds do not prove service startup, socket behavior, firewall
rules, users and groups, permissions, reboot behavior, or network interaction.
For reusable NixOS behavior, invoke the `test-nixos-service` skill and add a
`pkgs.testers.runNixOSTest` check. Hardware, credentials, GUI appearance, and
external services may still require a precisely described manual check after
the user activates the configuration.
## Diagnose failures by layer
Invoke the `debug-nix-failure` skill when a stage fails. Fix the first failing
layer before running a more expensive one. Do not hide a pre-existing failure,
weaken an assertion, add `lib.mkForce`, or skip a required host merely to make
the task appear green.
## Report evidence precisely
Conclude with:
- task-owned paths;
- affected units and hosts reported by `plan`;
- each command run and its result;
- which targets were parsed, evaluated, built, or runtime-tested;
- any compatible-platform or manual-runtime gap.
Never describe evaluation as a build, a build as activation, or a VM test as
proof of hardware-specific behavior.