Compare commits
2 commits
38db696ee8
...
bf35832817
| Author | SHA1 | Date | |
|---|---|---|---|
| bf35832817 | |||
| 4b0974e74e |
10 changed files with 314 additions and 57 deletions
8
.mcp.json
Normal file
8
.mcp.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"nixos": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["mcp-nixos"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
CLAUDE.md
Normal file
94
CLAUDE.md
Normal 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.
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = {};
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
53
quickshell/notif-pid-monitor.sh
Executable 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue