Compare commits

...

2 commits

Author SHA1 Message Date
bf35832817
fix anti-patterns: niri portals, nix optimise, zram; add dev packages
- niri: remove explicit package override (use niri-flake default), drop
  KDE portal, set xdg.portal.config for deterministic portal resolution,
  remove unused waybar
- core: replace synchronous auto-optimise-store with async
  nix.optimise.automatic, reduce zramSwap.memoryPercent from 300 to 100
- dev: add python3 and uv to system packages
- add .mcp.json (nixos MCP server) and CLAUDE.md
2026-06-25 09:37:50 -03:00
4b0974e74e
experimental notification focus 2026-06-25 08:26:57 -03:00
10 changed files with 314 additions and 57 deletions

8
.mcp.json Normal file
View file

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

94
CLAUDE.md Normal file
View file

@ -0,0 +1,94 @@
# 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,7 +8,6 @@
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"
@ -27,12 +26,13 @@
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 = 300; memoryPercent = 100;
}; };
services.xserver.enable = false; services.xserver.enable = false;

View file

@ -12,6 +12,8 @@ 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,16 +7,12 @@
fonts.packages = [ pkgs.nerd-fonts.jetbrains-mono ]; fonts.packages = [ pkgs.nerd-fonts.jetbrains-mono ];
programs.niri = { programs.niri.enable = true;
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
@ -25,7 +21,6 @@
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
@ -35,10 +30,8 @@
xdg.portal = { xdg.portal = {
enable = true; enable = true;
extraPortals = [ extraPortals = [ pkgs.xdg-desktop-portal-gnome ];
pkgs.xdg-desktop-portal-gnome config.common.default = [ "gnome" ];
pkgs.kdePackages.xdg-desktop-portal-kde
];
}; };
security.pam.services.swaylock = {}; security.pam.services.swaylock = {};

View file

@ -9,6 +9,8 @@ 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
@ -52,9 +54,14 @@ Item {
id: iconImg id: iconImg
anchors.fill: parent anchors.fill: parent
anchors.margins: 5 anchors.margins: 5
source: root.appIcon source: {
if (root.image) return root.image
if (!root.appIcon) return ""
if (root.appIcon.startsWith("/")) return root.appIcon
return Quickshell.iconPath(root.appIcon, true) || ""
}
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
visible: status === Image.Ready visible: status === Image.Ready && source !== ""
smooth: true smooth: true
} }
@ -98,6 +105,7 @@ 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
@ -158,7 +166,7 @@ Item {
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
NotificationsState.invokeDefault(root.nid) NotificationsState.invokeDefault(root.nid, root.desktopEntry || root.appName)
root.dismissed() root.dismissed()
} }
} }

View file

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

View file

@ -60,9 +60,16 @@ PanelWindow {
id: tIcon id: tIcon
anchors.fill: parent anchors.fill: parent
anchors.margins: 5 anchors.margins: 5
source: model.appIcon // 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) || ""
}
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
visible: status === Image.Ready visible: status === Image.Ready && source !== ""
smooth: true smooth: true
} }
@ -106,6 +113,7 @@ 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
@ -161,7 +169,7 @@ PanelWindow {
anchors.rightMargin: 34 anchors.rightMargin: 34
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
NotificationsState.invokeDefault(model.tid) NotificationsState.invokeDefault(model.tid, model.desktopEntry || model.appName)
NotificationsState.dismissToast(model.tid) NotificationsState.dismissToast(model.tid)
} }
} }

View file

@ -1,5 +1,6 @@
pragma Singleton pragma Singleton
import Quickshell import Quickshell
import Quickshell.Io
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
import QtQuick import QtQuick
@ -10,7 +11,8 @@ Singleton {
property int unreadCount: 0 property int unreadCount: 0
property ListModel notifications: ListModel {} property ListModel notifications: ListModel {}
property var _refs: ({}) // maps nid to the live Notification object property var _refs: ({}) // maps nid live Notification object
property var _pids: ({}) // maps nid sender process PID (from D-Bus monitor)
property ListModel toasts: ListModel {} property ListModel toasts: ListModel {}
@ -36,6 +38,43 @@ 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
@ -49,6 +88,8 @@ Singleton {
summary: notif.summary || "", summary: notif.summary || "",
body: notif.body || "", body: notif.body || "",
appIcon: notif.appIcon || "", appIcon: notif.appIcon || "",
image: notif.image || "",
desktopEntry: notif.desktopEntry || "",
urgency: notif.urgency || 1, urgency: notif.urgency || 1,
timestamp: ts, timestamp: ts,
day: day day: day
@ -66,6 +107,8 @@ Singleton {
summary: notif.summary || "", summary: notif.summary || "",
body: notif.body || "", body: notif.body || "",
appIcon: notif.appIcon || "", appIcon: notif.appIcon || "",
image: notif.image || "",
desktopEntry: notif.desktopEntry || "",
urgency: notif.urgency || 1, urgency: notif.urgency || 1,
progress: 1.0 progress: 1.0
}) })
@ -82,6 +125,9 @@ 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)
} }
@ -92,22 +138,65 @@ Singleton {
} }
notifications.clear() notifications.clear()
_refs = {} _refs = {}
_pids = {}
unreadCount = 0 unreadCount = 0
} }
// Invokes the "default" action on a notification, falling back to the first action // Invokes the D-Bus action (tells the app which chat/content to open) and
function invokeDefault(nid) { // 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.
const ref = _refs[nid] const ref = _refs[nid]
if (!ref) return if (ref) {
const actions = ref.actions const actions = ref.actions ? Array.from(ref.actions) : []
if (!actions || actions.length === 0) return if (actions.length > 0) {
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 (_) {}
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 return
} }
} _focusProc.running = false
try { actions[0].invoke() } catch (_) {} _focusProc.running = true
} }
function dismissToast(tid) { function dismissToast(tid) {

53
quickshell/notif-pid-monitor.sh Executable file
View file

@ -0,0 +1,53 @@
#!/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