diff --git a/.mcp.json b/.mcp.json deleted file mode 100644 index ca8c928..0000000 --- a/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "nixos": { - "command": "uvx", - "args": ["mcp-nixos"] - } - } -} diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f72e239..0000000 --- a/CLAUDE.md +++ /dev/null @@ -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.`) — 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 576bd39..2b5fd99 100644 --- a/modules/core.nix +++ b/modules/core.nix @@ -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; diff --git a/modules/dev.nix b/modules/dev.nix index abc0693..6a8e99d 100644 --- a/modules/dev.nix +++ b/modules/dev.nix @@ -12,8 +12,6 @@ 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 cce4922..0df25ea 100644 --- a/modules/niri.nix +++ b/modules/niri.nix @@ -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 @@ -29,9 +34,11 @@ ]; xdg.portal = { - enable = true; - extraPortals = [ pkgs.xdg-desktop-portal-gnome ]; - config.common.default = [ "gnome" ]; + enable = true; + extraPortals = [ + pkgs.xdg-desktop-portal-gnome + pkgs.kdePackages.xdg-desktop-portal-kde + ]; }; security.pam.services.swaylock = {}; diff --git a/quickshell/NotificationItem.qml b/quickshell/NotificationItem.qml index d06de83..082b87d 100644 --- a/quickshell/NotificationItem.qml +++ b/quickshell/NotificationItem.qml @@ -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,15 +52,10 @@ 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) || "" - } - fillMode: Image.PreserveAspectFit - visible: status === Image.Ready && source !== "" - smooth: true + source: root.appIcon + fillMode: Image.PreserveAspectFit + visible: status === Image.Ready + smooth: true } Text { @@ -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() } } diff --git a/quickshell/NotificationPanel.qml b/quickshell/NotificationPanel.qml index 1977ac9..50dafb4 100644 --- a/quickshell/NotificationPanel.qml +++ b/quickshell/NotificationPanel.qml @@ -141,16 +141,14 @@ 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 - image: model.image - desktopEntry: model.desktopEntry - urgency: model.urgency - timestamp: model.timestamp + width: list.width + nid: model.nid + appName: model.appName + summary: model.summary + body: model.body + appIcon: model.appIcon + urgency: model.urgency + timestamp: model.timestamp onDismissed: NotificationsState.dismiss(index) } diff --git a/quickshell/NotificationToast.qml b/quickshell/NotificationToast.qml index 6864c81..068a02d 100644 --- a/quickshell/NotificationToast.qml +++ b/quickshell/NotificationToast.qml @@ -60,17 +60,10 @@ 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) || "" - } - fillMode: Image.PreserveAspectFit - visible: status === Image.Ready && source !== "" - smooth: true + source: model.appIcon + fillMode: Image.PreserveAspectFit + visible: status === Image.Ready + smooth: true } Text { @@ -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) } } diff --git a/quickshell/NotificationsState.qml b/quickshell/NotificationsState.qml index 83516f8..2efa017 100644 --- a/quickshell/NotificationsState.qml +++ b/quickshell/NotificationsState.qml @@ -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 @@ -83,16 +44,14 @@ 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 || "", - image: notif.image || "", - desktopEntry: notif.desktopEntry || "", - urgency: notif.urgency || 1, - timestamp: ts, - day: day + nid: notif.id, + appName: notif.appName || "Unknown", + summary: notif.summary || "", + body: notif.body || "", + appIcon: notif.appIcon || "", + urgency: notif.urgency || 1, + timestamp: ts, + day: day }) const r = root._refs @@ -102,15 +61,13 @@ 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 || "", - image: notif.image || "", - desktopEntry: notif.desktopEntry || "", - urgency: notif.urgency || 1, - progress: 1.0 + tid: notif.id, + appName: notif.appName || "Unknown", + summary: notif.summary || "", + body: notif.body || "", + appIcon: notif.appIcon || "", + 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 - 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 (_) {} - } + 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 } } - - // 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) { diff --git a/quickshell/notif-pid-monitor.sh b/quickshell/notif-pid-monitor.sh deleted file mode 100755 index 3c287b1..0000000 --- a/quickshell/notif-pid-monitor.sh +++ /dev/null @@ -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: -# : -# 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