From 4b0974e74e8217028d1be351d8bdc81cb2f645de Mon Sep 17 00:00:00 2001 From: Zoty Date: Thu, 25 Jun 2026 08:26:57 -0300 Subject: [PATCH 1/2] experimental notification focus --- quickshell/NotificationItem.qml | 18 ++-- quickshell/NotificationPanel.qml | 18 ++-- quickshell/NotificationToast.qml | 18 ++-- quickshell/NotificationsState.qml | 141 ++++++++++++++++++++++++------ quickshell/notif-pid-monitor.sh | 53 +++++++++++ 5 files changed, 204 insertions(+), 44 deletions(-) create mode 100755 quickshell/notif-pid-monitor.sh diff --git a/quickshell/NotificationItem.qml b/quickshell/NotificationItem.qml index 082b87d..d06de83 100644 --- a/quickshell/NotificationItem.qml +++ b/quickshell/NotificationItem.qml @@ -9,6 +9,8 @@ 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 @@ -52,10 +54,15 @@ Item { id: iconImg anchors.fill: parent anchors.margins: 5 - source: root.appIcon - fillMode: Image.PreserveAspectFit - visible: status === Image.Ready - smooth: true + 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 + visible: status === Image.Ready && source !== "" + smooth: true } Text { @@ -98,6 +105,7 @@ Item { Text { visible: root.body.length > 0 text: root.body + textFormat: Text.StyledText color: Theme.fg2 font.pixelSize: Theme.fontSizeSmall font.family: Theme.font @@ -158,7 +166,7 @@ Item { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - NotificationsState.invokeDefault(root.nid) + NotificationsState.invokeDefault(root.nid, root.desktopEntry || root.appName) root.dismissed() } } diff --git a/quickshell/NotificationPanel.qml b/quickshell/NotificationPanel.qml index 50dafb4..1977ac9 100644 --- a/quickshell/NotificationPanel.qml +++ b/quickshell/NotificationPanel.qml @@ -141,14 +141,16 @@ PanelWindow { required property var model required property int index - width: list.width - nid: model.nid - appName: model.appName - summary: model.summary - body: model.body - appIcon: model.appIcon - urgency: model.urgency - timestamp: model.timestamp + width: list.width + nid: model.nid + appName: model.appName + summary: model.summary + body: model.body + appIcon: model.appIcon + image: model.image + desktopEntry: model.desktopEntry + urgency: model.urgency + timestamp: model.timestamp onDismissed: NotificationsState.dismiss(index) } diff --git a/quickshell/NotificationToast.qml b/quickshell/NotificationToast.qml index 068a02d..6864c81 100644 --- a/quickshell/NotificationToast.qml +++ b/quickshell/NotificationToast.qml @@ -60,10 +60,17 @@ PanelWindow { id: tIcon anchors.fill: parent anchors.margins: 5 - source: model.appIcon - fillMode: Image.PreserveAspectFit - visible: status === Image.Ready - smooth: true + // 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 + visible: status === Image.Ready && source !== "" + smooth: true } Text { @@ -106,6 +113,7 @@ PanelWindow { Text { visible: model.body.length > 0 text: model.body + textFormat: Text.StyledText color: Theme.fg2 font.pixelSize: Theme.fontSizeSmall font.family: Theme.font @@ -161,7 +169,7 @@ PanelWindow { anchors.rightMargin: 34 cursorShape: Qt.PointingHandCursor onClicked: { - NotificationsState.invokeDefault(model.tid) + NotificationsState.invokeDefault(model.tid, model.desktopEntry || model.appName) NotificationsState.dismissToast(model.tid) } } diff --git a/quickshell/NotificationsState.qml b/quickshell/NotificationsState.qml index 2efa017..83516f8 100644 --- a/quickshell/NotificationsState.qml +++ b/quickshell/NotificationsState.qml @@ -1,5 +1,6 @@ pragma Singleton import Quickshell +import Quickshell.Io import Quickshell.Services.Notifications import QtQuick @@ -10,7 +11,8 @@ Singleton { property int unreadCount: 0 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 {} @@ -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 { keepOnReload: true @@ -44,14 +83,16 @@ Singleton { const day = root._dayLabel(ts) root.notifications.insert(0, { - nid: notif.id, - appName: notif.appName || "Unknown", - summary: notif.summary || "", - body: notif.body || "", - appIcon: notif.appIcon || "", - urgency: notif.urgency || 1, - timestamp: ts, - day: day + nid: notif.id, + appName: notif.appName || "Unknown", + summary: notif.summary || "", + body: notif.body || "", + appIcon: notif.appIcon || "", + image: notif.image || "", + desktopEntry: notif.desktopEntry || "", + urgency: notif.urgency || 1, + timestamp: ts, + day: day }) const r = root._refs @@ -61,13 +102,15 @@ Singleton { if (!root.panelOpen) root.unreadCount++ root.toasts.insert(0, { - tid: notif.id, - appName: notif.appName || "Unknown", - summary: notif.summary || "", - body: notif.body || "", - appIcon: notif.appIcon || "", - urgency: notif.urgency || 1, - progress: 1.0 + tid: notif.id, + appName: notif.appName || "Unknown", + summary: notif.summary || "", + body: notif.body || "", + appIcon: notif.appIcon || "", + image: notif.image || "", + desktopEntry: notif.desktopEntry || "", + urgency: notif.urgency || 1, + progress: 1.0 }) } } @@ -82,6 +125,9 @@ Singleton { delete r[entry.nid] _refs = r } + const p = _pids + delete p[entry.nid] + _pids = p notifications.remove(index, 1) } @@ -92,22 +138,65 @@ Singleton { } notifications.clear() _refs = {} + _pids = {} unreadCount = 0 } - // Invokes the "default" action on a notification, falling back to the first action - function invokeDefault(nid) { + // 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. const ref = _refs[nid] - 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 (_) {} - return + if (ref) { + const actions = ref.actions ? Array.from(ref.actions) : [] + if (actions.length > 0) { + let found = false + 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 (_) {} + } } } - 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) { diff --git a/quickshell/notif-pid-monitor.sh b/quickshell/notif-pid-monitor.sh new file mode 100755 index 0000000..3c287b1 --- /dev/null +++ b/quickshell/notif-pid-monitor.sh @@ -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: +# : +# 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 From bf35832817701a749c3c951e90a129bc31606a3d Mon Sep 17 00:00:00 2001 From: Zoty Date: Thu, 25 Jun 2026 09:37:50 -0300 Subject: [PATCH 2/2] 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 --- .mcp.json | 8 +++++ CLAUDE.md | 94 ++++++++++++++++++++++++++++++++++++++++++++++++ modules/core.nix | 4 +-- modules/dev.nix | 2 ++ modules/niri.nix | 15 +++----- 5 files changed, 110 insertions(+), 13 deletions(-) create mode 100644 .mcp.json create mode 100644 CLAUDE.md diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..ca8c928 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "nixos": { + "command": "uvx", + "args": ["mcp-nixos"] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f72e239 --- /dev/null +++ b/CLAUDE.md @@ -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.`) — named, composable feature modules. Each aspect can define `nixos` (system config) and/or `homeManager` (user config) blocks. +- **Hosts** (`den.hosts.x86_64-linux.`) — 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/-hardware.nix`. +2. Add `den.hosts.x86_64-linux.` in `modules/den.nix`. +3. Define a `den.aspects.` 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. diff --git a/modules/core.nix b/modules/core.nix index 2b5fd99..576bd39 100644 --- a/modules/core.nix +++ b/modules/core.nix @@ -8,7 +8,6 @@ nix = { settings = { experimental-features = [ "nix-command" "flakes" ]; - auto-optimise-store = true; substituters = [ "https://cache.nixos.org" "https://niri.cachix.org" @@ -27,12 +26,13 @@ dates = "weekly"; options = "--delete-older-than 7d"; }; + optimise.automatic = true; }; zramSwap = { enable = true; algorithm = "zstd"; - memoryPercent = 300; + memoryPercent = 100; }; services.xserver.enable = false; diff --git a/modules/dev.nix b/modules/dev.nix index 6a8e99d..abc0693 100644 --- a/modules/dev.nix +++ b/modules/dev.nix @@ -12,6 +12,8 @@ in { environment.systemPackages = with pkgs-unstable; [ # CLI utilities gcc + python3 + uv fzf just tree-sitter diff --git a/modules/niri.nix b/modules/niri.nix index 0df25ea..cce4922 100644 --- a/modules/niri.nix +++ b/modules/niri.nix @@ -7,16 +7,12 @@ fonts.packages = [ pkgs.nerd-fonts.jetbrains-mono ]; - programs.niri = { - enable = true; - package = pkgs.niri; - }; + programs.niri.enable = true; programs.gpu-screen-recorder.enable = true; environment.systemPackages = with pkgs; [ quickshell-niri - waybar fuzzel swaylock swayidle @@ -25,7 +21,6 @@ slurp wl-clipboard xdg-desktop-portal-gnome - kdePackages.xdg-desktop-portal-kde xwayland-satellite gpu-screen-recorder-gtk libnotify @@ -34,11 +29,9 @@ ]; xdg.portal = { - enable = true; - extraPortals = [ - pkgs.xdg-desktop-portal-gnome - pkgs.kdePackages.xdg-desktop-portal-kde - ]; + enable = true; + extraPortals = [ pkgs.xdg-desktop-portal-gnome ]; + config.common.default = [ "gnome" ]; }; security.pam.services.swaylock = {};