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

View file

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

View file

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

View file

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

View file

@ -141,16 +141,14 @@ PanelWindow {
required property var model required property var model
required property int index required property int index
width: list.width width: list.width
nid: model.nid nid: model.nid
appName: model.appName appName: model.appName
summary: model.summary summary: model.summary
body: model.body body: model.body
appIcon: model.appIcon appIcon: model.appIcon
image: model.image urgency: model.urgency
desktopEntry: model.desktopEntry timestamp: model.timestamp
urgency: model.urgency
timestamp: model.timestamp
onDismissed: NotificationsState.dismiss(index) onDismissed: NotificationsState.dismiss(index)
} }

View file

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

View file

@ -1,6 +1,5 @@
pragma Singleton pragma Singleton
import Quickshell import Quickshell
import Quickshell.Io
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
import QtQuick import QtQuick
@ -11,8 +10,7 @@ Singleton {
property int unreadCount: 0 property int unreadCount: 0
property ListModel notifications: ListModel {} property ListModel notifications: ListModel {}
property var _refs: ({}) // maps nid live Notification object property var _refs: ({}) // maps nid to the live Notification object
property var _pids: ({}) // maps nid sender process PID (from D-Bus monitor)
property ListModel toasts: ListModel {} 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 { NotificationServer {
keepOnReload: true keepOnReload: true
@ -83,16 +44,14 @@ Singleton {
const day = root._dayLabel(ts) const day = root._dayLabel(ts)
root.notifications.insert(0, { root.notifications.insert(0, {
nid: notif.id, nid: notif.id,
appName: notif.appName || "Unknown", appName: notif.appName || "Unknown",
summary: notif.summary || "", summary: notif.summary || "",
body: notif.body || "", body: notif.body || "",
appIcon: notif.appIcon || "", appIcon: notif.appIcon || "",
image: notif.image || "", urgency: notif.urgency || 1,
desktopEntry: notif.desktopEntry || "", timestamp: ts,
urgency: notif.urgency || 1, day: day
timestamp: ts,
day: day
}) })
const r = root._refs const r = root._refs
@ -102,15 +61,13 @@ Singleton {
if (!root.panelOpen) root.unreadCount++ if (!root.panelOpen) root.unreadCount++
root.toasts.insert(0, { root.toasts.insert(0, {
tid: notif.id, tid: notif.id,
appName: notif.appName || "Unknown", appName: notif.appName || "Unknown",
summary: notif.summary || "", summary: notif.summary || "",
body: notif.body || "", body: notif.body || "",
appIcon: notif.appIcon || "", appIcon: notif.appIcon || "",
image: notif.image || "", urgency: notif.urgency || 1,
desktopEntry: notif.desktopEntry || "", progress: 1.0
urgency: notif.urgency || 1,
progress: 1.0
}) })
} }
} }
@ -125,9 +82,6 @@ Singleton {
delete r[entry.nid] delete r[entry.nid]
_refs = r _refs = r
} }
const p = _pids
delete p[entry.nid]
_pids = p
notifications.remove(index, 1) notifications.remove(index, 1)
} }
@ -138,65 +92,22 @@ Singleton {
} }
notifications.clear() notifications.clear()
_refs = {} _refs = {}
_pids = {}
unreadCount = 0 unreadCount = 0
} }
// Invokes the D-Bus action (tells the app which chat/content to open) and // Invokes the "default" action on a notification, falling back to the first action
// always also focuses the window via niri IPC. function invokeDefault(nid) {
// 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.
const ref = _refs[nid] const ref = _refs[nid]
if (ref) { if (!ref) return
const actions = ref.actions ? Array.from(ref.actions) : [] const actions = ref.actions
if (actions.length > 0) { if (!actions || actions.length === 0) return
let found = false for (let i = 0; i < actions.length; i++) {
for (let i = 0; i < actions.length; i++) { if (actions[i].identifier === "default") {
if (actions[i].identifier === "default") { try { actions[i].invoke() } catch (_) {}
try { actions[i].invoke() } catch (_) {} return
found = true
break
}
}
if (!found) {
try { actions[0].invoke() } catch (_) {}
}
} }
} }
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
} }
function dismissToast(tid) { 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