Compare commits

..

No commits in common. "bf35832817701a749c3c951e90a129bc31606a3d" and "38db696ee858d9db25486fa274bb62596d347cde" have entirely different histories.

10 changed files with 57 additions and 314 deletions

View file

@ -1,8 +0,0 @@
{
"mcpServers": {
"nixos": {
"command": "uvx",
"args": ["mcp-nixos"]
}
}
}

View file

@ -1,94 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Architecture: Dendritic Pattern
This config uses the **[den](https://github.com/vic/den)** framework with the **dendritic pattern** — configurations are composed from reusable *aspects* rather than per-host files.
Key concepts:
- **Aspects** (`den.aspects.<name>`) — named, composable feature modules. Each aspect can define `nixos` (system config) and/or `homeManager` (user config) blocks.
- **Hosts** (`den.hosts.x86_64-linux.<hostname>`) — defined in `modules/den.nix`, each assigned an aspect that lists its includes.
- **`import-tree`** — automatically imports all `.nix` files under `modules/`, so adding a new file is enough to register it.
- **`flake-aspects`** — wires the den module system into the flake output.
Host→aspect mapping (from `modules/den.nix`):
- `desktop` — gaming + dev + creative, Nvidia GPU
- `notebook` — portable dev + creative, Intel GPU
- `work` — portable dev only, no gaming/creative
## Module layout
```
modules/
den.nix # host declarations and aspect composition
output.nix # exposes flake.nixosConfigurations option
identity.nix # local.identity options (username, email, SSH key)
core.nix # base: nix settings, caches, locale, base packages
services.nix # shared system services
wayland.nix # Wayland env vars
niri.nix # niri compositor + quickshell bar + display tools
dev.nix # dev tools, LSPs, Docker, JDKs (system-level)
portable.nix # power management, TLP, firewall
desktop.nix # desktop-specific hardware/packages
notebook.nix # notebook-specific packages
work.nix # work-specific packages
secrets.nix # sops-nix integration
_hardware/ # per-host hardware-configuration.nix files
_home/ # home-manager modules (imported by den.aspects.zoty)
default.nix # zsh, direnv, common home packages (firefox, kitty, claude-code)
niri.nix # niri home config
git.nix # git identity
theme.nix # GTK/icon theme
cursor.nix # cursor theme
dolphin.nix # file manager
xdg-dirs.nix # XDG directory overrides
quickshell/ # QML bar widgets (quickshell + qml-niri)
```
## Common commands
```bash
just switch # rebuild + switch (uses current hostname)
just switch desktop # target a specific host
just build # build only, don't switch
just test # apply temporarily (reverts on next boot)
just diff # show what changed vs running system
just check # evaluate flake for errors
just update # update all flake inputs
just update-input nixpkgs # update a single input
just gc # garbage collect (>7 days old)
just repl # nix repl with flake loaded
just secrets # edit SOPS-encrypted secrets
just iso # build installer ISO
```
## Inputs
| Input | Channel | Role |
|---|---|---|
| `nixpkgs` | nixos-25.11 | stable packages |
| `nixpkgs-unstable` | nixos-unstable | neovim, JDKs, some dev tools |
| `home-manager` | release-25.11 | user environment |
| `sops-nix` | — | secret decryption at boot |
| `niri-flake` | — | niri compositor |
| `disko` | — | declarative disk partitioning |
| `claude-code` | sadjow/claude-code-nix | claude-code CLI |
| `qml-niri` | imiric/qml-niri | quickshell niri integration |
## Secrets (SOPS + age)
- Encrypted in `secrets/secrets.yaml`, decrypted at boot by sops-nix.
- Age key must exist at `/etc/sops/age/keys.txt` before first deploy.
- New secrets: add to `just secrets`, then declare in `modules/secrets.nix`.
- New host key: add public key to `.sops.yaml`, then run `sops updatekeys secrets/secrets.yaml`.
## Adding a new host
1. Copy hardware config to `modules/_hardware/<hostname>-hardware.nix`.
2. Add `den.hosts.x86_64-linux.<hostname>` in `modules/den.nix`.
3. Define a `den.aspects.<hostname>` with the desired `includes` list.
## Identity
Global identity (username, full name, email, SSH key) is declared as NixOS options in `modules/identity.nix` and passed to home-manager modules via `specialArgs`. Override `local.identity.*` options in a host aspect when needed.

View file

@ -8,6 +8,7 @@
nix = {
settings = {
experimental-features = [ "nix-command" "flakes" ];
auto-optimise-store = true;
substituters = [
"https://cache.nixos.org"
"https://niri.cachix.org"
@ -26,13 +27,12 @@
dates = "weekly";
options = "--delete-older-than 7d";
};
optimise.automatic = true;
};
zramSwap = {
enable = true;
algorithm = "zstd";
memoryPercent = 100;
memoryPercent = 300;
};
services.xserver.enable = false;

View file

@ -12,8 +12,6 @@ in {
environment.systemPackages = with pkgs-unstable; [
# CLI utilities
gcc
python3
uv
fzf
just
tree-sitter

View file

@ -7,12 +7,16 @@
fonts.packages = [ pkgs.nerd-fonts.jetbrains-mono ];
programs.niri.enable = true;
programs.niri = {
enable = true;
package = pkgs.niri;
};
programs.gpu-screen-recorder.enable = true;
environment.systemPackages = with pkgs; [
quickshell-niri
waybar
fuzzel
swaylock
swayidle
@ -21,6 +25,7 @@
slurp
wl-clipboard
xdg-desktop-portal-gnome
kdePackages.xdg-desktop-portal-kde
xwayland-satellite
gpu-screen-recorder-gtk
libnotify
@ -30,8 +35,10 @@
xdg.portal = {
enable = true;
extraPortals = [ pkgs.xdg-desktop-portal-gnome ];
config.common.default = [ "gnome" ];
extraPortals = [
pkgs.xdg-desktop-portal-gnome
pkgs.kdePackages.xdg-desktop-portal-kde
];
};
security.pam.services.swaylock = {};

View file

@ -9,8 +9,6 @@ Item {
required property string summary
required property string body
required property string appIcon
required property string image
required property string desktopEntry
required property int urgency
required property var timestamp
@ -54,14 +52,9 @@ Item {
id: iconImg
anchors.fill: parent
anchors.margins: 5
source: {
if (root.image) return root.image
if (!root.appIcon) return ""
if (root.appIcon.startsWith("/")) return root.appIcon
return Quickshell.iconPath(root.appIcon, true) || ""
}
source: root.appIcon
fillMode: Image.PreserveAspectFit
visible: status === Image.Ready && source !== ""
visible: status === Image.Ready
smooth: true
}
@ -105,7 +98,6 @@ Item {
Text {
visible: root.body.length > 0
text: root.body
textFormat: Text.StyledText
color: Theme.fg2
font.pixelSize: Theme.fontSizeSmall
font.family: Theme.font
@ -166,7 +158,7 @@ Item {
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
NotificationsState.invokeDefault(root.nid, root.desktopEntry || root.appName)
NotificationsState.invokeDefault(root.nid)
root.dismissed()
}
}

View file

@ -147,8 +147,6 @@ PanelWindow {
summary: model.summary
body: model.body
appIcon: model.appIcon
image: model.image
desktopEntry: model.desktopEntry
urgency: model.urgency
timestamp: model.timestamp

View file

@ -60,16 +60,9 @@ PanelWindow {
id: tIcon
anchors.fill: parent
anchors.margins: 5
// Priority: notification image (avatar/screenshot) > app icon.
// appIcon can be a path or a theme name; iconPath() resolves theme names.
source: {
if (model.image) return model.image
if (!model.appIcon) return ""
if (model.appIcon.startsWith("/")) return model.appIcon
return Quickshell.iconPath(model.appIcon, true) || ""
}
source: model.appIcon
fillMode: Image.PreserveAspectFit
visible: status === Image.Ready && source !== ""
visible: status === Image.Ready
smooth: true
}
@ -113,7 +106,6 @@ PanelWindow {
Text {
visible: model.body.length > 0
text: model.body
textFormat: Text.StyledText
color: Theme.fg2
font.pixelSize: Theme.fontSizeSmall
font.family: Theme.font
@ -169,7 +161,7 @@ PanelWindow {
anchors.rightMargin: 34
cursorShape: Qt.PointingHandCursor
onClicked: {
NotificationsState.invokeDefault(model.tid, model.desktopEntry || model.appName)
NotificationsState.invokeDefault(model.tid)
NotificationsState.dismissToast(model.tid)
}
}

View file

@ -1,6 +1,5 @@
pragma Singleton
import Quickshell
import Quickshell.Io
import Quickshell.Services.Notifications
import QtQuick
@ -11,8 +10,7 @@ Singleton {
property int unreadCount: 0
property ListModel notifications: ListModel {}
property var _refs: ({}) // maps nid live Notification object
property var _pids: ({}) // maps nid sender process PID (from D-Bus monitor)
property var _refs: ({}) // maps nid to the live Notification object
property ListModel toasts: ListModel {}
@ -38,43 +36,6 @@ Singleton {
}
}
// Executes a niri focus-window command
Process {
id: _focusProc
}
// Monitors D-Bus for Notify calls and outputs "nid:sender_pid" so we can
// walk /proc and focus the exact window that sent each notification.
Process {
id: _pidMonitor
command: ["bash", String(Qt.resolvedUrl("notif-pid-monitor.sh")).replace("file://", "")]
running: true
stdout: SplitParser {
onRead: line => {
const parts = line.split(":")
if (parts.length !== 2) return
const nid = parseInt(parts[0])
const pid = parts[1].trim()
if (!isNaN(nid) && pid) {
const p = root._pids
p[nid] = pid
root._pids = p
}
}
}
onExited: (exitCode, exitStatus) => _pidRestartTimer.start()
}
// Restarts the D-Bus monitor if dbus-monitor exits unexpectedly (e.g. session bus restart).
Timer {
id: _pidRestartTimer
interval: 1000
repeat: false
onTriggered: { _pidMonitor.running = false; _pidMonitor.running = true }
}
NotificationServer {
keepOnReload: true
@ -88,8 +49,6 @@ Singleton {
summary: notif.summary || "",
body: notif.body || "",
appIcon: notif.appIcon || "",
image: notif.image || "",
desktopEntry: notif.desktopEntry || "",
urgency: notif.urgency || 1,
timestamp: ts,
day: day
@ -107,8 +66,6 @@ Singleton {
summary: notif.summary || "",
body: notif.body || "",
appIcon: notif.appIcon || "",
image: notif.image || "",
desktopEntry: notif.desktopEntry || "",
urgency: notif.urgency || 1,
progress: 1.0
})
@ -125,9 +82,6 @@ Singleton {
delete r[entry.nid]
_refs = r
}
const p = _pids
delete p[entry.nid]
_pids = p
notifications.remove(index, 1)
}
@ -138,65 +92,22 @@ Singleton {
}
notifications.clear()
_refs = {}
_pids = {}
unreadCount = 0
}
// Invokes the D-Bus action (tells the app which chat/content to open) and
// always also focuses the window via niri IPC.
// invoke() alone cannot steal focus on Wayland without an XDG activation token;
// niri IPC bypasses that restriction because the compositor grants focus directly.
// entry = desktopEntry || appName, passed from the model so it works even after
// a hot-reload clears _refs.
function invokeDefault(nid, entry) {
// Send ActionInvoked to the app if we still have a live reference.
// Invokes the "default" action on a notification, falling back to the first action
function invokeDefault(nid) {
const ref = _refs[nid]
if (ref) {
const actions = ref.actions ? Array.from(ref.actions) : []
if (actions.length > 0) {
let found = false
if (!ref) return
const actions = ref.actions
if (!actions || actions.length === 0) return
for (let i = 0; i < actions.length; i++) {
if (actions[i].identifier === "default") {
try { actions[i].invoke() } catch (_) {}
found = true
break
}
}
if (!found) {
try { actions[0].invoke() } catch (_) {}
}
}
}
// Bring the window to front via niri IPC.
const senderPid = _pids[nid]
if (senderPid) {
// Walk /proc from the sender PID upward until we find a PID that owns
// a niri window, then focus that window. This correctly handles multiple
// instances of the same app (e.g. several kitty terminals).
_focusProc.command = [
"bash", "-c",
'P=' + senderPid + '; ' +
'while [ "$P" -gt 1 ]; do ' +
' WIN=$(niri msg --json windows | jq -r --arg p "$P" \'.[] | select(.pid == ($p | tonumber)) | .id // empty\'); ' +
' if [ -n "$WIN" ]; then niri msg action focus-window --id "$WIN"; exit 0; fi; ' +
' P=$(awk \'/^PPid:/{print $2}\' /proc/"$P"/status 2>/dev/null); ' +
' [ -z "$P" ] && exit 1; ' +
'done'
]
} else if (entry) {
// Fallback when no PID was captured: match by app_id, pick most recently focused.
_focusProc.command = [
"bash", "-c",
'WID=$(niri msg --json windows | jq -r --arg id "' + entry + '" ' +
'\'[.[] | select(.app_id == $id)] | sort_by(.focus_timestamp.secs, .focus_timestamp.nanos) | last | .id // empty\'); ' +
'[ -n "$WID" ] && niri msg action focus-window --id "$WID"'
]
} else {
return
}
_focusProc.running = false
_focusProc.running = true
}
try { actions[0].invoke() } catch (_) {}
}
function dismissToast(tid) {

View file

@ -1,53 +0,0 @@
#!/usr/bin/env bash
# Monitors D-Bus for org.freedesktop.Notifications.Notify method calls.
# For each notification, captures the sender process PID and outputs:
# <notification-id>:<sender-pid>
# Quickshell reads this to know which process originated each notification,
# allowing it to walk the /proc tree and focus the exact window that sent it.
get_pid() {
busctl call org.freedesktop.DBus /org/freedesktop/DBus \
org.freedesktop.DBus GetConnectionUnixProcessID s "$1" 2>/dev/null \
| awk '{print $2}'
}
# Maps serial → pid so concurrent notifications don't clobber each other.
declare -A pending_pids
awaiting_nid=false
pending_pid=""
dbus-monitor --session "dest='org.freedesktop.Notifications'" 2>/dev/null | \
while IFS= read -r line; do
line="${line#"${line%%[![:space:]]*}"}" # trim leading whitespace
if [[ "$line" == *"method call"* && "$line" == *"member=Notify"* ]]; then
if [[ "$line" =~ sender=([^[:space:],;]+) ]]; then sender="${BASH_REMATCH[1]}"; fi
if [[ "$line" =~ serial=([0-9]+) ]]; then serial="${BASH_REMATCH[1]}"; fi
if [[ "$sender" == :* && -n "$serial" ]]; then
pid=$(get_pid "$sender")
if [[ -n "$pid" && "$pid" != "0" ]]; then
pending_pids["$serial"]="$pid"
fi
fi
elif [[ "$line" == *"method return"* ]]; then
if [[ "$line" =~ reply_serial=([0-9]+) ]]; then
reply="${BASH_REMATCH[1]}"
if [[ -n "${pending_pids[$reply]}" ]]; then
awaiting_nid=true
pending_pid="${pending_pids[$reply]}"
unset "pending_pids[$reply]"
fi
fi
elif [[ "$awaiting_nid" == true ]]; then
if [[ "$line" =~ ^uint32[[:space:]]+([0-9]+) ]]; then
echo "${BASH_REMATCH[1]}:${pending_pid}"
awaiting_nid=false
pending_pid=""
elif [[ -n "$line" ]]; then
awaiting_nid=false
fi
fi
done