From ddbc8929e468b82984fbfb4c25e678edb0d8e608 Mon Sep 17 00:00:00 2001 From: rope Date: Sat, 13 Jun 2026 17:54:37 +0100 Subject: [PATCH 01/61] alerting: silence per-ban crowdsec pushes; ntfy alert on service down/recovery - crowdsec.nix: drop the ntfy notifications (one push per ban was constant noise on the WAN-exposed box); bans still happen silently - service-health.nix: OnFailure=notify-failure@%n on 16 core units sends an ntfy 'down' push when a unit truly fails (after exhausting Restart=), then a 'recovered' push when it comes back. Shares /var/secrets/ntfy-url. Co-Authored-By: Claude Fable 5 --- common.nix | 1 + services/crowdsec.nix | 64 +++--------------------------- services/service-health.nix | 77 +++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 58 deletions(-) create mode 100644 services/service-health.nix diff --git a/common.nix b/common.nix index 48cb9c8..3bd1a75 100644 --- a/common.nix +++ b/common.nix @@ -37,6 +37,7 @@ ./services/adguard.nix ./services/router.nix ./services/crowdsec.nix + ./services/service-health.nix ./services/sabnzbd.nix ./services/forgejo-runner.nix ./services/code-server.nix diff --git a/services/crowdsec.nix b/services/crowdsec.nix index 355f9d9..d35905c 100644 --- a/services/crowdsec.nix +++ b/services/crowdsec.nix @@ -9,37 +9,10 @@ # 2. Delete ../modules/crowdsec/ and the disabledModules + imports lines below # 3. The settings/option API is the same as the PR's, so config below is forward-compatible # -# Before first deploy, create /var/secrets/ntfy-url with your topic URL: -# echo 'https://ntfy.sh/nordhammer-' | sudo tee /var/secrets/ntfy-url -# sudo chmod 600 /var/secrets/ntfy-url +# CrowdSec bans silently — no ntfy pushes (they were constant noise). +# The /var/secrets/ntfy-url topic is used by services/service-health.nix instead. { config, lib, pkgs, ... }: let - # The real URL is injected at service start (see ExecStartPre below) — - # eval-time builtins.readFile can't see /var/secrets under pure flake - # evaluation, which is how the `update` alias builds. - ntfyUrlPlaceholder = "@NTFY_URL@"; - - # The module renders settings.notifications into /etc/crowdsec/notifications/ - # as a symlink into /etc/static (the store). Re-render it from the static - # source with the secret substituted on every service start; nixos-rebuild - # restores the symlink on activation, so this never goes stale. - injectNtfyUrl = pkgs.writeShellScript "crowdsec-inject-ntfy-url" '' - set -euo pipefail - src=/etc/static/crowdsec/notifications/0-nixos-generated.yaml - dst=/etc/crowdsec/notifications/0-nixos-generated.yaml - secret=/var/secrets/ntfy-url - if [ ! -f "$secret" ]; then - echo "WARNING: $secret not found; ntfy notifications will not work" >&2 - exit 0 - fi - url=$(${pkgs.coreutils}/bin/tr -d '\n' < "$secret") - tmp=$(${pkgs.coreutils}/bin/mktemp "$dst.XXXXXX") - ${pkgs.gnused}/bin/sed "s|${ntfyUrlPlaceholder}|$url|g" "$src" > "$tmp" - ${pkgs.coreutils}/bin/chmod 600 "$tmp" - ${pkgs.coreutils}/bin/chown crowdsec:crowdsec "$tmp" - ${pkgs.coreutils}/bin/mv "$tmp" "$dst" - ''; - # nixpkgs only builds the agent + cscli; the new module also expects # notification plugins at $out/libexec/crowdsec/plugins/. Compile them # from the same source tree (cmd/notification-*) and move them there. @@ -142,52 +115,27 @@ in } ]; - # Push notifications via ntfy.sh - notifications = [ - { - name = "ntfy_http"; - type = "http"; - log_level = "info"; - url = ntfyUrlPlaceholder; - method = "POST"; - headers = { - Title = "CrowdSec alert"; - Priority = "high"; - Tags = "rotating_light"; - }; - format = '' - {{range . -}} - {{.Scenario}} from {{.Source.IP}} ({{.Source.Cn}}) — {{len .Decisions}} decision(s) taken - {{end -}} - ''; - } - ]; - - # Override default profiles to attach the ntfy notifier + # Profiles set ban duration to 4h. No ntfy notifications: a push per + # ban was constant noise on a WAN-exposed box. ntfy is now reserved + # for service-down alerts (see services/service-health.nix); CrowdSec + # still bans silently. profiles = [ { name = "default_ip_remediation"; filters = [ "Alert.Remediation == true && Alert.GetScope() == 'Ip'" ]; decisions = [{ type = "ban"; duration = "4h"; }]; - notifications = [ "ntfy_http" ]; on_success = "break"; } { name = "default_range_remediation"; filters = [ "Alert.Remediation == true && Alert.GetScope() == 'Range'" ]; decisions = [{ type = "ban"; duration = "4h"; }]; - notifications = [ "ntfy_http" ]; on_success = "break"; } ]; }; }; - # Inject the ntfy topic URL into the rendered notification config before - # every start. "+" runs the script with full privileges (it reads the - # root-owned secret and replaces a root-owned /etc symlink). - systemd.services.crowdsec.serviceConfig.ExecStartPre = [ "+${injectNtfyUrl}" ]; - # Firewall bouncer enforces decisions via nftables; auto-registers with LAPI services.crowdsec-firewall-bouncer = { enable = true; diff --git a/services/service-health.nix b/services/service-health.nix new file mode 100644 index 0000000..9dcd112 --- /dev/null +++ b/services/service-health.nix @@ -0,0 +1,77 @@ +# services/service-health.nix — ntfy alert when a watched systemd unit fails, +# and again when it recovers. Replaces the noisy per-ban CrowdSec pushes +# (silenced in services/crowdsec.nix); both share the /var/secrets/ntfy-url topic. +# +# Detection is event-driven: each watched unit gets OnFailure=notify-failure@%n. +# OnFailure fires only once a unit truly enters "failed" state — i.e. after it +# has exhausted its Restart= attempts — so transient restarts stay silent and +# you're only paged when a service has genuinely given up. The handler sends a +# "down" push, then waits for the unit to come back and sends "recovered". +# +# Requires /var/secrets/ntfy-url (the same topic file CrowdSec used): +# echo 'https://ntfy.sh/your-topic' | sudo tee /var/secrets/ntfy-url +# sudo chmod 600 /var/secrets/ntfy-url +{ config, lib, pkgs, ... }: +let + # Core media + infra units to page on. All verified to exist on the box; + # adding a name that isn't a real unit would create a stray stub service. + watched = [ + "jellyfin" "sonarr" "radarr" "prowlarr" "bazarr" + "qbittorrent-nox" "sabnzbd" "authelia-main" "nginx" + "adguardhome" "crowdsec" "frigate" "go2rtc" + "homepage-dashboard" "cloudflare-dyndns" "gitea-runner-default" + ]; + + # Reads the topic at runtime (pure flake eval can't see /var/secrets). + # $1 = the failed unit's full name, e.g. "jellyfin.service". + notify = pkgs.writeShellScript "service-health-notify" '' + set -uo pipefail + unit="$1" + name="''${unit%.service}" + host="${config.networking.hostName}" + secret=/var/secrets/ntfy-url + if [ ! -f "$secret" ]; then + echo "service-health: $secret missing; cannot notify" >&2 + exit 0 + fi + url=$(${pkgs.coreutils}/bin/tr -d '\n' < "$secret") + + post() { # title priority tags body + ${pkgs.curl}/bin/curl -fsS --max-time 10 \ + -H "Title: $1" -H "Priority: $2" -H "Tags: $3" \ + -d "$4" "$url" >/dev/null 2>&1 || true + } + + post "Service down" high rotating_light "$name failed on $host" + + # Wait for recovery: up to 2h, polling every 20s. + for _ in $(${pkgs.coreutils}/bin/seq 1 360); do + ${pkgs.coreutils}/bin/sleep 20 + if ${pkgs.systemd}/bin/systemctl is-active --quiet "$unit"; then + post "Service recovered" default white_check_mark "$name is running again on $host" + exit 0 + fi + done + ''; +in +{ + config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { + + systemd.services = lib.mkMerge [ + # Templated handler: %i is the failed unit's full name (jellyfin.service). + { + "notify-failure@" = { + description = "ntfy alert: %i failed"; + serviceConfig = { + Type = "simple"; + ExecStart = "${notify} %i"; + }; + }; + } + # Wire OnFailure onto each watched unit (merges with its existing config). + (lib.genAttrs watched (_: { + unitConfig.OnFailure = [ "notify-failure@%n.service" ]; + })) + ]; + }; +} From faa345d01629f8964589cd088dcd0d6868750df2 Mon Sep 17 00:00:00 2001 From: rope Date: Sat, 13 Jun 2026 19:25:06 +0100 Subject: [PATCH 02/61] quickshell: match calendar notification styling to the toast (shared defaults) Co-Authored-By: Claude Fable 5 --- settings/quickshell.nix | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 77844c6..fd938f1 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -420,12 +420,12 @@ in component NotifContent: Column { id: _nc property var notif - property int summarySize: 11 - property int bodySize: 10 - property int bodyLines: 2 - property color chipBg: Theme.base02 - property color chipBgHover: Theme.base03 - property color chipBorder: Theme.base03 + property int summarySize: 12 + property int bodySize: 11 + property int bodyLines: 3 + property color chipBg: Theme.base01 + property color chipBgHover: Theme.base02 + property color chipBorder: Theme.base02 signal actionInvoked() spacing: 2 @@ -2544,7 +2544,7 @@ in id: notifItem required property var modelData width: parent.width - height: ncBody.height + 12 + height: ncBody.height + 16 radius: Theme.radiusSmall color: Theme.base02 @@ -2554,17 +2554,17 @@ in anchors.left: parent.left anchors.right: dismissBtn.left anchors.top: parent.top - anchors.margins: 6 + anchors.margins: 8 } SIcon { id: dismissBtn anchors.right: parent.right anchors.top: parent.top - anchors.margins: 6 + anchors.margins: 8 text: "close" color: dismissMa.containsMouse ? Theme.base05 : Theme.base03 - font.pixelSize: 14 + font.pixelSize: 15 MouseArea { id: dismissMa anchors.fill: parent @@ -2677,12 +2677,6 @@ in anchors.right: toastDismiss.left anchors.top: parent.top anchors.margins: 8 - summarySize: 12 - bodySize: 11 - bodyLines: 3 - chipBg: Theme.base01 - chipBgHover: Theme.base02 - chipBorder: Theme.base02 onActionInvoked: toastItem.dismiss() } From ad70441589fdb895b946befb9bb1fa7e70ca5be6 Mon Sep 17 00:00:00 2001 From: rope Date: Sat, 13 Jun 2026 19:36:11 +0100 Subject: [PATCH 03/61] quickshell: toast notification sits in a base02 card to match the calendar Co-Authored-By: Claude Fable 5 --- settings/quickshell.nix | 59 +++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index fd938f1..ede6aec 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -2663,37 +2663,50 @@ in anchors.horizontalCenter: parent.horizontalCenter anchors.top: parent.top width: 320 - height: toastItem.toastOpen ? toastCol.height + 16 : 0 + height: toastItem.toastOpen ? toastCard.height + 12 : 0 clip: true Behavior on height { NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } } - NotifContent { - id: toastCol - notif: toastItem.currentNotif + // Notification sits in a base02 rounded card, matching + // the calendar list. Inset 6px so the melt panel frames it. + Rectangle { + id: toastCard + anchors.top: parent.top anchors.left: parent.left - anchors.right: toastDismiss.left - anchors.top: parent.top - anchors.margins: 8 - onActionInvoked: toastItem.dismiss() - } - - SIcon { - id: toastDismiss anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 8 - text: "close" - color: toastDismissMa.containsMouse ? Theme.base05 : Theme.base03 - font.pixelSize: 15 - MouseArea { - id: toastDismissMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { toastItem.currentNotif.dismiss(); toastItem.dismiss(); } + anchors.margins: 6 + height: toastCol.height + 16 + radius: Theme.radiusSmall + color: Theme.base02 + + NotifContent { + id: toastCol + notif: toastItem.currentNotif + anchors.left: parent.left + anchors.right: toastDismiss.left + anchors.top: parent.top + anchors.margins: 8 + onActionInvoked: toastItem.dismiss() + } + + SIcon { + id: toastDismiss + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + text: "close" + color: toastDismissMa.containsMouse ? Theme.base05 : Theme.base03 + font.pixelSize: 15 + MouseArea { + id: toastDismissMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { toastItem.currentNotif.dismiss(); toastItem.dismiss(); } + } } } } From 0397a5391b7482f17bde7a22bbb4d9b5f536999a Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Sun, 14 Jun 2026 04:01:01 +0000 Subject: [PATCH 04/61] Update flake inputs --- flake.lock | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/flake.lock b/flake.lock index 79d501a..0a20233 100644 --- a/flake.lock +++ b/flake.lock @@ -71,11 +71,11 @@ "cachyos-kernel": { "flake": false, "locked": { - "lastModified": 1780413908, - "narHash": "sha256-T15bnskj20rdc4vJ55bFF2lVCVR8edilWn0hiYR7vVs=", + "lastModified": 1781018471, + "narHash": "sha256-jQHNMqg2+Iey/WnF+RNvEs+HG3HFVdCX5W/7wne3UIM=", "owner": "CachyOS", "repo": "linux-cachyos", - "rev": "a61f943f5e94b75c5600a2968cb699d0e37945b3", + "rev": "39d9d125940996ed2eb32425ffec7f2de6ac7fba", "type": "github" }, "original": { @@ -87,11 +87,11 @@ "cachyos-kernel-patches": { "flake": false, "locked": { - "lastModified": 1780462466, - "narHash": "sha256-t6c7FTqMB0skEz+4tei5v8GEyL4fRDgx24oW3LrnYiE=", + "lastModified": 1781257359, + "narHash": "sha256-J2/PBS+5u6osnWZUB7UTjLaD+S8diM+6hlOWDoX/+bw=", "owner": "CachyOS", "repo": "kernel-patches", - "rev": "bb41330bd4372672f552beda66712fb70b17f0fa", + "rev": "46b45d26b536195f3ee8bc510b96b7fa47567163", "type": "github" }, "original": { @@ -234,11 +234,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1780771919, - "narHash": "sha256-cbace1ZTWYFG0luPL7OFlUxDh/t9lmPj+Isvg9hLN0k=", + "lastModified": 1781292859, + "narHash": "sha256-etlzg6/H1NuHGTnrxxhvdmhDYQls/AMHk1IrBbNc0WM=", "owner": "xddxdd", "repo": "nix-cachyos-kernel", - "rev": "3d940a534da0ba6bce60e345ff2c9c7b062087fb", + "rev": "c57a7ab46f5e9b4eb32ed74ba6e7cd5bcd3a6e64", "type": "github" }, "original": { @@ -250,11 +250,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1780751787, - "narHash": "sha256-nWR7F46SyrLvN8Ot39XJDpVCswekGakXlOD4KsTYKW0=", + "lastModified": 1781246015, + "narHash": "sha256-C3D5TBgght7LBaqm5oGNRf6CynGl5lGED4jcDw2ZOOk=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "00fa9a692bafc08a86061886f888b843bf7fbdb0", + "rev": "7bd229cbe77d7746d64a1e8c1a6f6cc08f606fc4", "type": "github" }, "original": { @@ -281,11 +281,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1780902259, - "narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=", + "lastModified": 1781216227, + "narHash": "sha256-9mUW6gNwoN2SWc/l0fW4svPNOulXLl8ijqKyeSOGgJE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca", + "rev": "a0374025a863d007d98e3297f6aa46cc3141c2f0", "type": "github" }, "original": { @@ -501,11 +501,11 @@ ] }, "locked": { - "lastModified": 1781173532, - "narHash": "sha256-MwnZpL82aQO1I15JH525vz6REI/OULEAmXDp6cIcgNg=", + "lastModified": 1781353552, + "narHash": "sha256-FHqsgWr7O3XcRNtBO/bjo/nI06EP9JjUb5AEz0Dh3HI=", "owner": "0xc000022070", "repo": "zen-browser-flake", - "rev": "f13e82162fae68af7716147207fa5f868f5ca381", + "rev": "b7562bb6de361d2982a2a6104ee27e4f3169efa5", "type": "github" }, "original": { From bf7d24d7401e7e1bac06a0ba3edb9453376f3f98 Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Mon, 15 Jun 2026 04:01:11 +0000 Subject: [PATCH 05/61] Update flake inputs --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 0a20233..449d4a8 100644 --- a/flake.lock +++ b/flake.lock @@ -501,11 +501,11 @@ ] }, "locked": { - "lastModified": 1781353552, - "narHash": "sha256-FHqsgWr7O3XcRNtBO/bjo/nI06EP9JjUb5AEz0Dh3HI=", + "lastModified": 1781426426, + "narHash": "sha256-yzxJMNgv/sLishhCT9G2lm7W9CjHSlXWkfbWC7vfjqc=", "owner": "0xc000022070", "repo": "zen-browser-flake", - "rev": "b7562bb6de361d2982a2a6104ee27e4f3169efa5", + "rev": "df336067c1a8af3bfce3f0b88b66dc1c57411b4e", "type": "github" }, "original": { From cc9ef378f68bcbc3b3581e1e645233e63dbf4cab Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Tue, 16 Jun 2026 04:00:57 +0000 Subject: [PATCH 06/61] Update flake inputs --- flake.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/flake.lock b/flake.lock index 449d4a8..86b56f1 100644 --- a/flake.lock +++ b/flake.lock @@ -71,11 +71,11 @@ "cachyos-kernel": { "flake": false, "locked": { - "lastModified": 1781018471, - "narHash": "sha256-jQHNMqg2+Iey/WnF+RNvEs+HG3HFVdCX5W/7wne3UIM=", + "lastModified": 1781455283, + "narHash": "sha256-/71qSmWc0vIyGsvtADG8/uHnC/NvXPEY6TXRoDMufeo=", "owner": "CachyOS", "repo": "linux-cachyos", - "rev": "39d9d125940996ed2eb32425ffec7f2de6ac7fba", + "rev": "3bd5b77999c4180ed01bdd0669bfabc5171b090a", "type": "github" }, "original": { @@ -234,11 +234,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1781292859, - "narHash": "sha256-etlzg6/H1NuHGTnrxxhvdmhDYQls/AMHk1IrBbNc0WM=", + "lastModified": 1781463250, + "narHash": "sha256-gYE/0gtSedmA21UWRZ2DA+iXhySh1JGKOssuqejV7cQ=", "owner": "xddxdd", "repo": "nix-cachyos-kernel", - "rev": "c57a7ab46f5e9b4eb32ed74ba6e7cd5bcd3a6e64", + "rev": "4039d20f1495f2c521e7d12723a0c45348b118e8", "type": "github" }, "original": { @@ -250,11 +250,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1781246015, - "narHash": "sha256-C3D5TBgght7LBaqm5oGNRf6CynGl5lGED4jcDw2ZOOk=", + "lastModified": 1781421111, + "narHash": "sha256-2xSTHlKBF5h/tgAeHyQPR/g48qk9ACz7dED3jc3pGKA=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "7bd229cbe77d7746d64a1e8c1a6f6cc08f606fc4", + "rev": "7fe8f446d9475534dc54591ccb5c87c1ce6eaf8b", "type": "github" }, "original": { From 4e3aa498e0d47443c0dc0ccffb45bc9e4ea631c9 Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 16 Jun 2026 10:10:27 +0100 Subject: [PATCH 07/61] hyprland: force Tiny Terraces to tile instead of float Co-Authored-By: Claude Opus 4.8 --- settings/hyprland.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/settings/hyprland.nix b/settings/hyprland.nix index 74a4287..873c32f 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -233,6 +233,12 @@ in workspace = "special silent", }) + -- Tiny Terraces opens floating by default; force it to tile. + hl.window_rule({ + match = { class = "steam_app_3136330" }, + tile = true, + }) + -- Binds local mod = "SUPER" From 792ecb80bb27cee74fbc8ece95cc4b89e4bfca6a Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 16 Jun 2026 15:55:34 +0100 Subject: [PATCH 08/61] quickshell: drop unused Theme tokens (base06, base07, toastBg) Co-Authored-By: Claude Opus 4.8 --- settings/quickshell.nix | 3 --- 1 file changed, 3 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index ede6aec..8a40372 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -153,15 +153,12 @@ in readonly property color base03: "#${c.base03}" readonly property color base04: "#${c.base04}" readonly property color base05: "#${c.base05}" - readonly property color base06: "#${c.base06}" - readonly property color base07: "#${c.base07}" readonly property color base08: "#${c.base08}" readonly property color base0A: "#${c.base0A}" readonly property color base0B: "#${c.base0B}" readonly property color base0C: "#${c.base0C}" readonly property color base0D: "#${c.base0D}" readonly property color barBg: "#B3${c.base00}" - readonly property color toastBg: "#E6${c.base00}" readonly property string fontFamily: "${monoFont}" // Ligature-based icon font: text "volume_up" renders the icon readonly property string iconFont: "Material Symbols Rounded" From 7fc29c82bfdcd5058cf2eba0602d07a67d6831b3 Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Wed, 17 Jun 2026 04:01:05 +0000 Subject: [PATCH 09/61] Update flake inputs --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 86b56f1..9ace5d0 100644 --- a/flake.lock +++ b/flake.lock @@ -501,11 +501,11 @@ ] }, "locked": { - "lastModified": 1781426426, - "narHash": "sha256-yzxJMNgv/sLishhCT9G2lm7W9CjHSlXWkfbWC7vfjqc=", + "lastModified": 1781638617, + "narHash": "sha256-sVJ+oryjyNY7K+EI3Ff5u2b9xDbDCbYLcDhe5r85xw0=", "owner": "0xc000022070", "repo": "zen-browser-flake", - "rev": "df336067c1a8af3bfce3f0b88b66dc1c57411b4e", + "rev": "eab258d9b5c326c49d308fb2f29935101e6d2e02", "type": "github" }, "original": { From 98699b53463dcd2563fec77d5f3cd1cffbb14195 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 11:30:13 +0100 Subject: [PATCH 10/61] quickshell: fix hover/colour bugs, translucent card surfaces - calendar month chevrons: fade from transparent base02, no black flash - power menu: all four buttons use the logout base05 setup - runner results: base01 card segment matching other dropdowns - cards: translucent cardBg so the bar-layer blur shows through Co-Authored-By: Claude Opus 4.8 --- settings/quickshell.nix | 45 +++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 8a40372..717d128 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -159,6 +159,9 @@ in readonly property color base0C: "#${c.base0C}" readonly property color base0D: "#${c.base0D}" readonly property color barBg: "#B3${c.base00}" + // Card surfaces are translucent so the compositor's bar-layer + // blur shows through — a lessened blur over the panel tint. + readonly property color cardBg: "#CC${c.base01}" readonly property string fontFamily: "${monoFont}" // Ligature-based icon font: text "volume_up" renders the icon readonly property string iconFont: "Material Symbols Rounded" @@ -368,7 +371,7 @@ in default property alias cardData: _cardCol.data property alias cardSpacing: _cardCol.spacing radius: Theme.radius - color: Theme.base01 + color: Theme.cardBg implicitHeight: _cardCol.height + 2 * Theme.cardPad Column { id: _cardCol @@ -604,7 +607,7 @@ in width: 48 height: sessionCol.height + 8 radius: Theme.radius - color: Theme.base01 + color: Theme.cardBg // Sliding selection pill — same tech as the power // profile selector; glides between the buttons. @@ -641,11 +644,9 @@ in SIcon { anchors.centerIn: parent text: sessBtn.modelData.icon - // Calendar palette: base05 icons; red only when - // a destructive action is the armed selection - color: sessBtn.selected && sessBtn.modelData.danger - ? Theme.base08 : Theme.base05 - Behavior on color { ColorAnimation { duration: Theme.animFade } } + // All four buttons share the logout + // setup: base05, FILL on selection. + color: Theme.base05 font.pixelSize: 20 font.weight: 600 font.variableAxes: { "FILL": sessBtn.selected ? 1.0 : 0.0 } @@ -674,7 +675,7 @@ in property bool open: false readonly property real panelW: 420 property real targetH: 36 + launcherList.contentHeight - + (launcherList.count > 0 ? 8 : 0) + 24 + + (launcherList.count > 0 ? 8 + 2 * Theme.cardPad : 0) + 24 // Both axes animate: expands from a small point on the // bottom edge (like the top dropdowns' stub seed). property real openH: open ? targetH : 0 @@ -759,10 +760,19 @@ in width: launcherPanel.panelW - 24 spacing: 8 - ListView { - id: launcherList + // Results sit in a base01 card segment, like the + // notification list and the other dropdowns. + Rectangle { width: parent.width - height: contentHeight + height: launcherList.contentHeight + 2 * Theme.cardPad + radius: Theme.radius + color: Theme.cardBg + visible: launcherList.count > 0 + + ListView { + id: launcherList + anchors.fill: parent + anchors.margins: Theme.cardPad interactive: false model: launcherPanel.entries highlight: Rectangle { @@ -813,12 +823,13 @@ in } } } + } Rectangle { width: parent.width height: 36 - radius: Theme.radiusSmall - color: Theme.base01 + radius: Theme.radius + color: Theme.cardBg SIcon { id: searchIcon @@ -2243,7 +2254,7 @@ in Rectangle { width: 28; height: 28; radius: Theme.radiusSmall anchors.left: parent.left - color: calPrevMa.containsMouse ? Theme.base02 : "transparent" + color: calPrevMa.containsMouse ? Theme.base02 : Qt.rgba(Theme.base02.r, Theme.base02.g, Theme.base02.b, 0) Behavior on color { ColorAnimation { duration: Theme.animFade } } SIcon { anchors.centerIn: parent @@ -2276,7 +2287,7 @@ in Rectangle { width: 28; height: 28; radius: Theme.radiusSmall anchors.right: parent.right - color: calNextMa.containsMouse ? Theme.base02 : "transparent" + color: calNextMa.containsMouse ? Theme.base02 : Qt.rgba(Theme.base02.r, Theme.base02.g, Theme.base02.b, 0) Behavior on color { ColorAnimation { duration: Theme.animFade } } SIcon { anchors.centerIn: parent @@ -2346,7 +2357,7 @@ in width: parent.width height: weatherRow.height + 16 radius: Theme.radius - color: Theme.base01 + color: Theme.cardBg visible: calPopup.weatherDays.length > 0 Row { @@ -2401,7 +2412,7 @@ in width: parent.width height: 64 radius: Theme.radius - color: Theme.base01 + color: Theme.cardBg visible: calPopup.player !== null Row { From 6977568bf2be5bb2a1b2041be5185660ca0ad641 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 11:38:11 +0100 Subject: [PATCH 11/61] quickshell: drop fill-on-select for session menu icons Co-Authored-By: Claude Opus 4.8 --- settings/quickshell.nix | 2 -- 1 file changed, 2 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 717d128..5ab90ea 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -637,7 +637,6 @@ in id: sessBtn required property var modelData required property int index - readonly property bool selected: sessionMenu.selIdx === index width: 40 height: 40 @@ -649,7 +648,6 @@ in color: Theme.base05 font.pixelSize: 20 font.weight: 600 - font.variableAxes: { "FILL": sessBtn.selected ? 1.0 : 0.0 } } MouseArea { From 2f51d2b4f1fe2008af954ef873ded35eeeb58367 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 11:43:45 +0100 Subject: [PATCH 12/61] quickshell: split media cards per MPRIS source, add per-stream volume Co-Authored-By: Claude Opus 4.8 --- settings/quickshell.nix | 234 ++++++++++++++++++++++++++-------------- 1 file changed, 151 insertions(+), 83 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 5ab90ea..cd48428 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -2214,13 +2214,27 @@ in return "thunderstorm"; } - // --- Media: prefer the actively playing MPRIS player --- - property var player: { - let ps = Mpris.players.values; - for (let i = 0; i < ps.length; i++) { - if (ps[i].playbackState === MprisPlaybackState.Playing) return ps[i]; + // --- Media: one card per MPRIS player so Spotify, a + // browser tab, etc. stay separate instead of collapsing + // into a single combined player. --- + property var players: Mpris.players.values.filter( + p => p.trackTitle || p.playbackState === MprisPlaybackState.Playing) + + // Best-effort link from an MPRIS player to its Pipewire + // audio stream (matched by app name), so a card can carry a + // volume slider via the same per-app path as the volume + // widget. null when no stream matches. + function streamFor(player) { + if (!player) return null; + let id = (player.identity || "").toLowerCase(); + let ns = Pipewire.nodes.values; + for (let i = 0; i < ns.length; i++) { + let n = ns[i]; + if (!n.isStream || !n.audio) continue; + let an = (n.properties["application.name"] || "").toLowerCase(); + if (an && (an === id || an.includes(id) || id.includes(an))) return n; } - return ps.length > 0 ? ps[0] : null; + return null; } Row { @@ -2405,97 +2419,151 @@ in width: 300 spacing: 8 - // Media player card - Rectangle { - width: parent.width - height: 64 - radius: Theme.radius - color: Theme.cardBg - visible: calPopup.player !== null + // Media player cards — one per active MPRIS source, + // so Spotify and a browser tab stay separate. + Repeater { + model: calPopup.players - Row { - anchors.fill: parent - anchors.margins: 8 - spacing: 10 + Rectangle { + id: mediaCard + required property var modelData + property var pwNode: calPopup.streamFor(modelData) + width: parent.width + height: mediaCol.height + 16 + radius: Theme.radius + color: Theme.cardBg - Rectangle { - width: 48; height: 48 - radius: Theme.radiusSmall - anchors.verticalCenter: parent.verticalCenter - color: Theme.base02 - clip: true - SIcon { - anchors.centerIn: parent - visible: albumArt.status !== Image.Ready - text: "music_note" - color: Theme.base04 - font.pixelSize: 22 - } - Image { - id: albumArt - anchors.fill: parent - fillMode: Image.PreserveAspectCrop - source: calPopup.player ? calPopup.player.trackArtUrl : "" - } - } + PwObjectTracker { objects: mediaCard.pwNode ? [mediaCard.pwNode] : [] } Column { - width: parent.width - 48 - 10 - 88 - 10 - anchors.verticalCenter: parent.verticalCenter - spacing: 2 - SText { - width: parent.width - text: calPopup.player ? calPopup.player.trackTitle : "" - color: Theme.base05 - font.pixelSize: 12 - font.weight: Font.Medium - elide: Text.ElideRight - } - SText { - width: parent.width - text: calPopup.player ? calPopup.player.trackArtist : "" - color: Theme.base04 - font.pixelSize: 11 - elide: Text.ElideRight - } - } + id: mediaCol + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + spacing: 8 + + Row { + width: parent.width + height: 48 + spacing: 10 - Row { - anchors.verticalCenter: parent.verticalCenter - spacing: 2 - Repeater { - model: [ - { glyph: "skip_previous", act: "prev" }, - { glyph: calPopup.player && calPopup.player.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow", act: "toggle" }, - { glyph: "skip_next", act: "next" } - ] Rectangle { - id: mediaBtn - required property var modelData - width: 28; height: 28; radius: 14 - color: mediaBtnMa.containsMouse ? Theme.base02 : "transparent" - Behavior on color { ColorAnimation { duration: Theme.animFade } } + width: 48; height: 48 + radius: Theme.radiusSmall + anchors.verticalCenter: parent.verticalCenter + color: Theme.base02 + clip: true SIcon { anchors.centerIn: parent - text: mediaBtn.modelData.glyph - color: Theme.base05 - font.pixelSize: 18 + visible: albumArt.status !== Image.Ready + text: "music_note" + color: Theme.base04 + font.pixelSize: 22 } - MouseArea { - id: mediaBtnMa + Image { + id: albumArt anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - let p = calPopup.player; - if (!p) return; - if (mediaBtn.modelData.act === "prev") p.previous(); - else if (mediaBtn.modelData.act === "next") p.next(); - else p.togglePlaying(); + fillMode: Image.PreserveAspectCrop + source: mediaCard.modelData.trackArtUrl + } + } + + Column { + width: parent.width - 48 - 10 - 88 - 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + SText { + width: parent.width + text: mediaCard.modelData.trackTitle + color: Theme.base05 + font.pixelSize: 12 + font.weight: Font.Medium + elide: Text.ElideRight + } + SText { + width: parent.width + text: mediaCard.modelData.trackArtist + color: Theme.base04 + font.pixelSize: 11 + elide: Text.ElideRight + } + } + + Row { + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + Repeater { + model: [ + { glyph: "skip_previous", act: "prev" }, + { glyph: mediaCard.modelData.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow", act: "toggle" }, + { glyph: "skip_next", act: "next" } + ] + Rectangle { + id: mediaBtn + required property var modelData + width: 28; height: 28; radius: 14 + color: mediaBtnMa.containsMouse ? Theme.base02 : "transparent" + Behavior on color { ColorAnimation { duration: Theme.animFade } } + SIcon { + anchors.centerIn: parent + text: mediaBtn.modelData.glyph + color: Theme.base05 + font.pixelSize: 18 + } + MouseArea { + id: mediaBtnMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + let p = mediaCard.modelData; + if (!p) return; + if (mediaBtn.modelData.act === "prev") p.previous(); + else if (mediaBtn.modelData.act === "next") p.next(); + else p.togglePlaying(); + } + } } } } } + + // Per-source volume — same per-app path + // as the volume widget. Shown when the + // player's Pipewire stream is matched. + Row { + width: parent.width + spacing: 8 + visible: mediaCard.pwNode !== null && mediaCard.pwNode.audio !== null + + SIcon { + anchors.verticalCenter: parent.verticalCenter + text: "volume_up" + color: Theme.base04 + font.pixelSize: 15 + } + + PillSlider { + width: parent.width - 22 - mediaVolLabel.width - 16 + anchors.verticalCenter: parent.verticalCenter + height: 16 + trackH: 4 + value: mediaCard.pwNode && mediaCard.pwNode.audio ? Math.min(1, mediaCard.pwNode.audio.volume) : 0 + fillColor: Theme.base0C + onMoved: (v) => { if (mediaCard.pwNode && mediaCard.pwNode.audio) mediaCard.pwNode.audio.volume = v; } + } + + SText { + id: mediaVolLabel + width: 36 + text: mediaCard.pwNode && mediaCard.pwNode.audio ? Math.round(mediaCard.pwNode.audio.volume * 100) + "%" : "0%" + color: Theme.base04 + font.pixelSize: 10 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } } } } From 2697614e1b5545f35503111589783e72c98cf924 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 12:52:52 +0100 Subject: [PATCH 13/61] quickshell: fade hover highlights via transparent base02, no black flash Co-Authored-By: Claude Opus 4.8 --- settings/quickshell.nix | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index cd48428..8c66d5a 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -162,6 +162,10 @@ in // Card surfaces are translucent so the compositor's bar-layer // blur shows through — a lessened blur over the panel tint. readonly property color cardBg: "#CC${c.base01}" + // Transparent base02: hover highlights fade to/from this so the + // colour animation stays in-hue instead of dipping through + // transparent *black* (which reads as an extra flash colour). + readonly property color base02t: "#00${c.base02}" readonly property string fontFamily: "${monoFont}" // Ligature-based icon font: text "volume_up" renders the icon readonly property string iconFont: "Material Symbols Rounded" @@ -1633,7 +1637,7 @@ in width: 200 height: modelData.isSeparator ? 9 : 28 color: !modelData.isSeparator && itemMouse.containsMouse && modelData.enabled - ? Theme.base02 : "transparent" + ? Theme.base02 : Theme.base02t Behavior on color { ColorAnimation { duration: Theme.animFade } } radius: modelData.isSeparator ? 0 : 4 @@ -1742,7 +1746,7 @@ in Rectangle { width: parent.width height: 28 - color: masterMuteMa.containsMouse ? Theme.base02 : "transparent" + color: masterMuteMa.containsMouse ? Theme.base02 : Theme.base02t Behavior on color { ColorAnimation { duration: Theme.animFade } } radius: Theme.radiusTiny @@ -1891,7 +1895,7 @@ in visible: netWidget.netState === "connected" width: parent.width height: 28 - color: disconnectMouse.containsMouse ? Theme.base02 : "transparent" + color: disconnectMouse.containsMouse ? Theme.base02 : Theme.base02t Behavior on color { ColorAnimation { duration: Theme.animFade } } radius: Theme.radiusTiny @@ -1937,7 +1941,7 @@ in required property var modelData width: parent.width height: 32 - color: netItemMouse.containsMouse ? Theme.base02 : "transparent" + color: netItemMouse.containsMouse ? Theme.base02 : Theme.base02t Behavior on color { ColorAnimation { duration: Theme.animFade } } radius: Theme.radiusTiny @@ -2097,7 +2101,7 @@ in height: 36 radius: Theme.radiusSmall color: profMouse.containsMouse && batteryWidget.powerProfile !== modelData.name - ? Theme.base02 : "transparent" + ? Theme.base02 : Theme.base02t Behavior on color { ColorAnimation { duration: Theme.animFade } } Column { @@ -2266,7 +2270,7 @@ in Rectangle { width: 28; height: 28; radius: Theme.radiusSmall anchors.left: parent.left - color: calPrevMa.containsMouse ? Theme.base02 : Qt.rgba(Theme.base02.r, Theme.base02.g, Theme.base02.b, 0) + color: calPrevMa.containsMouse ? Theme.base02 : Theme.base02t Behavior on color { ColorAnimation { duration: Theme.animFade } } SIcon { anchors.centerIn: parent @@ -2299,7 +2303,7 @@ in Rectangle { width: 28; height: 28; radius: Theme.radiusSmall anchors.right: parent.right - color: calNextMa.containsMouse ? Theme.base02 : Qt.rgba(Theme.base02.r, Theme.base02.g, Theme.base02.b, 0) + color: calNextMa.containsMouse ? Theme.base02 : Theme.base02t Behavior on color { ColorAnimation { duration: Theme.animFade } } SIcon { anchors.centerIn: parent @@ -2503,7 +2507,7 @@ in id: mediaBtn required property var modelData width: 28; height: 28; radius: 14 - color: mediaBtnMa.containsMouse ? Theme.base02 : "transparent" + color: mediaBtnMa.containsMouse ? Theme.base02 : Theme.base02t Behavior on color { ColorAnimation { duration: Theme.animFade } } SIcon { anchors.centerIn: parent From 700d3f7de12971925c2fdd3b0ec04c962df2f225 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 13:22:28 +0100 Subject: [PATCH 14/61] quickshell: clickable mute icon on all volume sliders via VolIcon Co-Authored-By: Claude Opus 4.8 --- settings/quickshell.nix | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 8c66d5a..7aecf0f 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -368,6 +368,19 @@ in font.pixelSize: 16 } + // ── VolIcon: a slider's volume glyph that toggles its audio + // node's mute on click. Glyph reflects the mute state; pair it + // with a fill that greys when `audioNode.muted`. + component VolIcon: SIcon { + property var audioNode: null + text: (audioNode && audioNode.muted) ? "volume_off" : "volume_up" + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: if (audioNode) audioNode.muted = !audioNode.muted + } + } + // ── Card: the rounded base01 section surface used by every // dropdown. Children flow into a padded auto-height column, // so callers just set `width` and drop content in. @@ -1712,7 +1725,7 @@ in Row { spacing: 6 - SIcon { anchors.verticalCenter: parent.verticalCenter; text: "volume_up" } + VolIcon { anchors.verticalCenter: parent.verticalCenter; audioNode: volWidget.sink ? volWidget.sink.audio : null } SText { anchors.verticalCenter: parent.verticalCenter text: "Master" @@ -1824,8 +1837,16 @@ in width: parent.width spacing: 8 + VolIcon { + anchors.verticalCenter: parent.verticalCenter + width: 18 + color: Theme.base04 + font.pixelSize: 15 + audioNode: modelData.audio + } + PillSlider { - width: parent.width - appVolLabel.width - 8 + width: parent.width - 18 - appVolLabel.width - 16 anchors.verticalCenter: parent.verticalCenter height: 16 trackH: 4 @@ -2541,20 +2562,21 @@ in spacing: 8 visible: mediaCard.pwNode !== null && mediaCard.pwNode.audio !== null - SIcon { + VolIcon { anchors.verticalCenter: parent.verticalCenter - text: "volume_up" + width: 18 color: Theme.base04 font.pixelSize: 15 + audioNode: mediaCard.pwNode ? mediaCard.pwNode.audio : null } PillSlider { - width: parent.width - 22 - mediaVolLabel.width - 16 + width: parent.width - 18 - mediaVolLabel.width - 16 anchors.verticalCenter: parent.verticalCenter height: 16 trackH: 4 value: mediaCard.pwNode && mediaCard.pwNode.audio ? Math.min(1, mediaCard.pwNode.audio.volume) : 0 - fillColor: Theme.base0C + fillColor: mediaCard.pwNode && mediaCard.pwNode.audio && mediaCard.pwNode.audio.muted ? Theme.base03 : Theme.base0C onMoved: (v) => { if (mediaCard.pwNode && mediaCard.pwNode.audio) mediaCard.pwNode.audio.volume = v; } } From 6846f38b9acff91f335bc378be4e99a3ce02042b Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 13:52:15 +0100 Subject: [PATCH 15/61] quickshell: wrap tray context menu in shared Card segment Co-Authored-By: Claude Opus 4.8 --- settings/quickshell.nix | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 7aecf0f..f1c525f 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -1637,17 +1637,18 @@ in id: menuOpener } - Column { + Card { id: menuItems anchors.centerIn: parent width: 200 + cardSpacing: 0 Repeater { model: menuOpener.children Rectangle { required property var modelData - width: 200 + width: parent.width height: modelData.isSeparator ? 9 : 28 color: !modelData.isSeparator && itemMouse.containsMouse && modelData.enabled ? Theme.base02 : Theme.base02t From 215239e7aadbeeef676854b40cc1337ac6096de7 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 13:59:26 +0100 Subject: [PATCH 16/61] quickshell: extract HoverRow component, dedupe 6 hover targets Co-Authored-By: Claude Opus 4.8 --- settings/quickshell.nix | 143 ++++++++++++++++------------------------ 1 file changed, 55 insertions(+), 88 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index f1c525f..54e924f 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -381,6 +381,25 @@ in } } + // ── HoverRow: a rounded clickable row that owns the shared + // base02 hover-fade + pointer cursor. Drop content inside and + // handle `onClicked`; override `radius` for non-radiusTiny rows. + component HoverRow: Rectangle { + default property alias rowData: _hrContent.data + signal clicked() + radius: Theme.radiusTiny + color: _hrMa.containsMouse ? Theme.base02 : Theme.base02t + Behavior on color { ColorAnimation { duration: Theme.animFade } } + Item { id: _hrContent; anchors.fill: parent } + MouseArea { + id: _hrMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: parent.clicked() + } + } + // ── Card: the rounded base01 section surface used by every // dropdown. Children flow into a padded auto-height column, // so callers just set `width` and drop content in. @@ -1757,12 +1776,13 @@ in } // Mute button - Rectangle { + HoverRow { width: parent.width height: 28 - color: masterMuteMa.containsMouse ? Theme.base02 : Theme.base02t - Behavior on color { ColorAnimation { duration: Theme.animFade } } - radius: Theme.radiusTiny + onClicked: { + if (volWidget.sink && volWidget.sink.audio) + volWidget.sink.audio.muted = !volWidget.sink.audio.muted; + } Row { anchors.centerIn: parent @@ -1778,16 +1798,6 @@ in font.pixelSize: 12 } } - MouseArea { - id: masterMuteMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (volWidget.sink && volWidget.sink.audio) - volWidget.sink.audio.muted = !volWidget.sink.audio.muted; - } - } } } @@ -1913,13 +1923,19 @@ in } } - Rectangle { + HoverRow { visible: netWidget.netState === "connected" width: parent.width height: 28 - color: disconnectMouse.containsMouse ? Theme.base02 : Theme.base02t - Behavior on color { ColorAnimation { duration: Theme.animFade } } - radius: Theme.radiusTiny + onClicked: { + netDisconnectProc.targetDevice = netWidget.netDevice; + netDisconnectProc.running = true; + netWidget.netState = "disconnected"; + netWidget.netConn = ""; + netWidget.netIcon = "wifi_off"; + bar.closeAllDropdowns(); + netRefreshDelay.start(); + } SText { anchors.centerIn: parent @@ -1927,22 +1943,6 @@ in color: Theme.base08 font.pixelSize: 12 } - - MouseArea { - id: disconnectMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - netDisconnectProc.targetDevice = netWidget.netDevice; - netDisconnectProc.running = true; - netWidget.netState = "disconnected"; - netWidget.netConn = ""; - netWidget.netIcon = "wifi_off"; - bar.closeAllDropdowns(); - netRefreshDelay.start(); - } - } } } @@ -1959,13 +1959,18 @@ in Repeater { model: netWidget.wifiNetworks - Rectangle { + HoverRow { required property var modelData width: parent.width height: 32 - color: netItemMouse.containsMouse ? Theme.base02 : Theme.base02t - Behavior on color { ColorAnimation { duration: Theme.animFade } } - radius: Theme.radiusTiny + onClicked: { + if (!modelData.active) { + wifiConnectProc.targetSsid = modelData.ssid; + wifiConnectProc.running = true; + netRefreshDelay.start(); + } + bar.closeAllDropdowns(); + } Row { anchors.verticalCenter: parent.verticalCenter @@ -2006,20 +2011,6 @@ in } } - MouseArea { - id: netItemMouse - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (!modelData.active) { - wifiConnectProc.targetSsid = modelData.ssid; - wifiConnectProc.running = true; - netRefreshDelay.start(); - } - bar.closeAllDropdowns(); - } - } } } } @@ -2289,24 +2280,16 @@ in width: parent.width height: 28 - Rectangle { + HoverRow { width: 28; height: 28; radius: Theme.radiusSmall anchors.left: parent.left - color: calPrevMa.containsMouse ? Theme.base02 : Theme.base02t - Behavior on color { ColorAnimation { duration: Theme.animFade } } + onClicked: calPopup.shiftMonth(-1) SIcon { anchors.centerIn: parent text: "chevron_left" color: Theme.base05 font.pixelSize: 18 } - MouseArea { - id: calPrevMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: calPopup.shiftMonth(-1) - } } SText { @@ -2322,24 +2305,16 @@ in } } - Rectangle { + HoverRow { width: 28; height: 28; radius: Theme.radiusSmall anchors.right: parent.right - color: calNextMa.containsMouse ? Theme.base02 : Theme.base02t - Behavior on color { ColorAnimation { duration: Theme.animFade } } + onClicked: calPopup.shiftMonth(1) SIcon { anchors.centerIn: parent text: "chevron_right" color: Theme.base05 font.pixelSize: 18 } - MouseArea { - id: calNextMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: calPopup.shiftMonth(1) - } } } @@ -2525,31 +2500,23 @@ in { glyph: mediaCard.modelData.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow", act: "toggle" }, { glyph: "skip_next", act: "next" } ] - Rectangle { + HoverRow { id: mediaBtn required property var modelData width: 28; height: 28; radius: 14 - color: mediaBtnMa.containsMouse ? Theme.base02 : Theme.base02t - Behavior on color { ColorAnimation { duration: Theme.animFade } } + onClicked: { + let p = mediaCard.modelData; + if (!p) return; + if (mediaBtn.modelData.act === "prev") p.previous(); + else if (mediaBtn.modelData.act === "next") p.next(); + else p.togglePlaying(); + } SIcon { anchors.centerIn: parent text: mediaBtn.modelData.glyph color: Theme.base05 font.pixelSize: 18 } - MouseArea { - id: mediaBtnMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - let p = mediaCard.modelData; - if (!p) return; - if (mediaBtn.modelData.act === "prev") p.previous(); - else if (mediaBtn.modelData.act === "next") p.next(); - else p.togglePlaying(); - } - } } } } From 83b4c5ef0974a9e1fbf4540ee88d4d14e9c661c6 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 14:19:10 +0100 Subject: [PATCH 17/61] quickshell: click-outside dropdown dismissal, drop auto-close timers Extend the launcher/session HyprlandFocusGrab to the bar dropdowns and remove the per-dropdown inactivity timers. Shift+Super+S brackets hyprshot with a screenshot pin so open menus survive slurp's input grab. Co-Authored-By: Claude Opus 4.8 --- settings/hyprland.nix | 4 ++- settings/quickshell.nix | 72 +++++++++++++++++++---------------------- 2 files changed, 36 insertions(+), 40 deletions(-) diff --git a/settings/hyprland.nix b/settings/hyprland.nix index 873c32f..abbf620 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -281,7 +281,9 @@ in end -- Screenshots — Shift+Super+S matches GNOME binding - hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("hyprshot -m region --clipboard-only")) + -- Pin/unpin quickshell's focus grab around the region select so an + -- open menu survives slurp's input grab (no-ops if qs isn't up). + hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("sh -c 'qs ipc call screenshot pin; hyprshot -m region --clipboard-only; qs ipc call screenshot unpin'")) hl.bind("Print", hl.dsp.exec_cmd("hyprshot -m output --clipboard-only")) -- Settings shortcut — Super+I matches GNOME binding diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 54e924f..4824fdc 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -238,6 +238,16 @@ in function reload(): void { Quickshell.reload(false); } } + // Screenshot pin: the Shift+Super+S keybind brackets hyprshot + // with pin/unpin so the focus grab is suspended while slurp + // grabs input — otherwise the open menu would close like any + // click-outside. Self-heals via a watchdog if unpin is missed. + IpcHandler { + target: "screenshot" + function pin(): void { if (root.mainBar) root.mainBar.setScreenshotPin(true); } + function unpin(): void { if (root.mainBar) root.mainBar.setScreenshotPin(false); } + } + NotificationServer { id: _notifServer bodySupported: true @@ -291,7 +301,7 @@ in // (caelestia's): the grab redirects focus to this window and // OnDemand lets the layer surface accept it. Exclusive fights // the grab — it self-clears and instantly closes the panel. - WlrLayershell.keyboardFocus: sessionMenu.open || launcherPanel.open ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: (sessionMenu.open || launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None anchors { top: true @@ -910,18 +920,38 @@ in } } - // Click-outside dismissal for the keyboard-grabbing panels + // Click-outside dismissal for every panel — the launcher, + // session menu AND the bar dropdowns. Clicking into another + // window (or anywhere outside the bar) clears the grab and + // closes whatever is open. Suspended while screenshotPinned so + // slurp can grab input without dismissing the menu. HyprlandFocusGrab { - active: sessionMenu.open || launcherPanel.open + active: (sessionMenu.open || launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned windows: [bar] onCleared: { + if (bar.screenshotPinned) return; sessionMenu.open = false; launcherPanel.open = false; + bar.closeAllDropdowns(); } } property var activeDropdown: null + // Set by the screenshot keybind (via IPC) to hold menus open + // while a region screenshot runs. Watchdog unpins if the + // bracketing unpin call is ever missed. + property bool screenshotPinned: false + Timer { + id: _pinWatchdog + interval: 30000 + onTriggered: bar.screenshotPinned = false + } + function setScreenshotPin(v) { + screenshotPinned = v; + if (v) _pinWatchdog.restart(); else _pinWatchdog.stop(); + } + function closeAllDropdowns() { if (activeDropdown && activeDropdown.visible) { activeDropdown.animateClose(); @@ -1024,7 +1054,6 @@ in onEntered: { if (bar.activeDropdown) { if (bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup, function() { calPopup.resetView(); }); - else bar.activeDropdown.resetAutoClose(); } } } @@ -1104,7 +1133,6 @@ in onEntered: { if (bar.activeDropdown) { if (bar.activeDropdown !== volDropdown) volWidget.openVolDropdown(); - else bar.activeDropdown.resetAutoClose(); } } } @@ -1271,7 +1299,6 @@ in onEntered: { if (bar.activeDropdown) { if (bar.activeDropdown !== netDropdown) netWidget.openNetDropdown(); - else bar.activeDropdown.resetAutoClose(); } } } @@ -1352,7 +1379,6 @@ in onEntered: { if (bar.activeDropdown) { if (bar.activeDropdown !== batteryDropdown) batteryWidget.openBatteryDropdown(); - else bar.activeDropdown.resetAutoClose(); } } } @@ -1366,12 +1392,6 @@ in height: Theme.barHeight anchors.verticalCenter: parent.verticalCenter - HoverHandler { - onHoveredChanged: { - if (hovered && bar.activeDropdown) bar.activeDropdown.resetAutoClose(); - } - } - Repeater { model: SystemTray.items @@ -1405,7 +1425,6 @@ in acceptedButtons: Qt.NoButton onEntered: { if (bar.activeDropdown) { - bar.activeDropdown.resetAutoClose(); if (modelData.hasMenu && !(bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData)) { if (bar.activeDropdown === contextMenu) { // Same dropdown, just switch content @@ -1413,7 +1432,6 @@ in contextMenu.dropdownX = pos.x; contextMenu.trayItem = modelData; menuOpener.menu = modelData.menu; - contextMenu.resetAutoClose(); } else { bar.toggleDropdown(contextMenu, function() { let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0); @@ -1457,7 +1475,6 @@ in property real dropdownX: 0 property real fullWidth: 200 property real fullHeight: 200 - property int autoCloseMs: 1500 // Flush-right dropdowns merge into the screen frame's // right column instead of centering on their widget. property bool alignRight: false @@ -1477,14 +1494,9 @@ in bar.activeDropdown = null; chrome.shrinkToButton(dropdown); } - _autoClose.stop(); _closeDelay.start(); } - function resetAutoClose() { - if (visible && !closing) _autoClose.restart(); - } - // Reopen a dropdown that's mid-close: the pending hide // timer must be cancelled, otherwise it fires later and // closes the revived dropdown (and the whole chrome). @@ -1492,7 +1504,6 @@ in _closeDelay.stop(); closing = false; open = true; - _autoClose.restart(); } x: alignRight @@ -1507,33 +1518,18 @@ in if (visible) { closing = false; open = true; - _autoClose.restart(); } else { open = false; closing = false; - _autoClose.stop(); } } - Timer { - id: _autoClose - interval: dropdown.autoCloseMs - onTriggered: bar.closeAllDropdowns() - } - Timer { id: _closeDelay interval: 300 onTriggered: { dropdown.visible = false; dropdown.closing = false; if (bar.activeDropdown === dropdown) bar.activeDropdown = null; } } - HoverHandler { - onHoveredChanged: { - if (hovered) _autoClose.stop(); - else _autoClose.restart(); - } - } - // Content is clipped to the chrome's ANIMATED geometry — // revealed as the panel slides/grows over it and wiped as // the panel leaves, instead of popping in place. The inner @@ -1731,7 +1727,6 @@ in alignRight: true fullWidth: volDropdownCol.width + 28 fullHeight: volDropdownCol.height + 20 - autoCloseMs: 3000 Column { id: volDropdownCol @@ -2165,7 +2160,6 @@ in // edges render as soft 2px lines fullWidth: Math.ceil(calRow.width) + 24 fullHeight: Math.ceil(calRow.height) + 24 - autoCloseMs: 3000 // Month being viewed; reset to today when the popup opens // (via the setup function passed to bar.toggleDropdown). From a77203422039c6fceb23813da92c7ad8c06c5300 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 14:25:54 +0100 Subject: [PATCH 18/61] quickshell: drop session menu auto-close timer too Co-Authored-By: Claude Opus 4.8 --- settings/quickshell.nix | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 4824fdc..fd1cf28 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -604,7 +604,6 @@ in if (open) { selIdx = 0; forceActiveFocus(); - _sessionAutoClose.restart(); } } @@ -616,25 +615,12 @@ in focus: open Keys.onEscapePressed: open = false - Keys.onUpPressed: { selIdx = (selIdx + actions.length - 1) % actions.length; _sessionAutoClose.restart(); } - Keys.onDownPressed: { selIdx = (selIdx + 1) % actions.length; _sessionAutoClose.restart(); } - Keys.onTabPressed: { selIdx = (selIdx + 1) % actions.length; _sessionAutoClose.restart(); } + Keys.onUpPressed: selIdx = (selIdx + actions.length - 1) % actions.length + Keys.onDownPressed: selIdx = (selIdx + 1) % actions.length + Keys.onTabPressed: selIdx = (selIdx + 1) % actions.length Keys.onReturnPressed: activate(actions[selIdx].act) Keys.onEnterPressed: activate(actions[selIdx].act) - Timer { - id: _sessionAutoClose - interval: 2500 - onTriggered: sessionMenu.open = false - } - - HoverHandler { - onHoveredChanged: { - if (hovered) _sessionAutoClose.stop(); - else if (sessionMenu.open) _sessionAutoClose.restart(); - } - } - // Content pinned to the column edge, revealed by the grow Item { anchors.fill: parent From 150f3629987fed18dd4d9e9c67495d7237e6c02e Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 14:43:09 +0100 Subject: [PATCH 19/61] =?UTF-8?q?quickshell:=20restack=20media=20card=20?= =?UTF-8?q?=E2=80=94=20art+controls=20row,=20then=20title/artist/volume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- settings/quickshell.nix | 61 +++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index fd1cf28..2eb6ee1 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -2424,14 +2424,16 @@ in anchors.margins: 8 spacing: 8 - Row { + // Album art + transport controls share + // the top row, so the art can be large. + Item { width: parent.width - height: 48 - spacing: 10 + height: 64 Rectangle { - width: 48; height: 48 + width: 64; height: 64 radius: Theme.radiusSmall + anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter color: Theme.base02 clip: true @@ -2440,7 +2442,7 @@ in visible: albumArt.status !== Image.Ready text: "music_note" color: Theme.base04 - font.pixelSize: 22 + font.pixelSize: 28 } Image { id: albumArt @@ -2450,30 +2452,10 @@ in } } - Column { - width: parent.width - 48 - 10 - 88 - 10 - anchors.verticalCenter: parent.verticalCenter - spacing: 2 - SText { - width: parent.width - text: mediaCard.modelData.trackTitle - color: Theme.base05 - font.pixelSize: 12 - font.weight: Font.Medium - elide: Text.ElideRight - } - SText { - width: parent.width - text: mediaCard.modelData.trackArtist - color: Theme.base04 - font.pixelSize: 11 - elide: Text.ElideRight - } - } - Row { + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - spacing: 2 + spacing: 4 Repeater { model: [ { glyph: "skip_previous", act: "prev" }, @@ -2483,7 +2465,7 @@ in HoverRow { id: mediaBtn required property var modelData - width: 28; height: 28; radius: 14 + width: 36; height: 36; radius: 18 onClicked: { let p = mediaCard.modelData; if (!p) return; @@ -2495,13 +2477,34 @@ in anchors.centerIn: parent text: mediaBtn.modelData.glyph color: Theme.base05 - font.pixelSize: 18 + font.pixelSize: 24 } } } } } + // Title, then artist, stacked below. + Column { + width: parent.width + spacing: 2 + SText { + width: parent.width + text: mediaCard.modelData.trackTitle + color: Theme.base05 + font.pixelSize: 13 + font.weight: Font.Medium + elide: Text.ElideRight + } + SText { + width: parent.width + text: mediaCard.modelData.trackArtist + color: Theme.base04 + font.pixelSize: 11 + elide: Text.ElideRight + } + } + // Per-source volume — same per-app path // as the volume widget. Shown when the // player's Pipewire stream is matched. From af35c81514ecbaeda63073d29e324443def70c8f Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 14:47:02 +0100 Subject: [PATCH 20/61] quickshell: bump media album art to 128x128 Co-Authored-By: Claude Opus 4.8 --- settings/quickshell.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 2eb6ee1..46133f4 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -2428,10 +2428,10 @@ in // the top row, so the art can be large. Item { width: parent.width - height: 64 + height: 128 Rectangle { - width: 64; height: 64 + width: 128; height: 128 radius: Theme.radiusSmall anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter @@ -2442,7 +2442,7 @@ in visible: albumArt.status !== Image.Ready text: "music_note" color: Theme.base04 - font.pixelSize: 28 + font.pixelSize: 48 } Image { id: albumArt From c901b9b56dcc7c460bdd5b1e58de766febfb18dc Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 14:48:31 +0100 Subject: [PATCH 21/61] =?UTF-8?q?quickshell:=20media=20card=20=E2=80=94=20?= =?UTF-8?q?title/artist=20right=20of=20art,=20controls+volume=20below?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- settings/quickshell.nix | 59 ++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 46133f4..c81d3a6 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -2424,16 +2424,15 @@ in anchors.margins: 8 spacing: 8 - // Album art + transport controls share - // the top row, so the art can be large. - Item { + // Album art with title + artist to its + // right; controls and volume below. + Row { width: parent.width - height: 128 + spacing: 12 Rectangle { width: 128; height: 128 radius: Theme.radiusSmall - anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter color: Theme.base02 clip: true @@ -2452,9 +2451,34 @@ in } } - Row { - anchors.right: parent.right + Column { + width: parent.width - 128 - 12 anchors.verticalCenter: parent.verticalCenter + spacing: 2 + SText { + width: parent.width + text: mediaCard.modelData.trackTitle + color: Theme.base05 + font.pixelSize: 13 + font.weight: Font.Medium + elide: Text.ElideRight + } + SText { + width: parent.width + text: mediaCard.modelData.trackArtist + color: Theme.base04 + font.pixelSize: 11 + elide: Text.ElideRight + } + } + } + + // Transport controls, centered below. + Item { + width: parent.width + height: 36 + Row { + anchors.centerIn: parent spacing: 4 Repeater { model: [ @@ -2484,27 +2508,6 @@ in } } - // Title, then artist, stacked below. - Column { - width: parent.width - spacing: 2 - SText { - width: parent.width - text: mediaCard.modelData.trackTitle - color: Theme.base05 - font.pixelSize: 13 - font.weight: Font.Medium - elide: Text.ElideRight - } - SText { - width: parent.width - text: mediaCard.modelData.trackArtist - color: Theme.base04 - font.pixelSize: 11 - elide: Text.ElideRight - } - } - // Per-source volume — same per-app path // as the volume widget. Shown when the // player's Pipewire stream is matched. From f0193eedd3729bbc591f1b7bc9313db9e7a83260 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 14:56:49 +0100 Subject: [PATCH 22/61] quickshell: round album art, full-width notifications with app icon + image preview - album art uses ClippingRectangle so the image follows the radius - NotifContent gains an app icon + name header - notifications move to a full-width card spanning both panes, each item showing the notification image preview when present Co-Authored-By: Claude Opus 4.8 --- settings/quickshell.nix | 193 ++++++++++++++++++++++++---------------- 1 file changed, 118 insertions(+), 75 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index c81d3a6..09956ab 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -475,6 +475,29 @@ in signal actionInvoked() spacing: 2 + // App header: icon + name of the sending app, when known. + Row { + width: parent.width + spacing: 4 + visible: _nc.notif && (_nc.notif.appName !== "" || _appIcon.source != "") + Image { + id: _appIcon + width: 14; height: 14 + sourceSize.width: 14; sourceSize.height: 14 + anchors.verticalCenter: parent.verticalCenter + source: _nc.notif && _nc.notif.appIcon ? Quickshell.iconPath(_nc.notif.appIcon, true) : "" + visible: source != "" + } + SText { + anchors.verticalCenter: parent.verticalCenter + text: _nc.notif ? _nc.notif.appName : "" + color: Theme.base04 + font.pixelSize: 10 + elide: Text.ElideRight + width: parent.width - (_appIcon.visible ? 18 : 0) + } + } + SText { width: parent.width text: _nc.notif ? (_nc.notif.summary || _nc.notif.appName) : "" @@ -2145,7 +2168,7 @@ in // ceil: Text metrics give fractional sizes; fractional rect // edges render as soft 2px lines fullWidth: Math.ceil(calRow.width) + 24 - fullHeight: Math.ceil(calRow.height) + 24 + fullHeight: Math.ceil(calRow.height + 8 + notifCard.height) + 24 // Month being viewed; reset to today when the popup opens // (via the setup function passed to bar.toggleDropdown). @@ -2430,12 +2453,11 @@ in width: parent.width spacing: 12 - Rectangle { + ClippingRectangle { width: 128; height: 128 radius: Theme.radiusSmall anchors.verticalCenter: parent.verticalCenter color: Theme.base02 - clip: true SIcon { anchors.centerIn: parent visible: albumArt.status !== Image.Ready @@ -2548,85 +2570,106 @@ in } } - // Notifications card - Card { + } + } + + // ── Notifications: spans both columns, below the panes ── + Card { + id: notifCard + anchors.top: calRow.bottom + anchors.topMargin: 8 + anchors.horizontalCenter: parent.horizontalCenter + width: calRow.width + opacity: calRow.opacity + cardSpacing: 6 + + Item { + width: parent.width + height: 20 + SText { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "Notifications" + font.weight: Font.Medium + } + SText { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: bar.notifServer.trackedNotifications.values.length > 0 ? "Clear all" : "" + color: Theme.base04 + font.pixelSize: 11 + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + let notifs = bar.notifServer.trackedNotifications.values; + for (let i = notifs.length - 1; i >= 0; i--) { + notifs[i].dismiss(); + } + } + } + } + } + + SText { + visible: bar.notifServer.trackedNotifications.values.length === 0 + text: "No notifications" + color: Theme.base03 + font.pixelSize: 11 + anchors.horizontalCenter: parent.horizontalCenter + } + + Repeater { + model: bar.notifServer.trackedNotifications + + Rectangle { + id: notifItem + required property var modelData width: parent.width - cardSpacing: 6 + height: Math.max(notifPreview.visible ? 48 : 0, ncBody.height) + 16 + radius: Theme.radiusSmall + color: Theme.base02 - Item { - width: parent.width - height: 20 - - SText { - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: "Notifications" - font.weight: Font.Medium - } - - SText { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - text: bar.notifServer.trackedNotifications.values.length > 0 ? "Clear all" : "" - color: Theme.base04 - font.pixelSize: 11 - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { - let notifs = bar.notifServer.trackedNotifications.values; - for (let i = notifs.length - 1; i >= 0; i--) { - notifs[i].dismiss(); - } - } - } + // Image preview (album art, screenshot thumb…) + ClippingRectangle { + id: notifPreview + visible: notifItem.modelData.image != "" + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: 8 + width: 48; height: 48 + radius: Theme.radiusTiny + color: Theme.base01 + Image { + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + source: notifItem.modelData.image } } - SText { - visible: bar.notifServer.trackedNotifications.values.length === 0 - text: "No notifications" - color: Theme.base03 - font.pixelSize: 11 - anchors.horizontalCenter: parent.horizontalCenter + NotifContent { + id: ncBody + notif: notifItem.modelData + anchors.left: notifPreview.visible ? notifPreview.right : parent.left + anchors.right: dismissBtn.left + anchors.top: parent.top + anchors.margins: 8 } - Repeater { - model: bar.notifServer.trackedNotifications - - Rectangle { - id: notifItem - required property var modelData - width: parent.width - height: ncBody.height + 16 - radius: Theme.radiusSmall - color: Theme.base02 - - NotifContent { - id: ncBody - notif: notifItem.modelData - anchors.left: parent.left - anchors.right: dismissBtn.left - anchors.top: parent.top - anchors.margins: 8 - } - - SIcon { - id: dismissBtn - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 8 - text: "close" - color: dismissMa.containsMouse ? Theme.base05 : Theme.base03 - font.pixelSize: 15 - MouseArea { - id: dismissMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: notifItem.modelData.dismiss() - } - } + SIcon { + id: dismissBtn + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + text: "close" + color: dismissMa.containsMouse ? Theme.base05 : Theme.base03 + font.pixelSize: 15 + MouseArea { + id: dismissMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: notifItem.modelData.dismiss() } } } From 7cd7a0e3dcc3839c19cbf98d6d1ac42005397a80 Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 15:20:53 +0100 Subject: [PATCH 23/61] screenshots: save file for notification previews, fall back to appIcon path Co-Authored-By: Claude Opus 4.8 --- settings/hyprland.nix | 4 ++-- settings/quickshell.nix | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/settings/hyprland.nix b/settings/hyprland.nix index abbf620..4347614 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -283,8 +283,8 @@ in -- Screenshots — Shift+Super+S matches GNOME binding -- Pin/unpin quickshell's focus grab around the region select so an -- open menu survives slurp's input grab (no-ops if qs isn't up). - hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("sh -c 'qs ipc call screenshot pin; hyprshot -m region --clipboard-only; qs ipc call screenshot unpin'")) - hl.bind("Print", hl.dsp.exec_cmd("hyprshot -m output --clipboard-only")) + hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("sh -c 'qs ipc call screenshot pin; hyprshot -m region; qs ipc call screenshot unpin'")) + hl.bind("Print", hl.dsp.exec_cmd("hyprshot -m output")) -- Settings shortcut — Super+I matches GNOME binding hl.bind(mod .. " + I", hl.dsp.exec_cmd("pavucontrol")) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 09956ab..cb30da4 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -2625,6 +2625,16 @@ in Rectangle { id: notifItem required property var modelData + // Prefer the notification's image; fall back to a + // file-path appIcon (e.g. hyprshot's saved shot). + readonly property string previewSource: { + let m = notifItem.modelData; + if (m.image && m.image !== "") return m.image; + let a = m.appIcon || ""; + if (a.startsWith("file://")) return a; + if (a.startsWith("/")) return "file://" + a; + return ""; + } width: parent.width height: Math.max(notifPreview.visible ? 48 : 0, ncBody.height) + 16 radius: Theme.radiusSmall @@ -2633,7 +2643,7 @@ in // Image preview (album art, screenshot thumb…) ClippingRectangle { id: notifPreview - visible: notifItem.modelData.image != "" + visible: notifItem.previewSource !== "" anchors.left: parent.left anchors.top: parent.top anchors.margins: 8 @@ -2643,7 +2653,7 @@ in Image { anchors.fill: parent fillMode: Image.PreserveAspectCrop - source: notifItem.modelData.image + source: notifItem.previewSource } } From 23a5ad291472726406bac596be812917a93a0fed Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 17 Jun 2026 15:39:11 +0100 Subject: [PATCH 24/61] quickshell: trim hyprshot body, add image preview to toast (shared helper) Co-Authored-By: Claude Opus 4.8 --- settings/quickshell.nix | 49 ++++++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index cb30da4..7c98ea1 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -513,7 +513,9 @@ in elide: Text.ElideRight maximumLineCount: _nc.bodyLines wrapMode: Text.Wrap - visible: text !== "" + // ponytail: hyprshot's body is a noisy file path; the + // "Screenshot saved" summary + the preview say enough. + visible: text !== "" && !(_nc.notif && _nc.notif.appName === "Hyprshot") } Row { spacing: 4 @@ -966,6 +968,17 @@ in activeDropdown.animateClose(); } } + + // Preview image for a notification: its image hint, else a + // file-path appIcon (e.g. hyprshot's saved shot). "" = none. + function notifPreviewSource(notif) { + if (!notif) return ""; + if (notif.image && notif.image !== "") return notif.image; + let a = notif.appIcon || ""; + if (a.startsWith("file://")) return a; + if (a.startsWith("/")) return "file://" + a; + return ""; + } function toggleDropdown(dd, setupFn) { if (dd.visible && !dd.closing) { dd.animateClose(); @@ -2625,16 +2638,7 @@ in Rectangle { id: notifItem required property var modelData - // Prefer the notification's image; fall back to a - // file-path appIcon (e.g. hyprshot's saved shot). - readonly property string previewSource: { - let m = notifItem.modelData; - if (m.image && m.image !== "") return m.image; - let a = m.appIcon || ""; - if (a.startsWith("file://")) return a; - if (a.startsWith("/")) return "file://" + a; - return ""; - } + readonly property string previewSource: bar.notifPreviewSource(notifItem.modelData) width: parent.width height: Math.max(notifPreview.visible ? 48 : 0, ncBody.height) + 16 radius: Theme.radiusSmall @@ -2785,14 +2789,33 @@ in anchors.left: parent.left anchors.right: parent.right anchors.margins: 6 - height: toastCol.height + 16 + height: Math.max(toastPreview.visible ? 48 : 0, toastCol.height) + 16 radius: Theme.radiusSmall color: Theme.base02 + property string previewSource: bar.notifPreviewSource(toastItem.currentNotif) + + // Image preview (screenshot thumb, album art…) + ClippingRectangle { + id: toastPreview + visible: toastCard.previewSource !== "" + anchors.left: parent.left + anchors.top: parent.top + anchors.margins: 8 + width: 48; height: 48 + radius: Theme.radiusTiny + color: Theme.base01 + Image { + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + source: toastCard.previewSource + } + } + NotifContent { id: toastCol notif: toastItem.currentNotif - anchors.left: parent.left + anchors.left: toastPreview.visible ? toastPreview.right : parent.left anchors.right: toastDismiss.left anchors.top: parent.top anchors.margins: 8 From 128143bc7432533ffb8f75c4a70079e5b011699d Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Thu, 18 Jun 2026 04:00:59 +0000 Subject: [PATCH 25/61] Update flake inputs --- flake.lock | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/flake.lock b/flake.lock index 9ace5d0..c49d25a 100644 --- a/flake.lock +++ b/flake.lock @@ -71,11 +71,11 @@ "cachyos-kernel": { "flake": false, "locked": { - "lastModified": 1781455283, - "narHash": "sha256-/71qSmWc0vIyGsvtADG8/uHnC/NvXPEY6TXRoDMufeo=", + "lastModified": 1781468456, + "narHash": "sha256-0nJ5RUyUQ/rEz8Lai9I1kLKwpzhnL6KLePSnO4IZh64=", "owner": "CachyOS", "repo": "linux-cachyos", - "rev": "3bd5b77999c4180ed01bdd0669bfabc5171b090a", + "rev": "68925a2fb4eb362d4dd6cf2c3e9d85e434a96298", "type": "github" }, "original": { @@ -87,11 +87,11 @@ "cachyos-kernel-patches": { "flake": false, "locked": { - "lastModified": 1781257359, - "narHash": "sha256-J2/PBS+5u6osnWZUB7UTjLaD+S8diM+6hlOWDoX/+bw=", + "lastModified": 1781605133, + "narHash": "sha256-lmxxhcZt3qSHPS3ETDeN2OIZqC4yIa3uVpMXuIR+8Rc=", "owner": "CachyOS", "repo": "kernel-patches", - "rev": "46b45d26b536195f3ee8bc510b96b7fa47567163", + "rev": "3d40b72fc3c40581269f9e7a12433950f2fd6d95", "type": "github" }, "original": { @@ -234,11 +234,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1781463250, - "narHash": "sha256-gYE/0gtSedmA21UWRZ2DA+iXhySh1JGKOssuqejV7cQ=", + "lastModified": 1781725202, + "narHash": "sha256-oKGzXDreg43jHD8JdmmTTiEpX+DN2G6F5/H+n7oHJuU=", "owner": "xddxdd", "repo": "nix-cachyos-kernel", - "rev": "4039d20f1495f2c521e7d12723a0c45348b118e8", + "rev": "a6c0699e9671453603284ab8c2cba92ac8c60788", "type": "github" }, "original": { @@ -250,11 +250,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1781421111, - "narHash": "sha256-2xSTHlKBF5h/tgAeHyQPR/g48qk9ACz7dED3jc3pGKA=", + "lastModified": 1781655698, + "narHash": "sha256-KjVVyysvz3BJhwT2GBsgIXEOrKaxg/iBp2Xxpna4/F4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "7fe8f446d9475534dc54591ccb5c87c1ce6eaf8b", + "rev": "8cf9f7fff1426b388a6421ca0b7bd5eb231d9d8c", "type": "github" }, "original": { @@ -297,11 +297,11 @@ }, "nixpkgs_3": { "locked": { - "lastModified": 1781074563, - "narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=", + "lastModified": 1781577229, + "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", "owner": "nixos", "repo": "nixpkgs", - "rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca", + "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", "type": "github" }, "original": { @@ -357,11 +357,11 @@ "nixpkgs": "nixpkgs_3" }, "locked": { - "lastModified": 1781246162, - "narHash": "sha256-fXCugzDNhaoboraH1j5NJRYWxOS+wXkbxlRkny1u4NU=", + "lastModified": 1781678824, + "narHash": "sha256-XCT0LsCWDg6x7VeqZ3qtBo6O919DCF/TJJdEdsgMvn4=", "owner": "powerofthe69", "repo": "proton-cachyos-nix", - "rev": "12843d42325e2497e53338224b546b98cf22f8fd", + "rev": "c2534336e2b956638fc3afecca7c1819fb52dffc", "type": "github" }, "original": { @@ -501,11 +501,11 @@ ] }, "locked": { - "lastModified": 1781638617, - "narHash": "sha256-sVJ+oryjyNY7K+EI3Ff5u2b9xDbDCbYLcDhe5r85xw0=", + "lastModified": 1781732195, + "narHash": "sha256-QA+DNtZjIepZtQs5OIh2bU3mQxxhEmKwATpl1utwqUA=", "owner": "0xc000022070", "repo": "zen-browser-flake", - "rev": "eab258d9b5c326c49d308fb2f29935101e6d2e02", + "rev": "68060c0abfc9dc024e3693661e11ddf31bbcffbc", "type": "github" }, "original": { From 85c230457bad9cdea3122dab14ccc570cb53eaca Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 18 Jun 2026 19:24:20 +0100 Subject: [PATCH 26/61] hyprland: suppress battle.net activation events that close the launcher Battle.net (non-Steam shortcut, class steam_app_0) spams window-activation events that clear quickshell's HyprlandFocusGrab, instantly closing the launcher / power menu. suppress_event activate activatefocus drops them. Co-Authored-By: Claude Opus 4.8 --- settings/hyprland.nix | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/settings/hyprland.nix b/settings/hyprland.nix index 4347614..5be3e44 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -239,6 +239,14 @@ in tile = true, }) + -- Battle.net (a non-Steam shortcut, so class steam_app_0) spams + -- window-activation events that clear quickshell's focus grab and + -- instantly close the launcher / power menu. Drop those events. + hl.window_rule({ + match = { class = "steam_app_0" }, + suppress_event = "activate activatefocus", + }) + -- Binds local mod = "SUPER" From 0f92b3fbf59a401fd9e760ce39d7466e3aa583bf Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 18 Jun 2026 21:11:06 +0100 Subject: [PATCH 27/61] Disable frigate for now Co-Authored-By: Claude Opus 4.8 --- common.nix | 2 +- services/service-health.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/common.nix b/common.nix index 3bd1a75..d07acc2 100644 --- a/common.nix +++ b/common.nix @@ -22,7 +22,7 @@ ./services/qbittorrent-nox.nix ./services/nginx.nix ./services/go2rtc.nix - ./services/frigate.nix + # ./services/frigate.nix ./services/sonarr.nix ./services/radarr.nix ./services/prowlarr.nix diff --git a/services/service-health.nix b/services/service-health.nix index 9dcd112..58335d5 100644 --- a/services/service-health.nix +++ b/services/service-health.nix @@ -18,7 +18,7 @@ let watched = [ "jellyfin" "sonarr" "radarr" "prowlarr" "bazarr" "qbittorrent-nox" "sabnzbd" "authelia-main" "nginx" - "adguardhome" "crowdsec" "frigate" "go2rtc" + "adguardhome" "crowdsec" "go2rtc" "homepage-dashboard" "cloudflare-dyndns" "gitea-runner-default" ]; From 3396401e9201f373ca648c47b3e878891eae2d1d Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Fri, 19 Jun 2026 04:00:54 +0000 Subject: [PATCH 28/61] Update flake inputs --- flake.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flake.lock b/flake.lock index c49d25a..6114b2d 100644 --- a/flake.lock +++ b/flake.lock @@ -357,11 +357,11 @@ "nixpkgs": "nixpkgs_3" }, "locked": { - "lastModified": 1781678824, - "narHash": "sha256-XCT0LsCWDg6x7VeqZ3qtBo6O919DCF/TJJdEdsgMvn4=", + "lastModified": 1781764732, + "narHash": "sha256-ERUsQETV8+XghwjomyHhvr83eLsp3bAeRnUtR51JfXE=", "owner": "powerofthe69", "repo": "proton-cachyos-nix", - "rev": "c2534336e2b956638fc3afecca7c1819fb52dffc", + "rev": "a5fd20e0a07244f7c29b6583c2747d135f7c0369", "type": "github" }, "original": { @@ -501,11 +501,11 @@ ] }, "locked": { - "lastModified": 1781732195, - "narHash": "sha256-QA+DNtZjIepZtQs5OIh2bU3mQxxhEmKwATpl1utwqUA=", + "lastModified": 1781820312, + "narHash": "sha256-tTFfF/wIXGH9B+m8r0g23UkzkmM3jBgWnqGC4/NFzrs=", "owner": "0xc000022070", "repo": "zen-browser-flake", - "rev": "68060c0abfc9dc024e3693661e11ddf31bbcffbc", + "rev": "f1b189bea5972ceee02628f913f2f342ce96e21e", "type": "github" }, "original": { From d300b9d30dcb64d64c87872708ef216eab99d089 Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Sat, 20 Jun 2026 04:00:52 +0000 Subject: [PATCH 29/61] Update flake inputs --- flake.lock | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/flake.lock b/flake.lock index 6114b2d..3588270 100644 --- a/flake.lock +++ b/flake.lock @@ -71,11 +71,11 @@ "cachyos-kernel": { "flake": false, "locked": { - "lastModified": 1781468456, - "narHash": "sha256-0nJ5RUyUQ/rEz8Lai9I1kLKwpzhnL6KLePSnO4IZh64=", + "lastModified": 1781767284, + "narHash": "sha256-cjIw6fLHT1shDREFkhsMeZeRAYO7z+cnauxmpUkSdp4=", "owner": "CachyOS", "repo": "linux-cachyos", - "rev": "68925a2fb4eb362d4dd6cf2c3e9d85e434a96298", + "rev": "d9b3cd77b1aa6fd4dc4baa3d876a598f884f5472", "type": "github" }, "original": { @@ -234,11 +234,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1781725202, - "narHash": "sha256-oKGzXDreg43jHD8JdmmTTiEpX+DN2G6F5/H+n7oHJuU=", + "lastModified": 1781811723, + "narHash": "sha256-o0Y3TIykOACq7nwwoSzeQOhQhheI7P4x0PvHtUsoXPY=", "owner": "xddxdd", "repo": "nix-cachyos-kernel", - "rev": "a6c0699e9671453603284ab8c2cba92ac8c60788", + "rev": "26da04e24aef2993ea256917be42d18f83ce8e8b", "type": "github" }, "original": { @@ -250,11 +250,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1781655698, - "narHash": "sha256-KjVVyysvz3BJhwT2GBsgIXEOrKaxg/iBp2Xxpna4/F4=", + "lastModified": 1781793837, + "narHash": "sha256-T9/Q/A5B/Q1hC65fdwCz0aKVN7u1MDXGQXMKgoMQ1Hw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8cf9f7fff1426b388a6421ca0b7bd5eb231d9d8c", + "rev": "05eced6879632fc2f62fec56f2e33269157984ef", "type": "github" }, "original": { @@ -357,11 +357,11 @@ "nixpkgs": "nixpkgs_3" }, "locked": { - "lastModified": 1781764732, - "narHash": "sha256-ERUsQETV8+XghwjomyHhvr83eLsp3bAeRnUtR51JfXE=", + "lastModified": 1781851732, + "narHash": "sha256-71dBgM5OpLTkNI/DSdWbQfuMxXsg1NxSwZ4WczudThk=", "owner": "powerofthe69", "repo": "proton-cachyos-nix", - "rev": "a5fd20e0a07244f7c29b6583c2747d135f7c0369", + "rev": "d56612c0b933a5b729fc57daf0f3faed9a2bebb8", "type": "github" }, "original": { @@ -501,11 +501,11 @@ ] }, "locked": { - "lastModified": 1781820312, - "narHash": "sha256-tTFfF/wIXGH9B+m8r0g23UkzkmM3jBgWnqGC4/NFzrs=", + "lastModified": 1781896963, + "narHash": "sha256-77IYuReU5V9smjSB4hXBQb6in8ljg6XhJ8Ld66S4L0I=", "owner": "0xc000022070", "repo": "zen-browser-flake", - "rev": "f1b189bea5972ceee02628f913f2f342ce96e21e", + "rev": "8c5b06ac3d7157ed46ed770cc1afead935d99c1e", "type": "github" }, "original": { From 4d328af16bc9af5305a4a59ca86d43a4f056b5fd Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Sun, 21 Jun 2026 04:01:00 +0000 Subject: [PATCH 30/61] Update flake inputs --- flake.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flake.lock b/flake.lock index 3588270..b339e8b 100644 --- a/flake.lock +++ b/flake.lock @@ -211,11 +211,11 @@ ] }, "locked": { - "lastModified": 1781319724, - "narHash": "sha256-ZGuxexEMo4Xv28KJ0dX/m/PHN4oZIOnxHZpNTyrvx4M=", + "lastModified": 1781981105, + "narHash": "sha256-/1nNBbA7PrSQpTc9Qazkhl4kIPg+TNl0CjxS3UQJKlw=", "owner": "nix-community", "repo": "home-manager", - "rev": "8355f0a16b2dbb06a97959a918af5b239bbe05ae", + "rev": "7bfff44b465909f69a442701293bc0badcf476dc", "type": "github" }, "original": { @@ -501,11 +501,11 @@ ] }, "locked": { - "lastModified": 1781896963, - "narHash": "sha256-77IYuReU5V9smjSB4hXBQb6in8ljg6XhJ8Ld66S4L0I=", + "lastModified": 1782012261, + "narHash": "sha256-3uA/FtAVbmBIL3kUs6ZDUtf/ZKjLmHljDQZWSKuJwx0=", "owner": "0xc000022070", "repo": "zen-browser-flake", - "rev": "8c5b06ac3d7157ed46ed770cc1afead935d99c1e", + "rev": "6593c895a25b4bf166f596987f2cfabc63a86c0d", "type": "github" }, "original": { From 4807be6cb0212b2201038469278b3734cb23c652 Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Mon, 22 Jun 2026 04:00:53 +0000 Subject: [PATCH 31/61] Update flake inputs --- flake.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/flake.lock b/flake.lock index b339e8b..766fcb0 100644 --- a/flake.lock +++ b/flake.lock @@ -71,11 +71,11 @@ "cachyos-kernel": { "flake": false, "locked": { - "lastModified": 1781767284, - "narHash": "sha256-cjIw6fLHT1shDREFkhsMeZeRAYO7z+cnauxmpUkSdp4=", + "lastModified": 1781883168, + "narHash": "sha256-raAojJGk0aWdscfFn/9ikZ6V5oUuAZcAz5kjAZ2QN3E=", "owner": "CachyOS", "repo": "linux-cachyos", - "rev": "d9b3cd77b1aa6fd4dc4baa3d876a598f884f5472", + "rev": "daed450e9b1a4fadfef68fb4fa5e2f3391fedb34", "type": "github" }, "original": { @@ -87,11 +87,11 @@ "cachyos-kernel-patches": { "flake": false, "locked": { - "lastModified": 1781605133, - "narHash": "sha256-lmxxhcZt3qSHPS3ETDeN2OIZqC4yIa3uVpMXuIR+8Rc=", + "lastModified": 1781953785, + "narHash": "sha256-YgEE1a5QdKd47AfRoU5G8nm0gGzeCuPN2emupNDMQcc=", "owner": "CachyOS", "repo": "kernel-patches", - "rev": "3d40b72fc3c40581269f9e7a12433950f2fd6d95", + "rev": "e8e9d325eea25f5664e045787c19baac661828de", "type": "github" }, "original": { @@ -234,11 +234,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1781811723, - "narHash": "sha256-o0Y3TIykOACq7nwwoSzeQOhQhheI7P4x0PvHtUsoXPY=", + "lastModified": 1782068713, + "narHash": "sha256-Vujeg1QyOCnEMCNV1U7aFDllD0w2lEl8c0uJyKM+cOI=", "owner": "xddxdd", "repo": "nix-cachyos-kernel", - "rev": "26da04e24aef2993ea256917be42d18f83ce8e8b", + "rev": "756ed060ca6adcdf3e65371e3725b89c58a1354d", "type": "github" }, "original": { @@ -250,11 +250,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1781793837, - "narHash": "sha256-T9/Q/A5B/Q1hC65fdwCz0aKVN7u1MDXGQXMKgoMQ1Hw=", + "lastModified": 1782014548, + "narHash": "sha256-zYKx9xcbPvk4zOzkny3w2/AkuKhJqGwRD+piA3urkx8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "05eced6879632fc2f62fec56f2e33269157984ef", + "rev": "72349305fb839e27697873de7a4ce3a98c378f48", "type": "github" }, "original": { From 00e02c28ff579d0f51f25e9c71d6a5cd53a420fb Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Tue, 23 Jun 2026 04:00:51 +0000 Subject: [PATCH 32/61] Update flake inputs --- flake.lock | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/flake.lock b/flake.lock index 766fcb0..b63f2aa 100644 --- a/flake.lock +++ b/flake.lock @@ -234,11 +234,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1782068713, - "narHash": "sha256-Vujeg1QyOCnEMCNV1U7aFDllD0w2lEl8c0uJyKM+cOI=", + "lastModified": 1782159764, + "narHash": "sha256-255zJue+y2e3nfTj8WJgn9/YWc5ntyrNSu8iUVAegT8=", "owner": "xddxdd", "repo": "nix-cachyos-kernel", - "rev": "756ed060ca6adcdf3e65371e3725b89c58a1354d", + "rev": "f0b6b9acd227ed4822b0dfa55998919a55d466e6", "type": "github" }, "original": { @@ -250,11 +250,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1782014548, - "narHash": "sha256-zYKx9xcbPvk4zOzkny3w2/AkuKhJqGwRD+piA3urkx8=", + "lastModified": 1782145344, + "narHash": "sha256-8cz8/2hWTYdsPcpqWXa/IAkyjw1xBv3tx2UPNzkpe3c=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "72349305fb839e27697873de7a4ce3a98c378f48", + "rev": "8dc49b8b206a683d1f6605e0fd993c0f5d49c98d", "type": "github" }, "original": { @@ -281,11 +281,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1781216227, - "narHash": "sha256-9mUW6gNwoN2SWc/l0fW4svPNOulXLl8ijqKyeSOGgJE=", + "lastModified": 1781808408, + "narHash": "sha256-0sOb6OIaD/K1zHxBhpmlKvkADfe0n8+l4Jj2e1Q0r3w=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "a0374025a863d007d98e3297f6aa46cc3141c2f0", + "rev": "e8210c649915deed7080033cdbabcc19e40bb899", "type": "github" }, "original": { @@ -357,11 +357,11 @@ "nixpkgs": "nixpkgs_3" }, "locked": { - "lastModified": 1781851732, - "narHash": "sha256-71dBgM5OpLTkNI/DSdWbQfuMxXsg1NxSwZ4WczudThk=", + "lastModified": 1782185495, + "narHash": "sha256-wqdNdpcNtFIRd7K5LAepIRDfRvNGmFcXHuS7GCDZANU=", "owner": "powerofthe69", "repo": "proton-cachyos-nix", - "rev": "d56612c0b933a5b729fc57daf0f3faed9a2bebb8", + "rev": "4c31ef56c48c3276f6692f979404ef8a2113cea1", "type": "github" }, "original": { @@ -501,11 +501,11 @@ ] }, "locked": { - "lastModified": 1782012261, - "narHash": "sha256-3uA/FtAVbmBIL3kUs6ZDUtf/ZKjLmHljDQZWSKuJwx0=", + "lastModified": 1782144240, + "narHash": "sha256-RgCWSv7AJZCwPhCzz+J0lvwp1WBz9ouvCnnlmvu0xfw=", "owner": "0xc000022070", "repo": "zen-browser-flake", - "rev": "6593c895a25b4bf166f596987f2cfabc63a86c0d", + "rev": "d1693556428967f8b4eef128feb090421ddcaf15", "type": "github" }, "original": { From 1ed7cda25c71760b030111cd35f5f065bc4ca722 Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Wed, 24 Jun 2026 04:00:54 +0000 Subject: [PATCH 33/61] Update flake inputs --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index b63f2aa..19a4e7a 100644 --- a/flake.lock +++ b/flake.lock @@ -281,11 +281,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1781808408, - "narHash": "sha256-0sOb6OIaD/K1zHxBhpmlKvkADfe0n8+l4Jj2e1Q0r3w=", + "lastModified": 1782116945, + "narHash": "sha256-G3tw/IXmaH6IQ2upZvhuN9sG8CkuX+BLuJDpE8hz0Ds=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e8210c649915deed7080033cdbabcc19e40bb899", + "rev": "34268251cf5547d39063f2c5ea9a196246f7f3a6", "type": "github" }, "original": { From 5e870d0e8b3660e72dd43a56a70f16d7e620e31f Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 24 Jun 2026 21:35:37 +0100 Subject: [PATCH 34/61] arr-interconnect: auto-add Jellyfin library-refresh notification to Sonarr/Radarr Co-Authored-By: Claude Opus 4.8 --- services/arr-interconnect.nix | 83 ++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/services/arr-interconnect.nix b/services/arr-interconnect.nix index 66440df..7e8293e 100644 --- a/services/arr-interconnect.nix +++ b/services/arr-interconnect.nix @@ -2,7 +2,7 @@ let interconnectScript = pkgs.writeShellScript "arr-interconnect" '' set -euo pipefail - PATH="${lib.makeBinPath [ pkgs.curl pkgs.jq pkgs.gnused pkgs.gnugrep pkgs.coreutils pkgs.systemd ]}:$PATH" + PATH="${lib.makeBinPath [ pkgs.curl pkgs.jq pkgs.gnused pkgs.gnugrep pkgs.coreutils pkgs.systemd pkgs.sqlite ]}:$PATH" BASE="http://127.0.0.1" @@ -30,6 +30,14 @@ let SABNZBD_KEY=$(grep -oP '^api_key\s*=\s*\K\S+' /var/lib/sabnzbd/sabnzbd.ini | head -n1 || true) fi + # Jellyfin has no config.xml api key; any AccessToken in its db works as + # an API key. Reuse the first one (create one in the Jellyfin UI once if + # the table is empty — same first-run caveat as SAB above). + JELLYFIN_KEY="" + if [ -f "/var/lib/jellyfin/data/jellyfin.db" ]; then + JELLYFIN_KEY=$(sqlite3 /var/lib/jellyfin/data/jellyfin.db "SELECT AccessToken FROM ApiKeys LIMIT 1;" 2>/dev/null || true) + fi + # --- Helpers --- wait_for() { local name="$1" url="$2" key="$3" @@ -341,6 +349,79 @@ let fi fi + ########################################################################## + # Sonarr → Jellyfin (refresh library on import so new shows appear + # without waiting for Jellyfin's flaky filesystem watcher / full scan) + ########################################################################## + if [ -n "$SONARR_KEY" ] && [ -n "$JELLYFIN_KEY" ]; then + if ! exists_by_name "$BASE:8989/api/v3/notification" "$SONARR_KEY" "Jellyfin"; then + echo "Adding Jellyfin notification to Sonarr..." + curl -sf -X POST \ + -H "Content-Type: application/json" \ + -H "X-Api-Key: $SONARR_KEY" \ + "$BASE:8989/api/v3/notification" \ + -d "$(jq -n --arg key "$JELLYFIN_KEY" '{ + name: "Jellyfin", + implementation: "MediaBrowser", + configContract: "MediaBrowserSettings", + implementationName: "Emby / Jellyfin", + onDownload: true, + onUpgrade: true, + onRename: true, + onSeriesDelete: true, + onEpisodeFileDelete: true, + onEpisodeFileDeleteForUpgrade: true, + fields: [ + {name: "host", value: "localhost"}, + {name: "port", value: 8096}, + {name: "useSsl", value: false}, + {name: "apiKey", value: $key}, + {name: "notify", value: false}, + {name: "updateLibrary", value: true} + ], + tags: [] + }')" > /dev/null && echo " done" || echo " failed" + else + echo "Sonarr → Jellyfin already configured" + fi + fi + + ########################################################################## + # Radarr → Jellyfin (refresh library on import) + ########################################################################## + if [ -n "$RADARR_KEY" ] && [ -n "$JELLYFIN_KEY" ]; then + if ! exists_by_name "$BASE:7878/api/v3/notification" "$RADARR_KEY" "Jellyfin"; then + echo "Adding Jellyfin notification to Radarr..." + curl -sf -X POST \ + -H "Content-Type: application/json" \ + -H "X-Api-Key: $RADARR_KEY" \ + "$BASE:7878/api/v3/notification" \ + -d "$(jq -n --arg key "$JELLYFIN_KEY" '{ + name: "Jellyfin", + implementation: "MediaBrowser", + configContract: "MediaBrowserSettings", + implementationName: "Emby / Jellyfin", + onDownload: true, + onUpgrade: true, + onRename: true, + onMovieDelete: true, + onMovieFileDelete: true, + onMovieFileDeleteForUpgrade: true, + fields: [ + {name: "host", value: "localhost"}, + {name: "port", value: 8096}, + {name: "useSsl", value: false}, + {name: "apiKey", value: $key}, + {name: "notify", value: false}, + {name: "updateLibrary", value: true} + ], + tags: [] + }')" > /dev/null && echo " done" || echo " failed" + else + echo "Radarr → Jellyfin already configured" + fi + fi + ########################################################################## # Prowlarr auth — trust localhost so Authelia is the only gate. Other # *arr apps default to this; Prowlarr does not. From cdf5184a52fe7f3c84173f438b2ce0d0f371759b Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Thu, 25 Jun 2026 04:00:55 +0000 Subject: [PATCH 35/61] Update flake inputs --- flake.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/flake.lock b/flake.lock index 19a4e7a..ccd38b9 100644 --- a/flake.lock +++ b/flake.lock @@ -87,11 +87,11 @@ "cachyos-kernel-patches": { "flake": false, "locked": { - "lastModified": 1781953785, - "narHash": "sha256-YgEE1a5QdKd47AfRoU5G8nm0gGzeCuPN2emupNDMQcc=", + "lastModified": 1782242233, + "narHash": "sha256-AUwTZq++PBq0qjDVFKqD0AZNNwa0b1RK41bM9XMbkW8=", "owner": "CachyOS", "repo": "kernel-patches", - "rev": "e8e9d325eea25f5664e045787c19baac661828de", + "rev": "19250dcc39862169961756c733b8a6ba77754c22", "type": "github" }, "original": { @@ -234,11 +234,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1782159764, - "narHash": "sha256-255zJue+y2e3nfTj8WJgn9/YWc5ntyrNSu8iUVAegT8=", + "lastModified": 1782328582, + "narHash": "sha256-UVOavLm7rSOXVlVaI4nlszpFPG109iQVxhIJ5UxXRCA=", "owner": "xddxdd", "repo": "nix-cachyos-kernel", - "rev": "f0b6b9acd227ed4822b0dfa55998919a55d466e6", + "rev": "3ea1942599d8d0a124bdb9ec1304b3e6f63e8b1f", "type": "github" }, "original": { @@ -250,11 +250,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1782145344, - "narHash": "sha256-8cz8/2hWTYdsPcpqWXa/IAkyjw1xBv3tx2UPNzkpe3c=", + "lastModified": 1782291881, + "narHash": "sha256-8dEV/c6gqHOSeRO1hIo/XhiHZ6NgBer1wrw+f+Vmw+o=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8dc49b8b206a683d1f6605e0fd993c0f5d49c98d", + "rev": "532f984da08d27af048ed8664238f97f38ede850", "type": "github" }, "original": { From 448e44753f58af1f0c828dd0300a0d006990e50d Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 25 Jun 2026 10:07:31 +0100 Subject: [PATCH 36/61] neko: Guild Wars in a browser (Xfce+Wine+NVIDIA), Authelia-gated Co-Authored-By: Claude Opus 4.8 --- common.nix | 1 + ports.toml | 5 +++ services/neko.nix | 85 ++++++++++++++++++++++++++++++++++++++++++++++ services/nginx.nix | 1 + 4 files changed, 92 insertions(+) create mode 100644 services/neko.nix diff --git a/common.nix b/common.nix index d07acc2..1dbcbaf 100644 --- a/common.nix +++ b/common.nix @@ -42,6 +42,7 @@ ./services/forgejo-runner.nix ./services/code-server.nix ./services/memos.nix + ./services/neko.nix ]; ### Make build time quicker diff --git a/ports.toml b/ports.toml index ac7f48d..3cf5547 100644 --- a/ports.toml +++ b/ports.toml @@ -45,5 +45,10 @@ name = "7DTD-coop voice/dynamic" ports = "26911-26912" protocol = "udp" +[[forward]] +name = "Neko WebRTC" +port = 59000 +protocol = "udp" + # DR (Dungeon Runners) forwards removed — services/dr-server.nix is disabled. # Re-add 2110 tcp, 2603 both, 2604-2605 udp, 2606 tcp if it comes back. diff --git a/services/neko.nix b/services/neko.nix new file mode 100644 index 0000000..1a455f1 --- /dev/null +++ b/services/neko.nix @@ -0,0 +1,85 @@ +# services/neko.nix — Guild Wars (2005) in a browser via Neko +# +# Streams an Xfce desktop running the Windows Guild Wars client (under Wine) +# to a browser tab over WebRTC. Reach it at neko.nordhammer.it (Authelia-gated). +# +# Neko's stock images don't ship Wine, and apt installs land in /usr — which is +# wiped whenever the container is recreated. So we bake Wine into a locally-built +# image (FROM the upstream nvidia-xfce base) instead of relying on a volume. +# Guild Wars' own data installs into the persistent /home/neko volume on first run. +# +# GPU: uses the host's NVIDIA 535 driver via the container toolkit (CDI). The +# Quadro M2000 does the GL rendering and NVENC video encode. If you ever see a +# black screen or no video, the NVIDIA capabilities exposed to the container +# (graphics + video) are the first thing to check — verify with: +# docker run --rm --device=nvidia.com/gpu=all neko-gw:local nvidia-smi +{ config, pkgs, lib, ... }: +{ + config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { + + virtualisation.docker.enable = true; + # Expose the host NVIDIA driver to containers via CDI (nvidia.com/gpu=all). + hardware.nvidia-container-toolkit.enable = true; + + systemd.tmpfiles.rules = [ + "d /var/lib/neko 0755 root root -" + "d /var/lib/neko/home 0755 root root -" + ]; + + # Dockerfile for the Wine-enabled image, built on first start (see below). + environment.etc."neko-gw/Dockerfile".text = '' + FROM ghcr.io/m1k1o/neko/nvidia-xfce:latest + USER root + RUN dpkg --add-architecture i386 \ + && apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + wine wine64 wine32 winbind cabextract winetricks ca-certificates wget \ + && rm -rf /var/lib/apt/lists/* + ''; + + systemd.services.neko = { + description = "Neko — Guild Wars in a browser (Xfce + Wine + NVIDIA)"; + after = [ "docker.service" "network-online.target" ]; + requires = [ "docker.service" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + # First start pulls a multi-GB base image and runs apt — give it room. + # If it fails, back off but don't crash-loop (see 7dtd veth-flood note). + startLimitIntervalSec = 600; + startLimitBurst = 3; + + serviceConfig = { + Restart = "on-failure"; + RestartSec = "30s"; + TimeoutStartSec = "3600"; + ExecStartPre = [ + "-${pkgs.docker}/bin/docker rm -f neko" + "${pkgs.docker}/bin/docker build -t neko-gw:local /etc/neko-gw" + ]; + # Wrapped in a shell script so the ICE-server JSON survives quoting + # (systemd's own ExecStart parser would strip the inner double quotes). + ExecStart = pkgs.writeShellScript "neko-run" '' + exec ${pkgs.docker}/bin/docker run --rm --name neko \ + --device=nvidia.com/gpu=all \ + -e NVIDIA_VISIBLE_DEVICES=all \ + -e NVIDIA_DRIVER_CAPABILITIES=all \ + --shm-size=1g \ + -p 127.0.0.1:8092:8080 \ + -p 59000:59000/udp \ + -e NEKO_DESKTOP_SCREEN=1280x720@30 \ + -e NEKO_MEMBER_PROVIDER=multiuser \ + -e NEKO_MEMBER_MULTIUSER_USER_PASSWORD=neko \ + -e NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD=neko-admin \ + -e NEKO_WEBRTC_UDPMUX=59000 \ + -e NEKO_WEBRTC_NAT1TO1=10.0.0.1 \ + -e 'NEKO_WEBRTC_ICESERVERS_FRONTEND=[{"urls":["stun:stun.l.google.com:19302"]}]' \ + -e 'NEKO_WEBRTC_ICESERVERS_BACKEND=[{"urls":["stun:stun.l.google.com:19302"]}]' \ + -v /var/lib/neko/home:/home/neko \ + neko-gw:local + ''; + ExecStop = "${pkgs.docker}/bin/docker stop neko"; + }; + }; + }; +} diff --git a/services/nginx.nix b/services/nginx.nix index c340533..0487df1 100644 --- a/services/nginx.nix +++ b/services/nginx.nix @@ -123,6 +123,7 @@ in ''; }; "notes.nordhammer.it" = protectedProxy 5230; + "neko.nordhammer.it" = protectedProxy 8092; # --- Local-only: serves update history JSON to Homepage's customapi widget --- "homepage-updates.local" = { From fe0cb4663eb4dc17ef9c4417a6bd00cc0a8ea2fd Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 25 Jun 2026 10:27:49 +0100 Subject: [PATCH 37/61] neko: add Authelia access rule for neko.nordhammer.it Co-Authored-By: Claude Opus 4.8 --- services/authelia.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/services/authelia.nix b/services/authelia.nix index 0d3f0f7..b1823e2 100644 --- a/services/authelia.nix +++ b/services/authelia.nix @@ -41,6 +41,7 @@ { domain = "sabnzbd.nordhammer.it"; policy = "one_factor"; } { domain = "code.nordhammer.it"; policy = "one_factor"; } { domain = "notes.nordhammer.it"; policy = "one_factor"; } + { domain = "neko.nordhammer.it"; policy = "one_factor"; } ]; }; From e199933dcec7d554c9ee2d8c77f57428544b75d2 Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 25 Jun 2026 10:31:31 +0100 Subject: [PATCH 38/61] neko: build image from stdin Dockerfile (fix symlinked-context build failure) Co-Authored-By: Claude Opus 4.8 --- services/neko.nix | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/services/neko.nix b/services/neko.nix index 1a455f1..a802b39 100644 --- a/services/neko.nix +++ b/services/neko.nix @@ -14,6 +14,20 @@ # (graphics + video) are the first thing to check — verify with: # docker run --rm --device=nvidia.com/gpu=all neko-gw:local nvidia-smi { config, pkgs, lib, ... }: +let + # Wine-enabled image definition. Fed to `docker build` over stdin (see below) + # so there's no build context — we have no COPY/ADD, and a Nix-store symlinked + # context dir breaks BuildKit's Dockerfile resolution. + dockerfile = pkgs.writeText "neko-gw.Dockerfile" '' + FROM ghcr.io/m1k1o/neko/nvidia-xfce:latest + USER root + RUN dpkg --add-architecture i386 \ + && apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + wine wine64 wine32 winbind cabextract winetricks ca-certificates wget \ + && rm -rf /var/lib/apt/lists/* + ''; +in { config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { @@ -26,17 +40,6 @@ "d /var/lib/neko/home 0755 root root -" ]; - # Dockerfile for the Wine-enabled image, built on first start (see below). - environment.etc."neko-gw/Dockerfile".text = '' - FROM ghcr.io/m1k1o/neko/nvidia-xfce:latest - USER root - RUN dpkg --add-architecture i386 \ - && apt-get update \ - && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - wine wine64 wine32 winbind cabextract winetricks ca-certificates wget \ - && rm -rf /var/lib/apt/lists/* - ''; - systemd.services.neko = { description = "Neko — Guild Wars in a browser (Xfce + Wine + NVIDIA)"; after = [ "docker.service" "network-online.target" ]; @@ -55,7 +58,9 @@ TimeoutStartSec = "3600"; ExecStartPre = [ "-${pkgs.docker}/bin/docker rm -f neko" - "${pkgs.docker}/bin/docker build -t neko-gw:local /etc/neko-gw" + "${pkgs.writeShellScript "neko-build" '' + exec ${pkgs.docker}/bin/docker build -t neko-gw:local - < ${dockerfile} + ''}" ]; # Wrapped in a shell script so the ICE-server JSON survives quoting # (systemd's own ExecStart parser would strip the inner double quotes). From e5589907a38b14500ae2d68e99139dd420013d98 Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 25 Jun 2026 10:35:51 +0100 Subject: [PATCH 39/61] neko: use real xfce image (software render), drop nonexistent nvidia-xfce + GPU Co-Authored-By: Claude Opus 4.8 --- services/neko.nix | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/services/neko.nix b/services/neko.nix index a802b39..426546d 100644 --- a/services/neko.nix +++ b/services/neko.nix @@ -8,18 +8,19 @@ # image (FROM the upstream nvidia-xfce base) instead of relying on a volume. # Guild Wars' own data installs into the persistent /home/neko volume on first run. # -# GPU: uses the host's NVIDIA 535 driver via the container toolkit (CDI). The -# Quadro M2000 does the GL rendering and NVENC video encode. If you ever see a -# black screen or no video, the NVIDIA capabilities exposed to the container -# (graphics + video) are the first thing to check — verify with: -# docker run --rm --device=nvidia.com/gpu=all neko-gw:local nvidia-smi +# Rendering is software-only (no GPU): neko doesn't ship a prebuilt NVIDIA Xfce +# desktop image, and building one from nvidia-base is a big detour for a 2005 +# game. Wine renders via llvmpipe (software OpenGL) and neko encodes via x264 — +# both are heavily multithreaded and this box has 56 Xeon threads to spare, so +# Guild Wars is comfortably playable this way. { config, pkgs, lib, ... }: let # Wine-enabled image definition. Fed to `docker build` over stdin (see below) # so there's no build context — we have no COPY/ADD, and a Nix-store symlinked - # context dir breaks BuildKit's Dockerfile resolution. + # context dir breaks BuildKit's Dockerfile resolution. Pinned to the v3.1 + # series so the NEKO_MEMBER_*/NEKO_WEBRTC_* env schema below stays valid. dockerfile = pkgs.writeText "neko-gw.Dockerfile" '' - FROM ghcr.io/m1k1o/neko/nvidia-xfce:latest + FROM ghcr.io/m1k1o/neko/xfce:3.1 USER root RUN dpkg --add-architecture i386 \ && apt-get update \ @@ -32,8 +33,6 @@ in config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { virtualisation.docker.enable = true; - # Expose the host NVIDIA driver to containers via CDI (nvidia.com/gpu=all). - hardware.nvidia-container-toolkit.enable = true; systemd.tmpfiles.rules = [ "d /var/lib/neko 0755 root root -" @@ -66,9 +65,6 @@ in # (systemd's own ExecStart parser would strip the inner double quotes). ExecStart = pkgs.writeShellScript "neko-run" '' exec ${pkgs.docker}/bin/docker run --rm --name neko \ - --device=nvidia.com/gpu=all \ - -e NVIDIA_VISIBLE_DEVICES=all \ - -e NVIDIA_DRIVER_CAPABILITIES=all \ --shm-size=1g \ -p 127.0.0.1:8092:8080 \ -p 59000:59000/udp \ From b00dee9dc6667d10a28f3d2e85ea6e72799134c5 Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 25 Jun 2026 10:39:20 +0100 Subject: [PATCH 40/61] neko: drop winetricks (not in Debian trixie main; GW needs only bare wine) Co-Authored-By: Claude Opus 4.8 --- services/neko.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/neko.nix b/services/neko.nix index 426546d..db5cb5f 100644 --- a/services/neko.nix +++ b/services/neko.nix @@ -25,7 +25,7 @@ let RUN dpkg --add-architecture i386 \ && apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - wine wine64 wine32 winbind cabextract winetricks ca-certificates wget \ + wine wine64 wine32 winbind ca-certificates wget \ && rm -rf /var/lib/apt/lists/* ''; in From cb9a03cbf450b9fcb21ee3041fa328961733c463 Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 25 Jun 2026 10:40:40 +0100 Subject: [PATCH 41/61] Add mcp-nixos MCP server (nix run) --- .mcp.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..24bc471 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "nixos": { + "type": "stdio", + "command": "nix", + "args": [ + "run", + "github:utensils/mcp-nixos", + "--" + ], + "env": {} + } + } +} \ No newline at end of file From c0ed58bcc233b60a1d8087e33cb03407ee464c9d Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 25 Jun 2026 10:42:13 +0100 Subject: [PATCH 42/61] neko: own /var/lib/neko/home as uid 1000 so the container desktop can start Co-Authored-By: Claude Opus 4.8 --- services/neko.nix | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/neko.nix b/services/neko.nix index db5cb5f..8bd501f 100644 --- a/services/neko.nix +++ b/services/neko.nix @@ -36,7 +36,10 @@ in systemd.tmpfiles.rules = [ "d /var/lib/neko 0755 root root -" - "d /var/lib/neko/home 0755 root root -" + # The container's neko user is uid/gid 1000 and must own its home, or the + # X server / Xfce can't create ~/.config, ~/.cache, etc. and the desktop + # never starts. + "d /var/lib/neko/home 0755 1000 1000 -" ]; systemd.services.neko = { From 370b69bd5aac93243c4230cea0d5fdb0c0f0aca9 Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 25 Jun 2026 10:42:22 +0100 Subject: [PATCH 43/61] Document: verify Nix options via nixos MCP before writing --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 8287bfe..b3dde06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,11 @@ That means evaluation is **pure**: config can never read files outside the repo ## Code Evaluation +Before writing or changing any NixOS / Home Manager option, verify it exists and +has the expected name and type using the `nixos` MCP server tools (`nix` / +`nix_versions`, configured in `.mcp.json`). Don't rely on memory for option or +package names — look them up first to avoid invented attributes that fail at eval. + Always validate Nix expressions with `nix eval` before committing. For example: ```bash From 38901eee27c60f466052bfcf45b1530e001c7c70 Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 25 Jun 2026 10:56:33 +0100 Subject: [PATCH 44/61] neko: add Mesa GL (i386) so Wine/Guild Wars gets an OpenGL context (llvmpipe) Co-Authored-By: Claude Opus 4.8 --- services/neko.nix | 3 +++ 1 file changed, 3 insertions(+) diff --git a/services/neko.nix b/services/neko.nix index 8bd501f..46e04cf 100644 --- a/services/neko.nix +++ b/services/neko.nix @@ -26,6 +26,8 @@ let && apt-get update \ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ wine wine64 wine32 winbind ca-certificates wget \ + libgl1 libgl1:i386 libglx-mesa0 libglx-mesa0:i386 \ + libgl1-mesa-dri libgl1-mesa-dri:i386 \ && rm -rf /var/lib/apt/lists/* ''; in @@ -69,6 +71,7 @@ in ExecStart = pkgs.writeShellScript "neko-run" '' exec ${pkgs.docker}/bin/docker run --rm --name neko \ --shm-size=1g \ + -e LIBGL_ALWAYS_SOFTWARE=1 \ -p 127.0.0.1:8092:8080 \ -p 59000:59000/udp \ -e NEKO_DESKTOP_SCREEN=1280x720@30 \ From d31a4501f148b748a923556a2fb8681b7cef1c2d Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 25 Jun 2026 11:42:45 +0100 Subject: [PATCH 45/61] selkies: browser game streaming for GW (pointer-lock relative mouse), retire neko Co-Authored-By: Claude Opus 4.8 --- common.nix | 3 +- services/authelia.nix | 2 +- services/nginx.nix | 2 +- services/selkies.nix | 85 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 services/selkies.nix diff --git a/common.nix b/common.nix index 1dbcbaf..b745c30 100644 --- a/common.nix +++ b/common.nix @@ -42,7 +42,8 @@ ./services/forgejo-runner.nix ./services/code-server.nix ./services/memos.nix - ./services/neko.nix + # ./services/neko.nix # superseded by selkies.nix (Neko can't handle GW's mouse grab) + ./services/selkies.nix ]; ### Make build time quicker diff --git a/services/authelia.nix b/services/authelia.nix index b1823e2..92b0e0d 100644 --- a/services/authelia.nix +++ b/services/authelia.nix @@ -41,7 +41,7 @@ { domain = "sabnzbd.nordhammer.it"; policy = "one_factor"; } { domain = "code.nordhammer.it"; policy = "one_factor"; } { domain = "notes.nordhammer.it"; policy = "one_factor"; } - { domain = "neko.nordhammer.it"; policy = "one_factor"; } + { domain = "selkies.nordhammer.it"; policy = "one_factor"; } ]; }; diff --git a/services/nginx.nix b/services/nginx.nix index 0487df1..ac98d9e 100644 --- a/services/nginx.nix +++ b/services/nginx.nix @@ -123,7 +123,7 @@ in ''; }; "notes.nordhammer.it" = protectedProxy 5230; - "neko.nordhammer.it" = protectedProxy 8092; + "selkies.nordhammer.it" = protectedProxy 8093; # --- Local-only: serves update history JSON to Homepage's customapi widget --- "homepage-updates.local" = { diff --git a/services/selkies.nix b/services/selkies.nix new file mode 100644 index 0000000..9230b44 --- /dev/null +++ b/services/selkies.nix @@ -0,0 +1,85 @@ +# services/selkies.nix — Guild Wars in a browser via Selkies +# +# Replaces the Neko attempt (services/neko.nix, now unimported): Neko's +# absolute-pointer input model can't handle Guild Wars' exclusive mouse grab. +# Selkies captures the mouse client-side with the browser Pointer Lock API and +# sends *relative* movement, so the grab is a non-issue — and it uses the GPU +# (NVENC + EGL) instead of software rendering. +# +# Reach it at selkies.nordhammer.it (Authelia-gated). The Wine prefix with +# Guild Wars already installed is reused from the old Neko home, seeded into +# /var/lib/selkies/home/.wine (see the deploy note in the repo). +{ config, pkgs, lib, ... }: +let + # Selkies' NVIDIA EGL desktop (Ubuntu 24.04) plus Wine for the 32-bit GW + # client. Built from stdin (no build context); see neko.nix for the why. + dockerfile = pkgs.writeText "selkies-gw.Dockerfile" '' + FROM ghcr.io/selkies-project/nvidia-egl-desktop:24.04 + USER root + RUN add-apt-repository -y multiverse \ + && dpkg --add-architecture i386 \ + && apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + wine wine32 wine64 winbind cabextract ca-certificates wget \ + && rm -rf /var/lib/apt/lists/* + USER 1000 + ''; +in +{ + config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { + + virtualisation.docker.enable = true; + # GPU into the container (CDI: nvidia.com/gpu=all) + 32-bit host GL libs so + # the toolkit can expose them to the 32-bit Wine/GW OpenGL stack. + hardware.nvidia-container-toolkit.enable = true; + hardware.graphics.enable32Bit = true; + + systemd.tmpfiles.rules = [ + "d /var/lib/selkies 0755 root root -" + # Container user is uid/gid 1000 and must own its home. + "d /var/lib/selkies/home 0755 1000 1000 -" + ]; + + systemd.services.selkies = { + description = "Selkies — Guild Wars in a browser (EGL desktop + Wine + NVENC)"; + after = [ "docker.service" "network-online.target" ]; + requires = [ "docker.service" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + startLimitIntervalSec = 600; + startLimitBurst = 3; + + serviceConfig = { + Restart = "on-failure"; + RestartSec = "30s"; + TimeoutStartSec = "3600"; + ExecStartPre = [ + "-${pkgs.docker}/bin/docker rm -f selkies" + "${pkgs.writeShellScript "selkies-build" '' + exec ${pkgs.docker}/bin/docker build -t selkies-gw:local - < ${dockerfile} + ''}" + ]; + ExecStart = pkgs.writeShellScript "selkies-run" '' + exec ${pkgs.docker}/bin/docker run --rm --name selkies \ + --device=nvidia.com/gpu=all \ + -e NVIDIA_VISIBLE_DEVICES=all \ + -e NVIDIA_DRIVER_CAPABILITIES=all \ + --shm-size=2g \ + -p 127.0.0.1:8093:8080 \ + -e TZ=Europe/Stockholm \ + -e DISPLAY_SIZEW=1280 -e DISPLAY_SIZEH=720 \ + -e DISPLAY_REFRESH=30 -e DISPLAY_DPI=96 -e DISPLAY_CDEPTH=24 \ + -e PASSWD=selkies \ + -e SELKIES_ENCODER=nvh264enc \ + -e SELKIES_VIDEO_BITRATE=8000 \ + -e SELKIES_FRAMERATE=30 \ + -e SELKIES_ENABLE_BASIC_AUTH=false \ + -v /var/lib/selkies/home:/home/ubuntu \ + selkies-gw:local + ''; + ExecStop = "${pkgs.docker}/bin/docker stop selkies"; + }; + }; + }; +} From 21b0fa15aec21477474e88c73ff5ae79f54057d8 Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 25 Jun 2026 11:52:28 +0100 Subject: [PATCH 46/61] selkies: enable internal TURN relay (LAN) so WebRTC media works behind nginx Co-Authored-By: Claude Opus 4.8 --- services/selkies.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/services/selkies.nix b/services/selkies.nix index 9230b44..15afc6d 100644 --- a/services/selkies.nix +++ b/services/selkies.nix @@ -67,6 +67,8 @@ in -e NVIDIA_DRIVER_CAPABILITIES=all \ --shm-size=2g \ -p 127.0.0.1:8093:8080 \ + -p 3478:3478 -p 3478:3478/udp \ + -p 65532-65535:65532-65535/udp \ -e TZ=Europe/Stockholm \ -e DISPLAY_SIZEW=1280 -e DISPLAY_SIZEH=720 \ -e DISPLAY_REFRESH=30 -e DISPLAY_DPI=96 -e DISPLAY_CDEPTH=24 \ @@ -75,6 +77,10 @@ in -e SELKIES_VIDEO_BITRATE=8000 \ -e SELKIES_FRAMERATE=30 \ -e SELKIES_ENABLE_BASIC_AUTH=false \ + -e SELKIES_TURN_HOST=10.0.0.1 \ + -e SELKIES_TURN_PROTOCOL=udp \ + -e SELKIES_TURN_PORT=3478 \ + -e TURN_MIN_PORT=65532 -e TURN_MAX_PORT=65535 \ -v /var/lib/selkies/home:/home/ubuntu \ selkies-gw:local ''; From 707f78c9d170e8658233a85565639efa15edb494 Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 25 Jun 2026 13:37:17 +0100 Subject: [PATCH 47/61] selkies: GPU-accelerate 32-bit GW via mounted 32-bit nvidia GL + vglrun launcher Mount config.hardware.nvidia.package.lib32 into the container (CDI only carries 64-bit driver libs) and add a `gw` launcher that runs Guild Wars through VirtualGL on the M2000. Drops GW from ~18 software-rendered CPU cores to <1. Also bump stream to 60fps. Co-Authored-By: Claude Opus 4.8 --- services/selkies.nix | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/services/selkies.nix b/services/selkies.nix index 15afc6d..b8c8e03 100644 --- a/services/selkies.nix +++ b/services/selkies.nix @@ -22,6 +22,12 @@ let && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ wine wine32 wine64 winbind cabextract ca-certificates wget \ && rm -rf /var/lib/apt/lists/* + # `gw` launcher: GW is 32-bit, so it needs the 32-bit NVIDIA GL libs (mounted + # at /usr/lib/i386-linux-gnu/nvidia by the service) on the loader path, and + # VirtualGL (EGL backend) to render on the M2000. Without this GW falls back + # to llvmpipe (software) and pegs ~18 CPU cores at <20 fps. + RUN printf '#!/bin/bash\nexport LD_LIBRARY_PATH=/usr/lib/i386-linux-gnu/nvidia\nexport VGL_DISPLAY=egl VGL_FPS=60\ncd "$HOME/.wine/drive_c/Program Files (x86)/Guild Wars/"\nexec vglrun wine Gw.exe "$@"\n' > /usr/local/bin/gw \ + && chmod +x /usr/local/bin/gw USER 1000 ''; in @@ -29,8 +35,9 @@ in config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { virtualisation.docker.enable = true; - # GPU into the container (CDI: nvidia.com/gpu=all) + 32-bit host GL libs so - # the toolkit can expose them to the 32-bit Wine/GW OpenGL stack. + # GPU into the container via CDI (nvidia.com/gpu=all). The CDI spec only + # carries the 64-bit driver libs, so the 32-bit set (for 32-bit Wine/GW) is + # bind-mounted separately below; enable32Bit makes them exist on the host. hardware.nvidia-container-toolkit.enable = true; hardware.graphics.enable32Bit = true; @@ -71,16 +78,17 @@ in -p 65532-65535:65532-65535/udp \ -e TZ=Europe/Stockholm \ -e DISPLAY_SIZEW=1280 -e DISPLAY_SIZEH=720 \ - -e DISPLAY_REFRESH=30 -e DISPLAY_DPI=96 -e DISPLAY_CDEPTH=24 \ + -e DISPLAY_REFRESH=60 -e DISPLAY_DPI=96 -e DISPLAY_CDEPTH=24 \ -e PASSWD=selkies \ -e SELKIES_ENCODER=nvh264enc \ -e SELKIES_VIDEO_BITRATE=8000 \ - -e SELKIES_FRAMERATE=30 \ + -e SELKIES_FRAMERATE=60 \ -e SELKIES_ENABLE_BASIC_AUTH=false \ -e SELKIES_TURN_HOST=10.0.0.1 \ -e SELKIES_TURN_PROTOCOL=udp \ -e SELKIES_TURN_PORT=3478 \ -e TURN_MIN_PORT=65532 -e TURN_MAX_PORT=65535 \ + -v ${config.hardware.nvidia.package.lib32}/lib:/usr/lib/i386-linux-gnu/nvidia:ro \ -v /var/lib/selkies/home:/home/ubuntu \ selkies-gw:local ''; From d69c9f624ff5aa51ea5566c9496f5b0384588555 Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 25 Jun 2026 19:37:35 +0100 Subject: [PATCH 48/61] hardware-health: rasdaemon MCE attribution + watchdog auto-reboot on mediaserver Co-Authored-By: Claude Opus 4.8 --- common.nix | 1 + services/hardware-health.nix | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 services/hardware-health.nix diff --git a/common.nix b/common.nix index b745c30..bac046e 100644 --- a/common.nix +++ b/common.nix @@ -44,6 +44,7 @@ ./services/memos.nix # ./services/neko.nix # superseded by selkies.nix (Neko can't handle GW's mouse grab) ./services/selkies.nix + ./services/hardware-health.nix ]; ### Make build time quicker diff --git a/services/hardware-health.nix b/services/hardware-health.nix new file mode 100644 index 0000000..6d2d525 --- /dev/null +++ b/services/hardware-health.nix @@ -0,0 +1,35 @@ +# services/hardware-health.nix — RAS error attribution + watchdog auto-recovery +# +# Context: Jun 2026 the dual Xeon E5-2697 v3 began throwing a storm of +# *corrected* Machine Check Exceptions on both sockets (Bank 5 / Bank 20), +# ~18k events in 36h, eventually hanging the box. Since this host is the +# router, a hang takes the whole LAN offline until a manual power-cycle. +# +# This module: +# - rasdaemon: decodes every MCE to a specific DIMM/channel/socket and +# persists a per-component error DB, so a failing part can be named +# (needed for the seller's warranty claim). Query with `ras-mc-ctl +# --error-count` and `ras-mc-ctl --summary`. +# - hardware watchdog: if userspace hangs again, systemd stops petting +# /dev/watchdog0 and the chipset watchdog reboots the box (~30s), +# restoring the LAN without physical access. + +{ config, lib, pkgs, ... }: +{ + config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { + + # Decode + log + persist machine-check / memory errors per component. + hardware.rasdaemon.enable = true; + + # ras-mc-ctl on PATH for manual inspection. + environment.systemPackages = [ pkgs.rasdaemon ]; + + # Hardware watchdog: auto-reboot a hung box instead of a dead LAN. + # systemd pets /dev/watchdog0 at half the runtime interval; if it stops + # (hang), the chipset resets after RuntimeWatchdogSec. + systemd.settings.Manager = { + RuntimeWatchdogSec = "30s"; + RebootWatchdogSec = "10min"; + }; + }; +} From 6622ed6864076e6ba23d2fdb03400459f067c639 Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Fri, 26 Jun 2026 04:00:53 +0000 Subject: [PATCH 49/61] Update flake inputs --- flake.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/flake.lock b/flake.lock index ccd38b9..5a95aae 100644 --- a/flake.lock +++ b/flake.lock @@ -234,11 +234,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1782328582, - "narHash": "sha256-UVOavLm7rSOXVlVaI4nlszpFPG109iQVxhIJ5UxXRCA=", + "lastModified": 1782415778, + "narHash": "sha256-Qts73QQA+lADfxWjonL3Q1JcZssVZPsQI38L3qZyS0o=", "owner": "xddxdd", "repo": "nix-cachyos-kernel", - "rev": "3ea1942599d8d0a124bdb9ec1304b3e6f63e8b1f", + "rev": "1740ec90e7b07730c212a3a1ff5e71af08a5270b", "type": "github" }, "original": { @@ -250,11 +250,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1782291881, - "narHash": "sha256-8dEV/c6gqHOSeRO1hIo/XhiHZ6NgBer1wrw+f+Vmw+o=", + "lastModified": 1782378976, + "narHash": "sha256-UqQgBlQATXM3aBvzTRE/1wxHrCdKg5/ePlXfG/7Eqd8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "532f984da08d27af048ed8664238f97f38ede850", + "rev": "5df71f3d167f0aad71658608361c1301147b9eb6", "type": "github" }, "original": { @@ -281,11 +281,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1782116945, - "narHash": "sha256-G3tw/IXmaH6IQ2upZvhuN9sG8CkuX+BLuJDpE8hz0Ds=", + "lastModified": 1782233679, + "narHash": "sha256-QyuGP5+QOtmXpy4i2X4DhBVBaySBdDKQEhqKcphcp34=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "34268251cf5547d39063f2c5ea9a196246f7f3a6", + "rev": "667d5cf1c59585031d743c78b394b0a647537c35", "type": "github" }, "original": { @@ -501,11 +501,11 @@ ] }, "locked": { - "lastModified": 1782144240, - "narHash": "sha256-RgCWSv7AJZCwPhCzz+J0lvwp1WBz9ouvCnnlmvu0xfw=", + "lastModified": 1782445527, + "narHash": "sha256-SxagHBpvd2R41gqt7S2PcWJY5lU30kg45uaDjSr302k=", "owner": "0xc000022070", "repo": "zen-browser-flake", - "rev": "d1693556428967f8b4eef128feb090421ddcaf15", + "rev": "281e00d0a67d778a310b3b93e4941f43e9112d90", "type": "github" }, "original": { From ee630bac30280f07cee7bb564275341b6fce1b70 Mon Sep 17 00:00:00 2001 From: rope Date: Fri, 26 Jun 2026 15:02:42 +0100 Subject: [PATCH 50/61] =?UTF-8?q?hyprland:=20idle=5Finhibit=20fullscreen?= =?UTF-8?q?=20=E2=80=94=20no=20lock=20during=20fullscreen=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- settings/hyprland.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/settings/hyprland.nix b/settings/hyprland.nix index 5be3e44..dd0737f 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -227,6 +227,12 @@ in hl.animation({ leaf = "workspaces", enabled = true, speed = 1, bezier = "snap" }) -- Window rules + -- Don't lock/idle while any window is fullscreen (video, games). + hl.window_rule({ + match = { class = ".*" }, + idle_inhibit = "fullscreen", + }) + -- Battle.net tray icon leaks as a tiny floating XWayland window. hl.window_rule({ match = { class = "steam_app_0", title = "^$", float = true }, From c7b3f8a3069389218e14a7987758aa25d8461ae4 Mon Sep 17 00:00:00 2001 From: rope Date: Fri, 26 Jun 2026 15:09:13 +0100 Subject: [PATCH 51/61] hypridle: don't lock/dpms/suspend while MPRIS media is playing Co-Authored-By: Claude Opus 4.8 --- settings/hyprland.nix | 55 ++++++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/settings/hyprland.nix b/settings/hyprland.nix index dd0737f..a6b608a 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -378,31 +378,38 @@ in }; }; - services.hypridle = lib.mkIf isMacbook { - enable = true; - settings = { - general = { - lock_cmd = "pidof hyprlock || hyprlock"; - before_sleep_cmd = "loginctl lock-session"; - after_sleep_cmd = "hyprctl dispatch dpms on"; + services.hypridle = lib.mkIf isMacbook ( + let + # Skip the action if any MPRIS player is playing — covers windowed + # video (Jellyfin in a browser, mpv, …) that the fullscreen + # idle_inhibit rule misses. Browsers expose MPRIS via playerctl. + unlessPlaying = cmd: "playerctl -a status 2>/dev/null | grep -q Playing || ${cmd}"; + in { + enable = true; + settings = { + general = { + lock_cmd = "pidof hyprlock || hyprlock"; + before_sleep_cmd = "loginctl lock-session"; + after_sleep_cmd = "hyprctl dispatch dpms on"; + }; + listener = [ + { + timeout = 300; # 5 min — lock + on-timeout = unlessPlaying "loginctl lock-session"; + } + { + timeout = 420; # 7 min — display off + on-timeout = unlessPlaying "hyprctl dispatch dpms off"; + on-resume = "hyprctl dispatch dpms on"; + } + { + timeout = 600; # 10 min — suspend + on-timeout = unlessPlaying "systemctl suspend"; + } + ]; }; - listener = [ - { - timeout = 300; # 5 min — lock - on-timeout = "loginctl lock-session"; - } - { - timeout = 420; # 7 min — display off - on-timeout = "hyprctl dispatch dpms off"; - on-resume = "hyprctl dispatch dpms on"; - } - { - timeout = 600; # 10 min — suspend - on-timeout = "systemctl suspend"; - } - ]; - }; - }; + } + ); # Scope all HM Wayland services (hyprpaper, etc.) to the # Hyprland session so they don't crash-loop in a GNOME session. From 8bf1d03dd2e4840a715d1ce79dd9f1bc96717e7f Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Sat, 27 Jun 2026 04:00:53 +0000 Subject: [PATCH 52/61] Update flake inputs --- flake.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/flake.lock b/flake.lock index 5a95aae..c7cc13a 100644 --- a/flake.lock +++ b/flake.lock @@ -281,11 +281,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1782233679, - "narHash": "sha256-QyuGP5+QOtmXpy4i2X4DhBVBaySBdDKQEhqKcphcp34=", + "lastModified": 1782375420, + "narHash": "sha256-wiPYmEuHbJvleW489n6+lamL7JSJg3pcKUYwURU9CkI=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "667d5cf1c59585031d743c78b394b0a647537c35", + "rev": "4062d36ebeae843c750011eef6b61ec9a9dbc9a9", "type": "github" }, "original": { @@ -297,11 +297,11 @@ }, "nixpkgs_3": { "locked": { - "lastModified": 1781577229, - "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", + "lastModified": 1782467914, + "narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=", "owner": "nixos", "repo": "nixpkgs", - "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", + "rev": "e73de5be04e0eff4190a1432b946d469c794e7b4", "type": "github" }, "original": { @@ -357,11 +357,11 @@ "nixpkgs": "nixpkgs_3" }, "locked": { - "lastModified": 1782185495, - "narHash": "sha256-wqdNdpcNtFIRd7K5LAepIRDfRvNGmFcXHuS7GCDZANU=", + "lastModified": 1782481477, + "narHash": "sha256-CgRxFjimm1aw9sXkN+Bhx458a1/MH760dfgRhULLdxU=", "owner": "powerofthe69", "repo": "proton-cachyos-nix", - "rev": "4c31ef56c48c3276f6692f979404ef8a2113cea1", + "rev": "7509cbb68c200f203af3b1b25d4699fa061371c3", "type": "github" }, "original": { @@ -501,11 +501,11 @@ ] }, "locked": { - "lastModified": 1782445527, - "narHash": "sha256-SxagHBpvd2R41gqt7S2PcWJY5lU30kg45uaDjSr302k=", + "lastModified": 1782460457, + "narHash": "sha256-R3EXRxLmv1uqMtacBi0L8gUCOWC6EpCPSXebf8e+vac=", "owner": "0xc000022070", "repo": "zen-browser-flake", - "rev": "281e00d0a67d778a310b3b93e4941f43e9112d90", + "rev": "563e515460b6c3bb68552979e3abbca447f8aaf1", "type": "github" }, "original": { From f21f7ac6f861a728d8a32b6802fc24703eac53fd Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Sun, 28 Jun 2026 04:00:54 +0000 Subject: [PATCH 53/61] Update flake inputs --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index c7cc13a..2916105 100644 --- a/flake.lock +++ b/flake.lock @@ -501,11 +501,11 @@ ] }, "locked": { - "lastModified": 1782460457, - "narHash": "sha256-R3EXRxLmv1uqMtacBi0L8gUCOWC6EpCPSXebf8e+vac=", + "lastModified": 1782554936, + "narHash": "sha256-tH3MNTu/o2xzYXnRYsl9/Q5k6mjIrcqWZ+qbqzdN2L8=", "owner": "0xc000022070", "repo": "zen-browser-flake", - "rev": "563e515460b6c3bb68552979e3abbca447f8aaf1", + "rev": "b3df24cd84ddecf5f13c48be9bdd99cf3bc7e1dc", "type": "github" }, "original": { From 0678a43a89e95f8529871c24907447d31d0c9da3 Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Mon, 29 Jun 2026 04:00:53 +0000 Subject: [PATCH 54/61] Update flake inputs --- flake.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/flake.lock b/flake.lock index 2916105..635eeab 100644 --- a/flake.lock +++ b/flake.lock @@ -211,11 +211,11 @@ ] }, "locked": { - "lastModified": 1781981105, - "narHash": "sha256-/1nNBbA7PrSQpTc9Qazkhl4kIPg+TNl0CjxS3UQJKlw=", + "lastModified": 1782704057, + "narHash": "sha256-G1I1gd32F7mp9LAe1DaZ4ZL7NX5gyiKwdCMwro1Vrck=", "owner": "nix-community", "repo": "home-manager", - "rev": "7bfff44b465909f69a442701293bc0badcf476dc", + "rev": "868d0a692de703c2de98fab61968e4e310b7c28e", "type": "github" }, "original": { @@ -281,11 +281,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1782375420, - "narHash": "sha256-wiPYmEuHbJvleW489n6+lamL7JSJg3pcKUYwURU9CkI=", + "lastModified": 1782535326, + "narHash": "sha256-ZeRxu4yn6shd3SNF5ZUQb4r7BaVo1zBKMjRhfoNSBmw=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "4062d36ebeae843c750011eef6b61ec9a9dbc9a9", + "rev": "714a5f8c4ead6b31148d829288440ed033ccc041", "type": "github" }, "original": { @@ -501,11 +501,11 @@ ] }, "locked": { - "lastModified": 1782554936, - "narHash": "sha256-tH3MNTu/o2xzYXnRYsl9/Q5k6mjIrcqWZ+qbqzdN2L8=", + "lastModified": 1782623843, + "narHash": "sha256-zQdTvI8jcVfblsrWafw1ykTnCVoV94ttxb5e6drwVaI=", "owner": "0xc000022070", "repo": "zen-browser-flake", - "rev": "b3df24cd84ddecf5f13c48be9bdd99cf3bc7e1dc", + "rev": "c59e57b9c6ea4c86f9f3b7efc92db3cbd305d078", "type": "github" }, "original": { From 7a9cf0e1f0f141828c70b8df002568b7d5138761 Mon Sep 17 00:00:00 2001 From: "forgejo-actions[bot]" Date: Tue, 30 Jun 2026 04:00:57 +0000 Subject: [PATCH 55/61] Update flake inputs --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 635eeab..7482e48 100644 --- a/flake.lock +++ b/flake.lock @@ -398,11 +398,11 @@ "tinted-zed": "tinted-zed" }, "locked": { - "lastModified": 1780702455, - "narHash": "sha256-+srjPGNy67nKytYwdlepycL51IG6S34sS4MKRZXK8G0=", + "lastModified": 1782770679, + "narHash": "sha256-+8RpmHKn5n2tYmoRCwiKJ6PeU85q15qnXzGQ2WGMn9Q=", "owner": "nix-community", "repo": "stylix", - "rev": "54fa19702f4f2c7f6a981a92850678933588af9a", + "rev": "3ed763829fc06d32cab3c1f31672379a1f53450e", "type": "github" }, "original": { From ebef93f618c3b60769a155d53a1e7313bfd2ad3b Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 30 Jun 2026 10:26:49 +0100 Subject: [PATCH 56/61] macbook: allow insecure pnpm (CVE-flagged build dep in closure) Co-Authored-By: Claude Opus 4.8 --- hosts/hardware/FredOS-Macbook.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hosts/hardware/FredOS-Macbook.nix b/hosts/hardware/FredOS-Macbook.nix index bc8e658..3518b0e 100644 --- a/hosts/hardware/FredOS-Macbook.nix +++ b/hosts/hardware/FredOS-Macbook.nix @@ -49,7 +49,7 @@ ]; nixpkgs.config.allowInsecurePredicate = pkg: - (lib.hasPrefix "broadcom-sta" (lib.getName pkg)); + lib.any (p: lib.hasPrefix p (lib.getName pkg)) [ "broadcom-sta" "pnpm" ]; services.xserver.deviceSection = lib.mkDefault '' Option "TearFree" "true" From 34d44a619e1780ba5c3f55375bc92969c063b7ff Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 30 Jun 2026 10:37:07 +0100 Subject: [PATCH 57/61] hardware-health: enable fwupd to check LVFS for P700 BIOS update Co-Authored-By: Claude Opus 4.8 --- services/hardware-health.nix | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/services/hardware-health.nix b/services/hardware-health.nix index 6d2d525..9ada84d 100644 --- a/services/hardware-health.nix +++ b/services/hardware-health.nix @@ -24,6 +24,13 @@ # ras-mc-ctl on PATH for manual inspection. environment.systemPackages = [ pkgs.rasdaemon ]; + # fwupd: lets us check whether Lenovo publishes a P700 BIOS/microcode + # update to LVFS that can be flashed in-place (UEFI capsule, applied on + # reboot). The dual-Xeon QPI fault is intermittent; a microcode bump may + # improve link tolerance. If LVFS has no payload for this 2014 board, + # this is harmless and can be removed. + services.fwupd.enable = true; + # Hardware watchdog: auto-reboot a hung box instead of a dead LAN. # systemd pets /dev/watchdog0 at half the runtime interval; if it stops # (hang), the chipset resets after RuntimeWatchdogSec. From ad1ceba28e1e57c5b9296d95f018bf44d0b30d1a Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 30 Jun 2026 10:42:08 +0100 Subject: [PATCH 58/61] macbook: pin to 6.12 LTS kernel so broadcom_sta + facetimehd build (7.x breaks them) Co-Authored-By: Claude Opus 4.8 --- hosts/hardware/FredOS-Macbook.nix | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/hosts/hardware/FredOS-Macbook.nix b/hosts/hardware/FredOS-Macbook.nix index 3518b0e..a045388 100644 --- a/hosts/hardware/FredOS-Macbook.nix +++ b/hosts/hardware/FredOS-Macbook.nix @@ -34,10 +34,17 @@ hardware.enableRedistributableFirmware = true; hardware.facetimehd.enable = true; + # Pin to the 6.12 LTS kernel: the out-of-tree broadcom_sta (Wi-Fi) and + # facetimehd (iSight) modules don't build against linuxPackages_latest + # (7.x) — broadcom-sta's wl_cfg80211 ops no longer match the cfg80211 API. + # 6.12 LTS is the newest kernel both modules compile against. Overrides + # common.nix's linuxPackages_latest. + boot.kernelPackages = lib.mkForce pkgs.linuxPackages_6_12; + # wait_prepare/wait_finish were removed from struct vb2_ops in Linux 6.8 nixpkgs.overlays = [ (final: prev: { - linuxPackages_latest = prev.linuxPackages_latest.extend (lpFinal: lpPrev: { + linuxPackages_6_12 = prev.linuxPackages_6_12.extend (lpFinal: lpPrev: { facetimehd = lpPrev.facetimehd.overrideAttrs (old: { postPatch = (old.postPatch or "") + '' sed -i '/\.wait_prepare[[:space:]]*=.*vb2_ops_wait_prepare/d' fthd_v4l2.c From 6cc3fb641970242752bf40e18d0f5f78d87d79a6 Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 30 Jun 2026 10:42:08 +0100 Subject: [PATCH 59/61] hardware-health: drop fwupd; no P700 BIOS published on LVFS Co-Authored-By: Claude Opus 4.8 --- services/hardware-health.nix | 7 ------- 1 file changed, 7 deletions(-) diff --git a/services/hardware-health.nix b/services/hardware-health.nix index 9ada84d..6d2d525 100644 --- a/services/hardware-health.nix +++ b/services/hardware-health.nix @@ -24,13 +24,6 @@ # ras-mc-ctl on PATH for manual inspection. environment.systemPackages = [ pkgs.rasdaemon ]; - # fwupd: lets us check whether Lenovo publishes a P700 BIOS/microcode - # update to LVFS that can be flashed in-place (UEFI capsule, applied on - # reboot). The dual-Xeon QPI fault is intermittent; a microcode bump may - # improve link tolerance. If LVFS has no payload for this 2014 board, - # this is harmless and can be removed. - services.fwupd.enable = true; - # Hardware watchdog: auto-reboot a hung box instead of a dead LAN. # systemd pets /dev/watchdog0 at half the runtime interval; if it stops # (hang), the chipset resets after RuntimeWatchdogSec. From 7d0c729e91e268f996284242ada47ec326df491d Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 30 Jun 2026 19:26:52 +0100 Subject: [PATCH 60/61] Update skills.md --- .claude/skills/ponytail/SKILL.md | 100 +++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .claude/skills/ponytail/SKILL.md diff --git a/.claude/skills/ponytail/SKILL.md b/.claude/skills/ponytail/SKILL.md new file mode 100644 index 0000000..0e0d3be --- /dev/null +++ b/.claude/skills/ponytail/SKILL.md @@ -0,0 +1,100 @@ +--- +name: ponytail +description: > + Forces the laziest solution that actually works, simplest, shortest, most + minimal. Channels a senior dev who has seen everything: question whether the + task needs to exist at all (YAGNI), reach for the standard library before + custom code, native platform features before dependencies, one line before + fifty. Supports intensity levels: lite, full (default), ultra. Use whenever + the user says "ponytail", "be lazy", "lazy mode", "simplest solution", + "minimal solution", "yagni", "do less", or "shortest path", and whenever + they complain about over-engineering, bloat, boilerplate, or unnecessary + dependencies. +license: MIT +--- + +# Ponytail + +You are a lazy senior developer. Lazy means efficient, not careless. You have +seen every over-engineered codebase and been paged at 3am for one. The best +code is the code never written. + +## Persistence + +ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if +unsure. Off only: "stop ponytail" / "normal mode". Default: **full**. +Switch: `/ponytail lite|full|ultra`. + +## The ladder + +Stop at the first rung that holds: + +1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI) +2. **Stdlib does it?** Use it. +3. **Native platform feature covers it?** `` over a picker lib, CSS over JS, DB constraint over app code. +4. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do. +5. **Can it be one line?** One line. +6. **Only then:** the minimum code that works. + +The ladder is a reflex, not a research project. Two rungs work → take the +higher one and move on. The first lazy solution that works is the right one. + +## Rules + +- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes. +- No boilerplate, no scaffolding "for later", later can scaffold for itself. +- Deletion over addition. Boring over clever, clever is what someone decodes at 3am. +- Fewest files possible. Shortest working diff wins. +- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default. +- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm. +- Mark deliberate simplifications with a `ponytail:` comment (`// ponytail: this exists`), simple reads as intent, not ignorance. Shortcut with a known ceiling (global lock, O(n²) scan, naive heuristic)? The comment names the ceiling and the upgrade path: `# ponytail: global lock, per-account locks if throughput matters`. + +## Output + +Code first. Then at most three short lines: what was skipped, when to add it. +No essays, no feature tours, no design notes. If the explanation is longer +than the code, delete the explanation, every paragraph defending a +simplification is complexity smuggled back in as prose. Explanation the user +explicitly asked for (a report, a walkthrough, per-phase notes) is not debt, +give it in full, the rule is only against unrequested prose. + +Pattern: `[code] → skipped: [X], add when [Y].` + +## Intensity + +| Level | What change | +|-------|------------| +| **lite** | Build what's asked, but name the lazier alternative in one line. User picks. | +| **full** | The ladder enforced. Stdlib and native first. Shortest diff, shortest explanation. Default. | +| **ultra** | YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same breath. | + +Example: "Add a cache for these API responses." +- lite: "Done, cache added. FYI: `functools.lru_cache` covers this in one line if you'd rather not own a cache class." +- full: "`@lru_cache(maxsize=1000)` on the fetch function. Skipped custom cache class, add when lru_cache measurably falls short." +- ultra: "No cache until a profiler says so. When it does: `@lru_cache`. A hand-rolled TTL cache class is a bug farm with a hit rate." + +## When NOT to be lazy + +Never simplify away: input validation at trust boundaries, error handling +that prevents data loss, security measures, accessibility basics, anything +explicitly requested. User insists on the full version → build it, no +re-arguing. + +Hardware is never the ideal on paper: a real clock drifts, a real sensor +reads off, a PCA9685 runs a few percent fast. Leave the calibration knob, not +just less code, the physical world needs tuning a minimal model can't see. + +Lazy code without its check is unfinished. Non-trivial logic (a branch, a +loop, a parser, a money/security path) leaves ONE runnable check behind, the +smallest thing that fails if the logic breaks: an `assert`-based +`demo()`/`__main__` self-check or one small `test_*.py`. No frameworks, no +fixtures, no per-function suites unless asked. Trivial one-liners need no +test, YAGNI applies to tests too. + +## Boundaries + +Ponytail governs what you build, not how you talk (pair with Caveman for +terse prose). "stop ponytail" / "normal mode": revert. Level persists until +changed or session end. + +The shortest path to done is the right path. From 9813812dfcba40347b094ac62bbe65c7206bb893 Mon Sep 17 00:00:00 2001 From: rope Date: Tue, 30 Jun 2026 20:00:32 +0100 Subject: [PATCH 61/61] Move insecure-pnpm/broadcom-sta allowance to common.nix (vesktop on all hosts) Co-Authored-By: Claude Opus 4.8 --- common.nix | 6 ++++++ hosts/hardware/FredOS-Macbook.nix | 3 +-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/common.nix b/common.nix index bac046e..2d07bff 100644 --- a/common.nix +++ b/common.nix @@ -91,6 +91,12 @@ # Allow unfree packages nixpkgs.config.allowUnfree = true; + # vesktop (multiple hosts) builds with pnpm via fetchPnpmDeps, which nixpkgs + # marks insecure (build-time only, hash-pinned FOD — not in PATH). broadcom-sta + # is Macbook-only Wi-Fi but allowing it everywhere is harmless (absent on others). + nixpkgs.config.allowInsecurePredicate = pkg: + lib.any (p: lib.hasPrefix p (lib.getName pkg)) [ "broadcom-sta" "pnpm" ]; + # Flakes — nixos-rebuild self-enables these, but plain `nix eval` / # `nix flake check` on the hosts need them too. nix.settings.experimental-features = [ "nix-command" "flakes" ]; diff --git a/hosts/hardware/FredOS-Macbook.nix b/hosts/hardware/FredOS-Macbook.nix index a045388..39af88d 100644 --- a/hosts/hardware/FredOS-Macbook.nix +++ b/hosts/hardware/FredOS-Macbook.nix @@ -55,8 +55,7 @@ }) ]; - nixpkgs.config.allowInsecurePredicate = pkg: - lib.any (p: lib.hasPrefix p (lib.getName pkg)) [ "broadcom-sta" "pnpm" ]; + # allowInsecurePredicate (broadcom-sta + pnpm) lives in common.nix now. services.xserver.deviceSection = lib.mkDefault '' Option "TearFree" "true"