From 93e79509c4cefb0742b2f2c07a32d6e27dce95bb Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 11 Jun 2026 10:00:02 +0100 Subject: [PATCH 1/6] crowdsec: inject ntfy url at runtime, drop obsolete hub prune Co-Authored-By: Claude Fable 5 --- services/crowdsec.nix | 57 ++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/services/crowdsec.nix b/services/crowdsec.nix index feeb9d3..355f9d9 100644 --- a/services/crowdsec.nix +++ b/services/crowdsec.nix @@ -11,14 +11,34 @@ # # 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 640 /var/secrets/ntfy-url +# sudo chmod 600 /var/secrets/ntfy-url { config, lib, pkgs, ... }: let - ntfyUrlFile = "/var/secrets/ntfy-url"; - ntfyUrl = - if builtins.pathExists ntfyUrlFile - then lib.removeSuffix "\n" (builtins.readFile ntfyUrlFile) - else "https://ntfy.sh/CHANGE-ME-CREATE-VAR-SECRETS-NTFY-URL"; + # 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 @@ -128,7 +148,7 @@ in name = "ntfy_http"; type = "http"; log_level = "info"; - url = ntfyUrl; + url = ntfyUrlPlaceholder; method = "POST"; headers = { Title = "CrowdSec alert"; @@ -163,28 +183,15 @@ in }; }; + # 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; registerBouncer.enable = true; }; - - # The hub keeps tracking upstream master, but nixpkgs stable's crowdsec - # binary is a few versions behind and doesn't know newer expr functions - # (e.g. LookupFile, used by crowdsecurity/http-technology-probing). The - # agent then refuses to load the entire bucket and crashes on startup. - # Strip incompatible scenarios after crowdsec-setup repopulates the hub - # but before crowdsec.service tries to load them. - systemd.services.crowdsec-prune-incompatible-hub-items = { - description = "Remove hub scenarios incompatible with the bundled crowdsec"; - after = [ "crowdsec-setup.service" ]; - before = [ "crowdsec.service" ]; - requiredBy = [ "crowdsec.service" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - ExecStart = "${pkgs.coreutils}/bin/rm -f /etc/crowdsec/scenarios/http-technology-probing.yaml"; - }; - }; }; } From f65675bd8099c311114f189b1dbfc5403c0d46a0 Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 11 Jun 2026 10:00:02 +0100 Subject: [PATCH 2/6] authelia: drop docker migration, tighten secret perms Co-Authored-By: Claude Fable 5 --- services/authelia.nix | 59 ++----------------------------------------- 1 file changed, 2 insertions(+), 57 deletions(-) diff --git a/services/authelia.nix b/services/authelia.nix index 9a04048..0d3f0f7 100644 --- a/services/authelia.nix +++ b/services/authelia.nix @@ -1,49 +1,6 @@ -# services/authelia.nix — Native Authelia SSO with auto-migration from Docker +# services/authelia.nix — Native Authelia SSO +# Secrets live in /var/secrets/authelia (root:authelia-main, 640) — see readme. { config, lib, pkgs, ... }: -let - # Migrates secrets + user DB from the old Docker Authelia setup - setupScript = pkgs.writeShellScript "authelia-setup" '' - set -euo pipefail - YQ="${pkgs.yq-go}/bin/yq" - DOCKER_CONFIG="/home/fred/docker/authelia/configuration.yml" - SECRETS_DIR="/var/secrets/authelia" - STATE_DIR="/var/lib/authelia-main" - - mkdir -p "$SECRETS_DIR" - mkdir -p "$STATE_DIR" - - # Migrate secrets from Docker config if they haven't been extracted yet - if [ -f "$DOCKER_CONFIG" ]; then - if [ ! -f "$SECRETS_DIR/jwt_secret" ]; then - $YQ '.identity_validation.reset_password.jwt_secret' "$DOCKER_CONFIG" \ - | tr -d '"' > "$SECRETS_DIR/jwt_secret" - echo "Migrated jwt_secret" - fi - if [ ! -f "$SECRETS_DIR/session_secret" ]; then - $YQ '.session.secret' "$DOCKER_CONFIG" \ - | tr -d '"' > "$SECRETS_DIR/session_secret" - echo "Migrated session_secret" - fi - if [ ! -f "$SECRETS_DIR/storage_encryption_key" ]; then - $YQ '.storage.encryption_key' "$DOCKER_CONFIG" \ - | tr -d '"' > "$SECRETS_DIR/storage_encryption_key" - echo "Migrated storage_encryption_key" - fi - fi - - chmod 644 "$SECRETS_DIR"/* - - # Migrate users database - if [ ! -f "$STATE_DIR/users_database.yml" ] && \ - [ -f "/home/fred/docker/authelia/users_database.yml" ]; then - cp /home/fred/docker/authelia/users_database.yml "$STATE_DIR/" - chown authelia-main:authelia-main "$STATE_DIR/users_database.yml" - echo "Migrated users_database.yml" - fi - - echo "Authelia setup complete." - ''; -in { config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { @@ -100,17 +57,5 @@ in notifier.filesystem.filename = "/var/lib/authelia-main/notification.txt"; }; }; - - # Auto-migrate Docker Authelia data on first deploy - systemd.services.authelia-setup = { - description = "Migrate Authelia secrets and user database from Docker"; - before = [ "authelia-main.service" ]; - requiredBy = [ "authelia-main.service" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - ExecStart = setupScript; - }; - }; }; } From 8dd70a2d9df6bb24ae839e349733757bb957180a Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 11 Jun 2026 10:00:02 +0100 Subject: [PATCH 3/6] mediaserver: drop no-op firewall rules, close unused DR forwards Co-Authored-By: Claude Fable 5 --- hosts/FredOS-Mediaserver.nix | 4 ++-- ports.toml | 21 ++------------------- services/adguard.nix | 5 ++--- services/bazarr.nix | 1 - services/dr-server.nix | 4 ++-- services/game-servers.nix | 3 --- services/homepage.nix | 1 - services/jellyfin.nix | 1 - services/nginx.nix | 2 -- services/prowlarr.nix | 1 - services/radarr.nix | 1 - services/sonarr.nix | 1 - 12 files changed, 8 insertions(+), 37 deletions(-) diff --git a/hosts/FredOS-Mediaserver.nix b/hosts/FredOS-Mediaserver.nix index 03ada0a..0dc518e 100644 --- a/hosts/FredOS-Mediaserver.nix +++ b/hosts/FredOS-Mediaserver.nix @@ -43,8 +43,8 @@ allowReboot = true; }; - # Open firewall for SSH - networking.firewall.allowedTCPPorts = [ 22 11434 ]; + # WAN exposure is controlled by nftables in services/router.nix + + # ports.toml (networking.firewall is disabled on this host). services.openssh = { enable = true; settings = { diff --git a/ports.toml b/ports.toml index 5eddc0b..ac7f48d 100644 --- a/ports.toml +++ b/ports.toml @@ -45,22 +45,5 @@ name = "7DTD-coop voice/dynamic" ports = "26911-26912" protocol = "udp" -[[forward]] -name = "DR auth" -port = 2110 -protocol = "tcp" - -[[forward]] -name = "DR game" -port = 2603 -protocol = "both" - -[[forward]] -name = "DR aux UDP" -ports = "2604-2605" -protocol = "udp" - -[[forward]] -name = "DR queue" -port = 2606 -protocol = "tcp" +# 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/adguard.nix b/services/adguard.nix index c1e64a4..57c75e7 100644 --- a/services/adguard.nix +++ b/services/adguard.nix @@ -45,8 +45,7 @@ }; }; - # LAN DNS — router blocks WAN:53 so this is effectively LAN-only - networking.firewall.allowedTCPPorts = [ 53 ]; - networking.firewall.allowedUDPPorts = [ 53 ]; + # LAN clients reach :53 via the nftables "LAN trusted" rule in router.nix; + # WAN:53 is dropped there. }; } diff --git a/services/bazarr.nix b/services/bazarr.nix index 2594a01..5dadadf 100644 --- a/services/bazarr.nix +++ b/services/bazarr.nix @@ -6,7 +6,6 @@ # Bazarr services.bazarr = { enable = true; - openFirewall = true; # Opens port 7878 dataDir = "/var/lib/bazarr"; user = "bazarr"; group = "media"; diff --git a/services/dr-server.nix b/services/dr-server.nix index 3f84e66..4a2667d 100644 --- a/services/dr-server.nix +++ b/services/dr-server.nix @@ -66,7 +66,7 @@ in }; }; - networking.firewall.allowedTCPPorts = [ 2110 2603 2604 2605 2606 ]; - networking.firewall.allowedUDPPorts = [ 2110 2603 2604 2605 2606 ]; + # WAN forwards for 2110/2603-2606 were removed from ports.toml when this + # service was disabled — re-add them there if this comes back. }; } diff --git a/services/game-servers.nix b/services/game-servers.nix index 7d49ee4..a3632c0 100644 --- a/services/game-servers.nix +++ b/services/game-servers.nix @@ -187,8 +187,5 @@ StartLimitIntervalSec = 300; StartLimitBurst = 5; }; - - networking.firewall.allowedTCPPorts = [ 26900 26910 ]; - networking.firewall.allowedUDPPorts = [ 26900 26901 26902 26910 26911 26912 ]; }; } diff --git a/services/homepage.nix b/services/homepage.nix index c81e413..cdd5f7c 100644 --- a/services/homepage.nix +++ b/services/homepage.nix @@ -87,7 +87,6 @@ in services.homepage-dashboard = { enable = true; - openFirewall = true; listenPort = 8084; # Allow access from anywhere on the LAN diff --git a/services/jellyfin.nix b/services/jellyfin.nix index 0e46627..705cbd1 100644 --- a/services/jellyfin.nix +++ b/services/jellyfin.nix @@ -5,7 +5,6 @@ # Jellyfin services.jellyfin = { enable = true; - openFirewall = true; }; # Ensure Jellyfin can write thumbnails/artwork to media directories diff --git a/services/nginx.nix b/services/nginx.nix index 7311b96..c340533 100644 --- a/services/nginx.nix +++ b/services/nginx.nix @@ -135,7 +135,5 @@ in }; }; }; - - networking.firewall.allowedTCPPorts = [ 80 443 ]; }; } diff --git a/services/prowlarr.nix b/services/prowlarr.nix index 4d4f0dd..6e9572b 100644 --- a/services/prowlarr.nix +++ b/services/prowlarr.nix @@ -19,7 +19,6 @@ # Prowlarr services.prowlarr = { enable = true; - openFirewall = true; dataDir = "/var/lib/prowlarr"; }; }; diff --git a/services/radarr.nix b/services/radarr.nix index 64ff752..152cf88 100644 --- a/services/radarr.nix +++ b/services/radarr.nix @@ -6,7 +6,6 @@ # Radarr services.radarr = { enable = true; - openFirewall = true; # Opens port 7878 dataDir = "/var/lib/radarr"; user = "radarr"; group = "media"; diff --git a/services/sonarr.nix b/services/sonarr.nix index 285d255..cb52b22 100644 --- a/services/sonarr.nix +++ b/services/sonarr.nix @@ -6,7 +6,6 @@ # Sonarr services.sonarr = { enable = true; - openFirewall = true; dataDir = "/var/lib/sonarr"; user = "sonarr"; group = "media"; From 7bf997176e0a3d7f144d807bb5f1424db75a065e Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 11 Jun 2026 10:00:02 +0100 Subject: [PATCH 4/6] quickshell: split QML out of hyprland.nix Co-Authored-By: Claude Fable 5 --- settings/hyprland.nix | 1969 +------------------------------------- settings/quickshell.nix | 1987 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 1988 insertions(+), 1968 deletions(-) create mode 100644 settings/quickshell.nix diff --git a/settings/hyprland.nix b/settings/hyprland.nix index 720a678..0afef3a 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -55,9 +55,7 @@ in networkmanagerapplet pavucontrol polkit_gnome - quickshell anyrun - qt6.qt5compat zenity libcanberra-gtk3 ]; @@ -423,1972 +421,7 @@ in Install.WantedBy = [ "hyprland-session.target" ]; }; - systemd.user.services.quickshell = { - Unit = { - Description = "Quickshell desktop shell"; - PartOf = [ "graphical-session.target" ]; - After = [ "graphical-session.target" ]; - }; - Service = { - ExecStart = "${pkgs.quickshell}/bin/qs"; - Restart = "always"; - RestartSec = 2; - }; - Install.WantedBy = [ "hyprland-session.target" ]; - }; - - xdg.configFile = let - qsRestart = '' - ${pkgs.systemd}/bin/systemctl --user restart quickshell.service 2>/dev/null || true - ''; - wifiConnectScript = pkgs.writeShellScript "wifi-connect" '' - ssid="$1" - ${pkgs.networkmanager}/bin/nmcli device wifi connect "$ssid" 2>/dev/null && exit 0 - pw=$(${pkgs.zenity}/bin/zenity --password --title="WiFi Password" 2>/dev/null) - [ -n "$pw" ] && ${pkgs.networkmanager}/bin/nmcli device wifi connect "$ssid" password "$pw" - ''; - nmcli = "${pkgs.networkmanager}/bin/nmcli"; - powerprofilesctl = "${pkgs.power-profiles-daemon}/bin/powerprofilesctl"; - in { - "quickshell/qmldir" = { - onChange = qsRestart; - text = '' - singleton Theme 1.0 Theme.qml - singleton Commands 1.0 Commands.qml - Bar 1.0 Bar.qml - ''; - }; - - "quickshell/Theme.qml" = { - onChange = qsRestart; - text = '' - pragma Singleton - import QtQuick - - QtObject { - readonly property color base00: "#${c.base00}" - readonly property color base01: "#${c.base01}" - readonly property color base02: "#${c.base02}" - readonly property color base03: "#${c.base03}" - readonly property color base04: "#${c.base04}" - readonly property color base05: "#${c.base05}" - 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}" - } - ''; - }; - - "quickshell/Commands.qml" = { - onChange = qsRestart; - text = '' - pragma Singleton - import QtQuick - - QtObject { - readonly property string nmcli: "${nmcli}" - readonly property string wifiConnect: "${wifiConnectScript}" - readonly property string powerprofilesctl: "${powerprofilesctl}" - readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play" - } - ''; - }; - - "quickshell/shell.qml" = { - onChange = qsRestart; - text = '' - //@ pragma UseQApplication - import Quickshell - import Quickshell.Services.Notifications - import QtQuick - - ShellRoot { - id: root - property var latestNotification: null - signal notificationReceived() - - NotificationServer { - id: _notifServer - bodySupported: true - actionsSupported: true - imageSupported: true - persistenceSupported: true - keepOnReload: true - onNotification: (notification) => { - notification.tracked = true; - root.latestNotification = notification; - root.notificationReceived(); - } - } - - Variants { - model: Quickshell.screens - - Bar { - notifServer: _notifServer - shellRoot: root - } - } - } - ''; - }; - - "quickshell/Bar.qml" = { - onChange = qsRestart; - text = '' - import Quickshell - import Quickshell.Hyprland - import Quickshell.Wayland - import Quickshell.Services.SystemTray - import Quickshell.Services.Notifications - import Quickshell.Services.Pipewire - import Quickshell.Widgets - import Quickshell.Io - import QtQuick - import QtQuick.Layouts - import Qt5Compat.GraphicalEffects - - PanelWindow { - id: bar - required property var modelData - required property NotificationServer notifServer - required property var shellRoot - screen: modelData - WlrLayershell.namespace: "quickshell-bar" - - anchors { - top: true - left: true - right: true - } - - implicitHeight: bar.screen.height - exclusiveZone: 30 - color: "transparent" - - mask: Region { - item: barBgRect - Region { - x: activeDropdown ? activeDropdown.x : 0 - y: activeDropdown ? activeDropdown.y : 0 - width: activeDropdown && activeDropdown.visible ? activeDropdown.width : 0 - height: activeDropdown && activeDropdown.visible ? activeDropdown.height : 0 - } - Region { - x: toastItem.visible ? toastItem.x : 0 - y: toastItem.visible ? toastItem.y : 0 - width: toastItem.visible ? toastItem.width : 0 - height: toastItem.visible ? toastItem.height : 0 - } - } - - Rectangle { - id: barBgRect - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - height: 30 - color: Theme.barBg - } - - // The "gap source" for the bar border — dropdown takes priority, then toast - property bool hasGap: (activeDropdown && activeDropdown.dropdownHeight > 0) - || (toastItem.visible && _toastRect.height > 0) - property real gapLeft: activeDropdown && activeDropdown.dropdownHeight > 0 - ? activeDropdown.x - : toastItem.visible && _toastRect.height > 0 - ? toastItem.x : 0 - property real gapRight: activeDropdown && activeDropdown.dropdownHeight > 0 - ? activeDropdown.x + activeDropdown.width - : toastItem.visible && _toastRect.height > 0 - ? toastItem.x + toastItem.width : 0 - property bool gapAlignRight: activeDropdown ? activeDropdown.alignRight : false - - // Bar bottom border — left segment (up to gap) - Rectangle { - id: barBorderLeft - x: 0; y: 30 - width: bar.hasGap ? bar.gapLeft : bar.width - height: 1 - color: Theme.base03 - } - - // Bar bottom border — right segment (after gap) - Rectangle { - id: barBorderRight - visible: bar.hasGap && !bar.gapAlignRight - x: bar.gapRight - y: 30 - width: bar.width - x - height: 1 - color: Theme.base03 - } - - property var activeDropdown: null - - function closeAllDropdowns() { - if (activeDropdown && activeDropdown.visible) { - activeDropdown.animateClose(); - } - } - function toggleDropdown(dd, setupFn) { - if (dd.visible && !dd.closing) { - dd.animateClose(); - } else { - if (activeDropdown && activeDropdown !== dd && activeDropdown.visible) { - activeDropdown.animateClose(); - } - if (setupFn) setupFn(); - if (dd.closing) { - dd.closing = false; - dd.open = true; - } else { - dd.visible = true; - } - activeDropdown = dd; - } - } - - // Left — workspaces - Row { - anchors.left: parent.left - anchors.leftMargin: 6 - anchors.verticalCenter: barBgRect.verticalCenter - spacing: 0 - - Repeater { - model: Hyprland.workspaces - - Item { - required property var modelData - visible: modelData.id > 0 - width: visible ? 28 : 0 - height: 30 - - Text { - anchors.centerIn: parent - text: modelData.name - color: modelData.focused ? Theme.base05 : Theme.base03 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - } - - Rectangle { - anchors.bottom: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - width: parent.width - 8 - height: 2 - color: Theme.base05 - visible: modelData.focused - } - - MouseArea { - anchors.fill: parent - onClicked: modelData.activate() - } - } - } - } - - // Center — clock - Text { - id: clockText - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: barBgRect.verticalCenter - property date now: new Date() - text: now.toLocaleTimeString(Qt.locale(), "HH:mm") - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - font.weight: Font.Medium - - Timer { - interval: 1000 - running: true - repeat: true - onTriggered: clockText.now = new Date() - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - onClicked: bar.toggleDropdown(calPopup) - onEntered: { - if (bar.activeDropdown) { - if (bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup); - else bar.activeDropdown.resetAutoClose(); - } - } - } - } - - // Right — network, battery, tray - Row { - anchors.right: parent.right - anchors.rightMargin: 8 - anchors.verticalCenter: barBgRect.verticalCenter - spacing: 10 - - // Volume - Item { - id: volWidget - width: volText.width - height: 30 - - property PwNode sink: Pipewire.defaultAudioSink - - PwObjectTracker { - objects: [volWidget.sink] - } - - property int vol: sink && sink.audio ? Math.round(sink.audio.volume * 100) : 0 - property bool muted: sink && sink.audio ? sink.audio.muted : false - property string volIcon: muted ? "\u{f0581}" - : vol > 66 ? "\u{f057e}" - : vol > 33 ? "\u{f0580}" - : vol > 0 ? "\u{f057f}" - : "\u{f0581}" - - function openVolDropdown() { - bar.toggleDropdown(volDropdown, function() { - let pos = volWidget.mapToItem(bar.contentItem, volWidget.width / 2, 0); - volDropdown.dropdownX = pos.x; - }); - } - - Text { - id: volText - anchors.verticalCenter: parent.verticalCenter - text: volWidget.volIcon + " " + volWidget.vol + "%" - color: volWidget.muted ? Theme.base03 : Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.MiddleButton - onClicked: (event) => { - if (event.button === Qt.MiddleButton) { - if (volWidget.sink && volWidget.sink.audio) - volWidget.sink.audio.muted = !volWidget.sink.audio.muted; - } else { - volWidget.openVolDropdown(); - } - } - onEntered: { - if (bar.activeDropdown) { - if (bar.activeDropdown !== volDropdown) volWidget.openVolDropdown(); - else bar.activeDropdown.resetAutoClose(); - } - } - } - } - - // Network status - Item { - id: netWidget - width: 16 - height: 30 - - property string netState: "disconnected" - property string netConn: "" - property string netType: "" - property string netIcon: "\u{f0b0}" - property var wifiNetworks: [] - property string netDevice: "" - - property string _pendingState: "disconnected" - property string _pendingConn: "" - property string _pendingType: "" - property string _pendingDevice: "" - property var _pendingNets: [] - - Timer { - interval: 5000 - running: true - repeat: true - triggeredOnStart: true - onTriggered: netWidget.refreshNet() - } - - function refreshNet() { - netWidget._pendingState = "disconnected"; - netWidget._pendingConn = ""; - netWidget._pendingType = ""; - netProc.running = true; - } - - Process { - id: netProc - command: [Commands.nmcli, "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"] - stdout: SplitParser { - onRead: data => { - let fields = data.split(":"); - if (fields.length < 4) return; - let type = fields[1]; - let state = fields[2]; - let conn = fields[3]; - if (type !== "ethernet" && type !== "wifi") return; - if (type === "wifi") { - netWidget._pendingDevice = fields[0]; - } - if (state === "connected") { - netWidget._pendingState = "connected"; - netWidget._pendingConn = conn; - netWidget._pendingType = type; - } - } - } - onRunningChanged: { - if (!running) { - netWidget.netState = netWidget._pendingState; - netWidget.netConn = netWidget._pendingConn; - netWidget.netType = netWidget._pendingType.length > 0 ? netWidget._pendingType : netWidget.netType; - netWidget.netDevice = netWidget._pendingDevice.length > 0 ? netWidget._pendingDevice : netWidget.netDevice; - if (netWidget.netState === "connected") { - netWidget.netIcon = netWidget.netType === "wifi" ? "\u{f05a9}" : "\u{f0200}"; - } else { - netWidget.netIcon = netWidget.netType === "wifi" ? "\u{f05aa}" : "\u{f0201}"; - } - } - } - } - - Text { - anchors.centerIn: parent - text: netWidget.netIcon - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 14 - } - - Timer { - id: netRefreshDelay - interval: 2000 - onTriggered: netWidget.refreshNet() - } - - Process { - id: wifiScanProc - command: [Commands.nmcli, "-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "device", "wifi", "list", "--rescan", "auto"] - stdout: SplitParser { - onRead: data => { - let fields = data.split(":"); - if (fields.length < 4 || fields[0] === "") return; - for (let i = 0; i < netWidget._pendingNets.length; i++) { - if (netWidget._pendingNets[i].ssid === fields[0]) return; - } - netWidget._pendingNets.push({ - ssid: fields[0], - signal: parseInt(fields[1]) || 0, - security: fields[2], - active: fields[3] === "*" - }); - } - } - onRunningChanged: { - if (!running) { - netWidget.wifiNetworks = netWidget._pendingNets; - netWidget._pendingNets = []; - } - } - } - - Process { - id: wifiConnectProc - property string targetSsid: "" - command: [Commands.wifiConnect, targetSsid] - } - - Process { - id: netDisconnectProc - property string targetDevice: "" - command: [Commands.nmcli, "device", "disconnect", targetDevice] - } - - function openNetDropdown() { - bar.toggleDropdown(netDropdown, function() { - wifiScanProc.running = true; - let pos = netWidget.mapToItem(bar.contentItem, netWidget.width / 2, 0); - netDropdown.dropdownX = pos.x; - }); - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - onClicked: netWidget.openNetDropdown() - onEntered: { - if (bar.activeDropdown) { - if (bar.activeDropdown !== netDropdown) netWidget.openNetDropdown(); - else bar.activeDropdown.resetAutoClose(); - } - } - } - } - - ${lib.optionalString isMacbook '' - // Battery - Item { - id: batteryWidget - width: batteryText.width + 4 + batteryIconText.width - height: 30 - - property int batteryLevel: 0 - property bool charging: false - property string batteryIcon: "\u{f008e}" - property real powerDraw: 0.0 - property real energyNow: 0.0 - property real energyFull: 0.0 - property string timeRemaining: "" - property string powerProfile: "balanced" - - function updateIcon() { - if (charging) { batteryIcon = "\u{f0084}"; return; } - if (batteryLevel >= 90) batteryIcon = "\u{f0079}"; - else if (batteryLevel >= 70) batteryIcon = "\u{f0082}"; - else if (batteryLevel >= 50) batteryIcon = "\u{f007f}"; - else if (batteryLevel >= 30) batteryIcon = "\u{f007c}"; - else if (batteryLevel >= 15) batteryIcon = "\u{f007a}"; - else batteryIcon = "\u{f008e}"; - } - - Timer { - interval: 5000 - running: true - repeat: true - triggeredOnStart: true - onTriggered: { batteryProc.running = true; profileProc.running = true; } - } - - Process { - id: batteryProc - command: ["sh", "-c", "cat /sys/class/power_supply/BAT0/capacity; cat /sys/class/power_supply/BAT0/status; cat /sys/class/power_supply/BAT0/power_now 2>/dev/null || echo 0; cat /sys/class/power_supply/BAT0/energy_now 2>/dev/null || echo 0; cat /sys/class/power_supply/BAT0/energy_full 2>/dev/null || echo 0"] - stdout: SplitParser { - property int lineNum: 0 - onRead: data => { - let trimmed = data.trim(); - let num = parseInt(trimmed); - lineNum++; - if (lineNum === 1) { - if (!isNaN(num)) batteryWidget.batteryLevel = num; - } else if (lineNum === 2) { - batteryWidget.charging = (trimmed === "Charging"); - } else if (lineNum === 3) { - if (!isNaN(num)) batteryWidget.powerDraw = num / 1000000.0; - } else if (lineNum === 4) { - if (!isNaN(num)) batteryWidget.energyNow = num / 1000000.0; - } else if (lineNum === 5) { - if (!isNaN(num)) batteryWidget.energyFull = num / 1000000.0; - lineNum = 0; - if (batteryWidget.powerDraw > 0.5) { - let hours; - if (batteryWidget.charging) { - hours = (batteryWidget.energyFull - batteryWidget.energyNow) / batteryWidget.powerDraw; - } else { - hours = batteryWidget.energyNow / batteryWidget.powerDraw; - } - let h = Math.floor(hours); - let m = Math.round((hours - h) * 60); - batteryWidget.timeRemaining = h + "h " + m + "m"; - } else { - batteryWidget.timeRemaining = ""; - } - } - batteryWidget.updateIcon(); - } - } - } - - Process { - id: profileProc - command: [Commands.powerprofilesctl, "get"] - stdout: SplitParser { - onRead: data => { - batteryWidget.powerProfile = data.trim(); - } - } - } - - Process { - id: setProfileProc - property string target: "balanced" - command: [Commands.powerprofilesctl, "set", target] - } - - Row { - anchors.verticalCenter: parent.verticalCenter - spacing: 4 - - Text { - id: batteryText - text: batteryWidget.batteryLevel + "%" - color: batteryWidget.batteryLevel <= 15 ? Theme.base08 - : batteryWidget.batteryLevel <= 30 ? Theme.base0A - : Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - } - - Text { - id: batteryIconText - text: batteryWidget.batteryIcon - color: batteryWidget.batteryLevel <= 15 ? Theme.base08 - : batteryWidget.batteryLevel <= 30 ? Theme.base0A - : Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 14 - } - } - - function openBatteryDropdown() { - bar.toggleDropdown(batteryDropdown, function() { - batteryProc.running = true; - profileProc.running = true; - let pos = batteryWidget.mapToItem(bar.contentItem, batteryWidget.width / 2, 0); - batteryDropdown.dropdownX = pos.x; - }); - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - onClicked: batteryWidget.openBatteryDropdown() - onEntered: { - if (bar.activeDropdown) { - if (bar.activeDropdown !== batteryDropdown) batteryWidget.openBatteryDropdown(); - else bar.activeDropdown.resetAutoClose(); - } - } - } - } - ''} - - // Tray icons - Row { - id: trayArea - spacing: 8 - height: 30 - anchors.verticalCenter: parent.verticalCenter - - HoverHandler { - onHoveredChanged: { - if (hovered && bar.activeDropdown) bar.activeDropdown.resetAutoClose(); - } - } - - Repeater { - model: SystemTray.items - - Item { - required property var modelData - width: 24 - height: 30 - - Image { - id: trayIcon - anchors.centerIn: parent - width: 16 - height: 16 - source: modelData.icon - sourceSize.width: 16 - sourceSize.height: 16 - smooth: true - mipmap: true - visible: false - } - - ColorOverlay { - anchors.fill: trayIcon - source: trayIcon - color: Theme.base05 - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - 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 - let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0); - 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); - contextMenu.dropdownX = pos.x; - contextMenu.trayItem = modelData; - menuOpener.menu = modelData.menu; - }); - } - } - } - } - } - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: (event) => { - if (modelData.hasMenu) { - bar.toggleDropdown(contextMenu, function() { - let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0); - contextMenu.dropdownX = pos.x; - contextMenu.trayItem = modelData; - menuOpener.menu = modelData.menu; - }); - } else { - modelData.activate(); - } - } - } - } - } - } - } - - // Reusable dropdown component - component BarDropdown: Item { - id: dropdown - property bool open: false - property bool closing: false - property real dropdownX: 0 - property real fullWidth: 200 - property real fullHeight: 200 - property int autoCloseMs: 1500 - property bool alignRight: false - property real dropdownHeight: _dropdownRect.height - default property alias content: dropdownContent.data - - function animateClose() { - if (!visible || closing) return; - closing = true; - open = false; - _autoClose.stop(); - _closeDelay.start(); - } - - function resetAutoClose() { - if (visible && !closing) _autoClose.restart(); - } - - x: alignRight ? bar.width - width : Math.min( - bar.width - width, - Math.max(0, dropdownX - (fullWidth + 16) / 2) - ) - y: 30 - visible: false - width: fullWidth + (alignRight ? 8 : 16) - height: fullHeight + 4 + (alignRight ? 8 : 0) - - onVisibleChanged: { - 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: 230 - onTriggered: { dropdown.visible = false; dropdown.closing = false; if (bar.activeDropdown === dropdown) bar.activeDropdown = null; } - } - - HoverHandler { - onHoveredChanged: { - if (hovered) _autoClose.stop(); - else _autoClose.restart(); - } - } - - // Left ear - Item { - anchors.right: _dropdownRect.left - anchors.top: parent.top - width: 8 - height: Math.min(8, _dropdownRect.height) - clip: true - visible: _dropdownRect.height >= 8 - Canvas { - anchors.top: parent.top - width: 8; height: 8 - onPaint: { - var ctx = getContext("2d"); - ctx.clearRect(0, 0, 8, 8); - ctx.fillStyle = Theme.barBg; - ctx.beginPath(); - ctx.moveTo(0, 0); ctx.lineTo(8, 0); ctx.lineTo(8, 8); - ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); - ctx.closePath(); ctx.fill(); - // Border stroke along the curve - ctx.strokeStyle = Theme.base03; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); - ctx.stroke(); - } - } - } - - // Right ear (for centered dropdowns) - Item { - anchors.left: _dropdownRect.right - anchors.top: parent.top - width: 8 - height: Math.min(8, _dropdownRect.height) - clip: true - visible: _dropdownRect.height >= 8 && !dropdown.alignRight - Canvas { - anchors.top: parent.top - width: 8; height: 8 - onPaint: { - var ctx = getContext("2d"); - ctx.clearRect(0, 0, 8, 8); - ctx.fillStyle = Theme.barBg; - ctx.beginPath(); - ctx.moveTo(0, 0); ctx.lineTo(8, 0); - ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true); - ctx.closePath(); ctx.fill(); - // Border stroke along the curve - ctx.strokeStyle = Theme.base03; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true); - ctx.stroke(); - } - } - } - - Rectangle { - id: _dropdownRect - anchors.right: dropdown.alignRight ? parent.right : undefined - anchors.horizontalCenter: dropdown.alignRight ? undefined : parent.horizontalCenter - anchors.top: parent.top - width: dropdown.fullWidth - height: dropdown.open ? dropdown.fullHeight : 0 - color: Theme.barBg - radius: 8 - topLeftRadius: 0 - topRightRadius: 0 - bottomRightRadius: dropdown.alignRight ? 0 : 8 - clip: true - - // Border outline (sides + bottom with rounded corners) - Canvas { - id: _dropdownBorder - anchors.fill: parent - onPaint: { - var ctx = getContext("2d"); - var w = width, h = height, r = 8; - ctx.clearRect(0, 0, w, h); - if (h < 1) return; - ctx.strokeStyle = Theme.base03; - ctx.lineWidth = 1; - ctx.beginPath(); - // Start below the top ear, go down left side - ctx.moveTo(0.5, r); - ctx.lineTo(0.5, h - r); - // Bottom-left curve - ctx.arc(r + 0.5, h - r - 0.5, r, Math.PI, Math.PI / 2, true); - // Bottom edge - if (dropdown.alignRight) { - // Stop 8px before right edge — bottom-right ear continues - ctx.lineTo(w - r, h - 0.5); - } else { - ctx.lineTo(w - r - 0.5, h - 0.5); - // Bottom-right curve - ctx.arc(w - r - 0.5, h - r - 0.5, r, Math.PI / 2, 0, true); - // Right side up (stop at ear height) - ctx.lineTo(w - 0.5, r); - } - ctx.stroke(); - } - // Repaint when size changes - onWidthChanged: requestPaint() - onHeightChanged: requestPaint() - } - - Behavior on height { - NumberAnimation { duration: 220; easing.type: Easing.OutCubic } - } - - Item { - id: dropdownContent - anchors.fill: parent - } - } - - // Bottom-right concave ear — connects dropdown bottom to right screen edge - Item { - visible: dropdown.alignRight && _dropdownRect.height >= 8 - anchors.right: _dropdownRect.right - anchors.top: _dropdownRect.bottom - width: 8 - height: Math.min(8, _dropdownRect.height) - clip: true - Canvas { - width: 8; height: 8 - onPaint: { - var ctx = getContext("2d"); - ctx.clearRect(0, 0, 8, 8); - ctx.fillStyle = Theme.barBg; - ctx.beginPath(); - ctx.moveTo(0, 0); - ctx.lineTo(8, 0); - ctx.lineTo(8, 8); - ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); - ctx.fill(); - // Border stroke along the curve - ctx.strokeStyle = Theme.base03; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); - ctx.stroke(); - } - } - } - } - - // Context menu - BarDropdown { - id: contextMenu - alignRight: true - property var trayItem: null - fullWidth: menuItems.width + 24 - fullHeight: menuItems.height + 16 - - onVisibleChanged: { - if (!visible) menuOpener.menu = null; - } - - QsMenuOpener { - id: menuOpener - } - - Column { - id: menuItems - anchors.centerIn: parent - width: 200 - - Repeater { - model: menuOpener.children - - Rectangle { - required property var modelData - width: 200 - height: modelData.isSeparator ? 9 : 28 - color: !modelData.isSeparator && itemMouse.containsMouse && modelData.enabled - ? Theme.base02 : "transparent" - radius: modelData.isSeparator ? 0 : 4 - - Rectangle { - visible: modelData.isSeparator - anchors.centerIn: parent - width: parent.width - 20 - height: 1 - color: Theme.base03 - } - - RowLayout { - visible: !modelData.isSeparator - anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 10 - spacing: 8 - - Text { - Layout.fillWidth: true - text: modelData.text ?? "" - color: modelData.enabled ? Theme.base05 : Theme.base03 - font.family: "FiraMono Nerd Font" - font.pixelSize: 12 - elide: Text.ElideRight - } - - Text { - visible: modelData.buttonType !== QsMenuButtonType.None - text: modelData.checkState === Qt.Checked ? "\u2713" : "" - color: Theme.base0D - font.family: "FiraMono Nerd Font" - font.pixelSize: 12 - } - } - - MouseArea { - id: itemMouse - anchors.fill: parent - hoverEnabled: true - enabled: !modelData.isSeparator && modelData.enabled - onClicked: { - modelData.triggered(); - bar.closeAllDropdowns(); - } - } - } - } - } - } - - // Volume dropdown - BarDropdown { - id: volDropdown - alignRight: true - fullWidth: volDropdownCol.width + 28 - fullHeight: volDropdownCol.height + 20 - autoCloseMs: 3000 - - Column { - id: volDropdownCol - anchors.centerIn: parent - width: 260 - spacing: 8 - - // Master volume - Text { - text: "\u{f057e} Master" - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - font.weight: Font.Medium - } - - Row { - width: parent.width - spacing: 8 - - Rectangle { - id: masterSliderBg - width: parent.width - masterVolLabel.width - 8 - height: 20 - radius: 4 - color: Theme.base01 - anchors.verticalCenter: parent.verticalCenter - - Rectangle { - width: volWidget.sink && volWidget.sink.audio - ? Math.min(1, volWidget.sink.audio.volume) * parent.width : 0 - height: parent.height - radius: 4 - color: volWidget.muted ? Theme.base03 : Theme.base0D - Behavior on width { NumberAnimation { duration: 80 } } - } - - MouseArea { - anchors.fill: parent - onPressed: (mouse) => setVolume(mouse) - onPositionChanged: (mouse) => { if (pressed) setVolume(mouse); } - function setVolume(mouse) { - if (!volWidget.sink || !volWidget.sink.audio) return; - let v = Math.max(0, Math.min(1, mouse.x / width)); - volWidget.sink.audio.volume = v; - } - } - } - - Text { - id: masterVolLabel - width: 36 - text: volWidget.vol + "%" - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - horizontalAlignment: Text.AlignRight - anchors.verticalCenter: parent.verticalCenter - } - } - - // Mute button - Rectangle { - width: parent.width - height: 28 - color: masterMuteMa.containsMouse ? Theme.base02 : "transparent" - radius: 4 - - Text { - anchors.centerIn: parent - text: volWidget.muted ? "\u{f0581} Unmute" : "\u{f057e} Mute" - color: Theme.base05 - font.family: "FiraMono Nerd Font" - 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; - } - } - } - - // Separator - Rectangle { - width: parent.width - 20 - anchors.horizontalCenter: parent.horizontalCenter - height: 1 - color: Theme.base02 - visible: appStreamsCol.childrenRect.height > 0 - } - - // App streams header - Text { - visible: appStreamsCol.childrenRect.height > 0 - text: "\u{f0641} Applications" - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - font.weight: Font.Medium - } - - // Per-app streams - Column { - id: appStreamsCol - width: parent.width - spacing: 6 - - Repeater { - id: appStreamsRepeater - model: Pipewire.nodes - - Column { - required property var modelData - width: parent.width - spacing: 2 - visible: modelData.isStream && modelData.audio !== null - - PwObjectTracker { - objects: [modelData] - } - - Text { - text: modelData.properties["application.name"] || modelData.name || "Unknown" - color: Theme.base04 - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - elide: Text.ElideRight - width: parent.width - } - - Row { - width: parent.width - spacing: 8 - - Rectangle { - width: parent.width - appVolLabel.width - 8 - height: 16 - radius: 3 - color: Theme.base01 - anchors.verticalCenter: parent.verticalCenter - - Rectangle { - width: modelData.audio - ? Math.min(1, modelData.audio.volume) * parent.width : 0 - height: parent.height - radius: 3 - color: modelData.audio && modelData.audio.muted - ? Theme.base03 : Theme.base0C - Behavior on width { NumberAnimation { duration: 80 } } - } - - MouseArea { - anchors.fill: parent - onPressed: (mouse) => setVol(mouse) - onPositionChanged: (mouse) => { if (pressed) setVol(mouse); } - function setVol(mouse) { - if (!modelData.audio) return; - let v = Math.max(0, Math.min(1, mouse.x / width)); - modelData.audio.volume = v; - } - } - } - - Text { - id: appVolLabel - width: 36 - text: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : "0%" - color: Theme.base04 - font.family: "FiraMono Nerd Font" - font.pixelSize: 10 - horizontalAlignment: Text.AlignRight - anchors.verticalCenter: parent.verticalCenter - } - } - } - } - } - } - } - - // Network dropdown - BarDropdown { - id: netDropdown - alignRight: true - fullWidth: netDropdownCol.width + 28 - fullHeight: netDropdownCol.height + 20 - - Column { - id: netDropdownCol - anchors.centerIn: parent - width: 220 - spacing: 4 - - Text { - width: parent.width - text: netWidget.netState === "connected" - ? "\u{f05a9} " + netWidget.netConn - : "\u{f05aa} Not connected" - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - font.weight: Font.Medium - elide: Text.ElideRight - } - - Rectangle { - visible: netWidget.netState === "connected" - width: parent.width - height: 28 - color: disconnectMouse.containsMouse ? Theme.base02 : "transparent" - radius: 4 - - Text { - anchors.centerIn: parent - text: "Disconnect" - color: Theme.base08 - font.family: "FiraMono Nerd Font" - font.pixelSize: 12 - } - - MouseArea { - id: disconnectMouse - anchors.fill: parent - hoverEnabled: true - onClicked: { - netDisconnectProc.targetDevice = netWidget.netDevice; - netDisconnectProc.running = true; - netWidget.netState = "disconnected"; - netWidget.netConn = ""; - netWidget.netIcon = "\u{f05aa}"; - bar.closeAllDropdowns(); - netRefreshDelay.start(); - } - } - } - - Rectangle { - width: parent.width - 20 - anchors.horizontalCenter: parent.horizontalCenter - height: 1 - color: Theme.base03 - } - - Text { - text: "Available networks" - color: Theme.base03 - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - topPadding: 2 - } - - Repeater { - model: netWidget.wifiNetworks - - Rectangle { - required property var modelData - width: 220 - height: 32 - color: netItemMouse.containsMouse ? Theme.base02 : "transparent" - radius: 4 - - Row { - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: 8 - anchors.right: parent.right - anchors.rightMargin: 8 - spacing: 8 - - Text { - text: { - let s = modelData.signal; - if (s >= 75) return "\u{f05a9}"; - if (s >= 50) return "\u{f05a9}"; - if (s >= 25) return "\u{f05a9}"; - return "\u{f05aa}"; - } - color: modelData.active ? Theme.base0B : Theme.base04 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - anchors.verticalCenter: parent.verticalCenter - } - - Text { - text: modelData.ssid - color: modelData.active ? Theme.base0B : Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 12 - elide: Text.ElideRight - width: 140 - anchors.verticalCenter: parent.verticalCenter - } - - Text { - visible: modelData.security !== "" && modelData.security !== "--" - text: "\u{f0341}" - color: Theme.base03 - font.family: "FiraMono Nerd Font" - font.pixelSize: 10 - anchors.verticalCenter: parent.verticalCenter - } - } - - MouseArea { - id: netItemMouse - anchors.fill: parent - hoverEnabled: true - onClicked: { - if (!modelData.active) { - wifiConnectProc.targetSsid = modelData.ssid; - wifiConnectProc.running = true; - netRefreshDelay.start(); - } - bar.closeAllDropdowns(); - } - } - } - } - } - } - - ${lib.optionalString isMacbook '' - // Battery dropdown - BarDropdown { - id: batteryDropdown - alignRight: true - fullWidth: batteryDropdownCol.width + 28 - fullHeight: batteryDropdownCol.height + 20 - - Column { - id: batteryDropdownCol - anchors.centerIn: parent - width: 200 - spacing: 8 - - Row { - width: parent.width - spacing: 8 - - Text { - text: batteryWidget.batteryIcon - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 18 - anchors.verticalCenter: parent.verticalCenter - } - - Column { - anchors.verticalCenter: parent.verticalCenter - Text { - text: batteryWidget.batteryLevel + "%" + (batteryWidget.charging ? " — Charging" : "") - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - font.weight: Font.Medium - } - Text { - text: batteryWidget.powerDraw.toFixed(1) + " W" - + (batteryWidget.timeRemaining !== "" ? " \u2022 " + batteryWidget.timeRemaining + (batteryWidget.charging ? " to full" : " left") : "") - color: Theme.base04 - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - } - } - } - - Rectangle { - width: parent.width - 10 - anchors.horizontalCenter: parent.horizontalCenter - height: 1 - color: Theme.base03 - } - - Text { - text: "Power Profile" - color: Theme.base03 - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - } - - Row { - width: parent.width - spacing: 4 - - Repeater { - model: [ - { name: "power-saver", label: "\u{f0425}", tip: "Saver" }, - { name: "balanced", label: "\u{f0376}", tip: "Balanced" }, - { name: "performance", label: "\u{f0e0e}", tip: "Performance" } - ] - - Rectangle { - required property var modelData - width: (parent.width - 8) / 3 - height: 36 - radius: 6 - color: batteryWidget.powerProfile === modelData.name - ? Theme.base02 : profMouse.containsMouse - ? Theme.base01 : "transparent" - border.width: batteryWidget.powerProfile === modelData.name ? 1 : 0 - border.color: Theme.base03 - - Column { - anchors.centerIn: parent - spacing: 1 - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: modelData.label - color: batteryWidget.powerProfile === modelData.name - ? Theme.base0D : Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 14 - } - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: modelData.tip - color: Theme.base04 - font.family: "FiraMono Nerd Font" - font.pixelSize: 9 - } - } - - MouseArea { - id: profMouse - anchors.fill: parent - hoverEnabled: true - onClicked: { - setProfileProc.target = modelData.name; - setProfileProc.running = true; - batteryWidget.powerProfile = modelData.name; - } - } - } - } - } - } - } - ''} - - // Calendar popup - BarDropdown { - id: calPopup - dropdownX: bar.width / 2 - fullWidth: calCol.width + 8 - fullHeight: calCol.height + 4 - - Column { - id: calCol - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - - spacing: 8 - opacity: calPopup.open ? 1.0 : 0.0 - - Behavior on opacity { - NumberAnimation { duration: 150; easing.type: Easing.OutCubic } - } - - Text { - id: calTitle - anchors.horizontalCenter: parent.horizontalCenter - text: clockText.now.toLocaleDateString(Qt.locale(), "dddd, d MMMM yyyy") - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 16 - font.weight: Font.Medium - } - - Row { - id: weekdayRow - anchors.horizontalCenter: parent.horizontalCenter - spacing: 0 - Repeater { - model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] - Text { - required property var modelData - width: 32 - horizontalAlignment: Text.AlignHCenter - text: modelData - color: Theme.base04 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - } - } - } - - Grid { - columns: 7 - spacing: 0 - - Repeater { - id: calRepeater - model: 42 - - Rectangle { - required property int index - width: 32 - height: 26 - radius: 4 - color: { - let d = clockText.now; - let first = new Date(d.getFullYear(), d.getMonth(), 1); - let startDay = (first.getDay() + 6) % 7; - let dayNum = index - startDay + 1; - let daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); - return (dayNum === d.getDate() && dayNum >= 1 && dayNum <= daysInMonth) - ? Theme.base03 : "transparent"; - } - - Text { - anchors.centerIn: parent - text: { - let d = clockText.now; - let first = new Date(d.getFullYear(), d.getMonth(), 1); - let startDay = (first.getDay() + 6) % 7; - let dayNum = parent.index - startDay + 1; - let daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); - return (dayNum >= 1 && dayNum <= daysInMonth) ? dayNum.toString() : ""; - } - color: { - let d = clockText.now; - let first = new Date(d.getFullYear(), d.getMonth(), 1); - let startDay = (first.getDay() + 6) % 7; - let dayNum = parent.index - startDay + 1; - return (dayNum === d.getDate()) ? Theme.base05 : Theme.base04; - } - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - } - } - } - } - - Rectangle { - width: 7 * 32 + 8 - height: 1 - color: Theme.base02 - anchors.horizontalCenter: parent.horizontalCenter - } - - Row { - width: 7 * 32 + 8 - anchors.horizontalCenter: parent.horizontalCenter - Text { - text: "Notifications" - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - font.weight: Font.Medium - } - Item { Layout.fillWidth: true; width: 10 } - Text { - anchors.right: parent.right - text: bar.notifServer.trackedNotifications.values.length > 0 ? "Clear all" : "" - color: Theme.base04 - font.family: "FiraMono Nerd Font" - 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(); - } - } - } - } - } - - Column { - spacing: 4 - width: 7 * 32 + 8 - anchors.horizontalCenter: parent.horizontalCenter - - Text { - visible: bar.notifServer.trackedNotifications.values.length === 0 - text: "No notifications" - color: Theme.base03 - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - anchors.horizontalCenter: parent.horizontalCenter - } - - Repeater { - model: bar.notifServer.trackedNotifications - - Rectangle { - id: notifItem - required property var modelData - width: 7 * 32 + 8 - height: notifCol.height + 12 - radius: 6 - color: Theme.base01 - - Column { - id: notifCol - anchors.left: parent.left - anchors.right: dismissBtn.left - anchors.top: parent.top - anchors.margins: 6 - spacing: 2 - - Text { - width: parent.width - text: notifItem.modelData.summary || notifItem.modelData.appName - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - font.weight: Font.Medium - elide: Text.ElideRight - } - - Text { - width: parent.width - text: notifItem.modelData.body || "" - color: Theme.base04 - font.family: "FiraMono Nerd Font" - font.pixelSize: 10 - elide: Text.ElideRight - maximumLineCount: 2 - wrapMode: Text.Wrap - visible: text !== "" - } - - Row { - spacing: 4 - visible: notifItem.modelData.actions.length > 0 - Repeater { - model: notifItem.modelData.actions - Rectangle { - required property var modelData - width: actionText.width + 12 - height: actionText.height + 4 - radius: 4 - color: actionMa.containsMouse ? Theme.base02 : Theme.base01 - border.width: 1 - border.color: Theme.base02 - Text { - id: actionText - anchors.centerIn: parent - text: modelData.text - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 10 - } - MouseArea { - id: actionMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: modelData.invoke() - } - } - } - } - } - - Text { - id: dismissBtn - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 6 - text: "\u{f0156}" - color: dismissMa.containsMouse ? Theme.base05 : Theme.base03 - font.family: "FiraMono Nerd Font" - font.pixelSize: 12 - MouseArea { - id: dismissMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: notifItem.modelData.dismiss() - } - } - } - } - } - } - } - - // ── Notification Toast (only on primary screen) ── - Item { - id: toastItem - visible: false - property var currentNotif: null - property bool toastOpen: false - readonly property var mutedApps: ["discord", "Discord", "Vesktop", "vesktop", "Spotify", "spotify", "vlc", "mpv"] - readonly property bool isPrimary: bar.screen === Quickshell.screens[0] - - x: Math.round(bar.width / 2 - width / 2) - y: 30 - width: _toastLeftEar.width + _toastRect.width + _toastRightEar.width - height: _toastRect.height + 4 - - Process { - id: notifSoundProc - command: [Commands.notifSound, "-i", "message"] - } - - Connections { - target: bar.shellRoot - function onNotificationReceived() { - if (toastItem.isPrimary) { - toastItem.showToast(bar.shellRoot.latestNotification); - } - } - } - - function showToast(notification) { - currentNotif = notification; - visible = true; - toastOpen = true; - _toastTimer.restart(); - if (!mutedApps.includes(notification.appName)) { - notifSoundProc.running = true; - } - } - - function dismiss() { - toastOpen = false; - _toastCloseDelay.start(); - } - - Timer { - id: _toastTimer - interval: 5000 - onTriggered: toastItem.dismiss() - } - - Timer { - id: _toastCloseDelay - interval: 230 - onTriggered: { toastItem.visible = false; toastItem.toastOpen = false; } - } - - HoverHandler { - onHoveredChanged: { - if (hovered) _toastTimer.stop(); - else _toastTimer.restart(); - } - } - - // Left inverse corner ear - Item { - id: _toastLeftEar - anchors.right: _toastRect.left - anchors.top: parent.top - width: 8 - height: Math.min(8, _toastRect.height) - clip: true - visible: _toastRect.height >= 8 - Canvas { - anchors.top: parent.top - width: 8; height: 8 - onPaint: { - var ctx = getContext("2d"); - ctx.clearRect(0, 0, 8, 8); - ctx.fillStyle = Theme.barBg; - ctx.beginPath(); - ctx.moveTo(0, 0); ctx.lineTo(8, 0); ctx.lineTo(8, 8); - ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); - ctx.closePath(); ctx.fill(); - ctx.strokeStyle = Theme.base03; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); - ctx.stroke(); - } - } - } - - // Right inverse corner ear - Item { - id: _toastRightEar - anchors.left: _toastRect.right - anchors.top: parent.top - width: 8 - height: Math.min(8, _toastRect.height) - clip: true - visible: _toastRect.height >= 8 - Canvas { - anchors.top: parent.top - width: 8; height: 8 - onPaint: { - var ctx = getContext("2d"); - ctx.clearRect(0, 0, 8, 8); - ctx.fillStyle = Theme.barBg; - ctx.beginPath(); - ctx.moveTo(0, 0); ctx.lineTo(8, 0); - ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true); - ctx.closePath(); ctx.fill(); - ctx.strokeStyle = Theme.base03; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true); - ctx.stroke(); - } - } - } - - Rectangle { - id: _toastRect - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - width: 320 - height: toastItem.toastOpen ? toastCol.height + 16 : 0 - color: Theme.barBg - radius: 8 - topLeftRadius: 0 - topRightRadius: 0 - clip: true - - // Border outline (sides + bottom with rounded corners) - Canvas { - anchors.fill: parent - onPaint: { - var ctx = getContext("2d"); - var w = width, h = height, r = 8; - ctx.clearRect(0, 0, w, h); - if (h < 1) return; - ctx.strokeStyle = Theme.base03; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(0.5, r); - ctx.lineTo(0.5, h - r); - ctx.arc(r + 0.5, h - r - 0.5, r, Math.PI, Math.PI / 2, true); - ctx.lineTo(w - r - 0.5, h - 0.5); - ctx.arc(w - r - 0.5, h - r - 0.5, r, Math.PI / 2, 0, true); - ctx.lineTo(w - 0.5, r); - ctx.stroke(); - } - onWidthChanged: requestPaint() - onHeightChanged: requestPaint() - } - - Behavior on height { - NumberAnimation { duration: 220; easing.type: Easing.OutCubic } - } - - Column { - id: toastCol - anchors.left: parent.left - anchors.right: toastDismiss.left - anchors.top: parent.top - anchors.margins: 8 - spacing: 2 - - Text { - width: parent.width - text: toastItem.currentNotif ? (toastItem.currentNotif.summary || toastItem.currentNotif.appName) : "" - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 12 - font.weight: Font.Medium - elide: Text.ElideRight - } - - Text { - width: parent.width - text: toastItem.currentNotif ? (toastItem.currentNotif.body || "") : "" - color: Theme.base04 - font.family: "FiraMono Nerd Font" - font.pixelSize: 11 - elide: Text.ElideRight - maximumLineCount: 3 - wrapMode: Text.Wrap - visible: text !== "" - } - - Row { - spacing: 4 - visible: toastItem.currentNotif && toastItem.currentNotif.actions.length > 0 - Repeater { - model: toastItem.currentNotif ? toastItem.currentNotif.actions : [] - Rectangle { - required property var modelData - width: toastActionText.width + 12 - height: toastActionText.height + 6 - radius: 4 - color: toastActionMa.containsMouse ? Theme.base02 : Theme.base01 - border.width: 1 - border.color: Theme.base02 - Text { - id: toastActionText - anchors.centerIn: parent - text: modelData.text - color: Theme.base05 - font.family: "FiraMono Nerd Font" - font.pixelSize: 10 - } - MouseArea { - id: toastActionMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { modelData.invoke(); toastItem.dismiss(); } - } - } - } - } - } - - Text { - id: toastDismiss - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 8 - text: "\u{f0156}" - color: toastDismissMa.containsMouse ? Theme.base05 : Theme.base03 - font.family: "FiraMono Nerd Font" - font.pixelSize: 13 - MouseArea { - id: toastDismissMa - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { toastItem.currentNotif.dismiss(); toastItem.dismiss(); } - } - } - } - } - } - ''; - }; - + xdg.configFile = { # anyrun config — written manually since HM 26.05 has no anyrun module. "anyrun/config.ron".text = '' Config( diff --git a/settings/quickshell.nix b/settings/quickshell.nix new file mode 100644 index 0000000..59e97cf --- /dev/null +++ b/settings/quickshell.nix @@ -0,0 +1,1987 @@ +# settings/quickshell.nix — Quickshell desktop shell (bar, notifications, QML), +# split out of settings/hyprland.nix. The hyprland-side blur layer rule for +# the "quickshell-bar" namespace still lives there. +{ config, pkgs, lib, ... }: +let + isMacbook = config.networking.hostName == "FredOS-Macbook"; +in +{ + config = lib.mkIf (lib.elem config.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ]) { + environment.systemPackages = with pkgs; [ + quickshell + qt6.qt5compat # Qt5Compat.GraphicalEffects in Bar.qml + ]; + + home-manager.users.fred = { config, lib, pkgs, ... }: + let + c = config.lib.stylix.colors; + in { + systemd.user.services.quickshell = { + Unit = { + Description = "Quickshell desktop shell"; + PartOf = [ "graphical-session.target" ]; + After = [ "graphical-session.target" ]; + }; + Service = { + ExecStart = "${pkgs.quickshell}/bin/qs"; + Restart = "always"; + RestartSec = 2; + }; + Install.WantedBy = [ "hyprland-session.target" ]; + }; + + xdg.configFile = let + qsRestart = '' + ${pkgs.systemd}/bin/systemctl --user restart quickshell.service 2>/dev/null || true + ''; + wifiConnectScript = pkgs.writeShellScript "wifi-connect" '' + ssid="$1" + ${pkgs.networkmanager}/bin/nmcli device wifi connect "$ssid" 2>/dev/null && exit 0 + pw=$(${pkgs.zenity}/bin/zenity --password --title="WiFi Password" 2>/dev/null) + [ -n "$pw" ] && ${pkgs.networkmanager}/bin/nmcli device wifi connect "$ssid" password "$pw" + ''; + nmcli = "${pkgs.networkmanager}/bin/nmcli"; + powerprofilesctl = "${pkgs.power-profiles-daemon}/bin/powerprofilesctl"; + in { + "quickshell/qmldir" = { + onChange = qsRestart; + text = '' + singleton Theme 1.0 Theme.qml + singleton Commands 1.0 Commands.qml + Bar 1.0 Bar.qml + ''; + }; + + "quickshell/Theme.qml" = { + onChange = qsRestart; + text = '' + pragma Singleton + import QtQuick + + QtObject { + readonly property color base00: "#${c.base00}" + readonly property color base01: "#${c.base01}" + readonly property color base02: "#${c.base02}" + readonly property color base03: "#${c.base03}" + readonly property color base04: "#${c.base04}" + readonly property color base05: "#${c.base05}" + 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}" + } + ''; + }; + + "quickshell/Commands.qml" = { + onChange = qsRestart; + text = '' + pragma Singleton + import QtQuick + + QtObject { + readonly property string nmcli: "${nmcli}" + readonly property string wifiConnect: "${wifiConnectScript}" + readonly property string powerprofilesctl: "${powerprofilesctl}" + readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play" + } + ''; + }; + + "quickshell/shell.qml" = { + onChange = qsRestart; + text = '' + //@ pragma UseQApplication + import Quickshell + import Quickshell.Services.Notifications + import QtQuick + + ShellRoot { + id: root + property var latestNotification: null + signal notificationReceived() + + NotificationServer { + id: _notifServer + bodySupported: true + actionsSupported: true + imageSupported: true + persistenceSupported: true + keepOnReload: true + onNotification: (notification) => { + notification.tracked = true; + root.latestNotification = notification; + root.notificationReceived(); + } + } + + Variants { + model: Quickshell.screens + + Bar { + notifServer: _notifServer + shellRoot: root + } + } + } + ''; + }; + + "quickshell/Bar.qml" = { + onChange = qsRestart; + text = '' + import Quickshell + import Quickshell.Hyprland + import Quickshell.Wayland + import Quickshell.Services.SystemTray + import Quickshell.Services.Notifications + import Quickshell.Services.Pipewire + import Quickshell.Widgets + import Quickshell.Io + import QtQuick + import QtQuick.Layouts + import Qt5Compat.GraphicalEffects + + PanelWindow { + id: bar + required property var modelData + required property NotificationServer notifServer + required property var shellRoot + screen: modelData + WlrLayershell.namespace: "quickshell-bar" + + anchors { + top: true + left: true + right: true + } + + implicitHeight: bar.screen.height + exclusiveZone: 30 + color: "transparent" + + mask: Region { + item: barBgRect + Region { + x: activeDropdown ? activeDropdown.x : 0 + y: activeDropdown ? activeDropdown.y : 0 + width: activeDropdown && activeDropdown.visible ? activeDropdown.width : 0 + height: activeDropdown && activeDropdown.visible ? activeDropdown.height : 0 + } + Region { + x: toastItem.visible ? toastItem.x : 0 + y: toastItem.visible ? toastItem.y : 0 + width: toastItem.visible ? toastItem.width : 0 + height: toastItem.visible ? toastItem.height : 0 + } + } + + Rectangle { + id: barBgRect + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 30 + color: Theme.barBg + } + + // The "gap source" for the bar border — dropdown takes priority, then toast + property bool hasGap: (activeDropdown && activeDropdown.dropdownHeight > 0) + || (toastItem.visible && _toastRect.height > 0) + property real gapLeft: activeDropdown && activeDropdown.dropdownHeight > 0 + ? activeDropdown.x + : toastItem.visible && _toastRect.height > 0 + ? toastItem.x : 0 + property real gapRight: activeDropdown && activeDropdown.dropdownHeight > 0 + ? activeDropdown.x + activeDropdown.width + : toastItem.visible && _toastRect.height > 0 + ? toastItem.x + toastItem.width : 0 + property bool gapAlignRight: activeDropdown ? activeDropdown.alignRight : false + + // Bar bottom border — left segment (up to gap) + Rectangle { + id: barBorderLeft + x: 0; y: 30 + width: bar.hasGap ? bar.gapLeft : bar.width + height: 1 + color: Theme.base03 + } + + // Bar bottom border — right segment (after gap) + Rectangle { + id: barBorderRight + visible: bar.hasGap && !bar.gapAlignRight + x: bar.gapRight + y: 30 + width: bar.width - x + height: 1 + color: Theme.base03 + } + + property var activeDropdown: null + + function closeAllDropdowns() { + if (activeDropdown && activeDropdown.visible) { + activeDropdown.animateClose(); + } + } + function toggleDropdown(dd, setupFn) { + if (dd.visible && !dd.closing) { + dd.animateClose(); + } else { + if (activeDropdown && activeDropdown !== dd && activeDropdown.visible) { + activeDropdown.animateClose(); + } + if (setupFn) setupFn(); + if (dd.closing) { + dd.closing = false; + dd.open = true; + } else { + dd.visible = true; + } + activeDropdown = dd; + } + } + + // Left — workspaces + Row { + anchors.left: parent.left + anchors.leftMargin: 6 + anchors.verticalCenter: barBgRect.verticalCenter + spacing: 0 + + Repeater { + model: Hyprland.workspaces + + Item { + required property var modelData + visible: modelData.id > 0 + width: visible ? 28 : 0 + height: 30 + + Text { + anchors.centerIn: parent + text: modelData.name + color: modelData.focused ? Theme.base05 : Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + } + + Rectangle { + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + width: parent.width - 8 + height: 2 + color: Theme.base05 + visible: modelData.focused + } + + MouseArea { + anchors.fill: parent + onClicked: modelData.activate() + } + } + } + } + + // Center — clock + Text { + id: clockText + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: barBgRect.verticalCenter + property date now: new Date() + text: now.toLocaleTimeString(Qt.locale(), "HH:mm") + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + font.weight: Font.Medium + + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: clockText.now = new Date() + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: bar.toggleDropdown(calPopup) + onEntered: { + if (bar.activeDropdown) { + if (bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup); + else bar.activeDropdown.resetAutoClose(); + } + } + } + } + + // Right — network, battery, tray + Row { + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: barBgRect.verticalCenter + spacing: 10 + + // Volume + Item { + id: volWidget + width: volText.width + height: 30 + + property PwNode sink: Pipewire.defaultAudioSink + + PwObjectTracker { + objects: [volWidget.sink] + } + + property int vol: sink && sink.audio ? Math.round(sink.audio.volume * 100) : 0 + property bool muted: sink && sink.audio ? sink.audio.muted : false + property string volIcon: muted ? "\u{f0581}" + : vol > 66 ? "\u{f057e}" + : vol > 33 ? "\u{f0580}" + : vol > 0 ? "\u{f057f}" + : "\u{f0581}" + + function openVolDropdown() { + bar.toggleDropdown(volDropdown, function() { + let pos = volWidget.mapToItem(bar.contentItem, volWidget.width / 2, 0); + volDropdown.dropdownX = pos.x; + }); + } + + Text { + id: volText + anchors.verticalCenter: parent.verticalCenter + text: volWidget.volIcon + " " + volWidget.vol + "%" + color: volWidget.muted ? Theme.base03 : Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + onClicked: (event) => { + if (event.button === Qt.MiddleButton) { + if (volWidget.sink && volWidget.sink.audio) + volWidget.sink.audio.muted = !volWidget.sink.audio.muted; + } else { + volWidget.openVolDropdown(); + } + } + onEntered: { + if (bar.activeDropdown) { + if (bar.activeDropdown !== volDropdown) volWidget.openVolDropdown(); + else bar.activeDropdown.resetAutoClose(); + } + } + } + } + + // Network status + Item { + id: netWidget + width: 16 + height: 30 + + property string netState: "disconnected" + property string netConn: "" + property string netType: "" + property string netIcon: "\u{f0b0}" + property var wifiNetworks: [] + property string netDevice: "" + + property string _pendingState: "disconnected" + property string _pendingConn: "" + property string _pendingType: "" + property string _pendingDevice: "" + property var _pendingNets: [] + + Timer { + interval: 5000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: netWidget.refreshNet() + } + + function refreshNet() { + netWidget._pendingState = "disconnected"; + netWidget._pendingConn = ""; + netWidget._pendingType = ""; + netProc.running = true; + } + + Process { + id: netProc + command: [Commands.nmcli, "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"] + stdout: SplitParser { + onRead: data => { + let fields = data.split(":"); + if (fields.length < 4) return; + let type = fields[1]; + let state = fields[2]; + let conn = fields[3]; + if (type !== "ethernet" && type !== "wifi") return; + if (type === "wifi") { + netWidget._pendingDevice = fields[0]; + } + if (state === "connected") { + netWidget._pendingState = "connected"; + netWidget._pendingConn = conn; + netWidget._pendingType = type; + } + } + } + onRunningChanged: { + if (!running) { + netWidget.netState = netWidget._pendingState; + netWidget.netConn = netWidget._pendingConn; + netWidget.netType = netWidget._pendingType.length > 0 ? netWidget._pendingType : netWidget.netType; + netWidget.netDevice = netWidget._pendingDevice.length > 0 ? netWidget._pendingDevice : netWidget.netDevice; + if (netWidget.netState === "connected") { + netWidget.netIcon = netWidget.netType === "wifi" ? "\u{f05a9}" : "\u{f0200}"; + } else { + netWidget.netIcon = netWidget.netType === "wifi" ? "\u{f05aa}" : "\u{f0201}"; + } + } + } + } + + Text { + anchors.centerIn: parent + text: netWidget.netIcon + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 14 + } + + Timer { + id: netRefreshDelay + interval: 2000 + onTriggered: netWidget.refreshNet() + } + + Process { + id: wifiScanProc + command: [Commands.nmcli, "-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "device", "wifi", "list", "--rescan", "auto"] + stdout: SplitParser { + onRead: data => { + let fields = data.split(":"); + if (fields.length < 4 || fields[0] === "") return; + for (let i = 0; i < netWidget._pendingNets.length; i++) { + if (netWidget._pendingNets[i].ssid === fields[0]) return; + } + netWidget._pendingNets.push({ + ssid: fields[0], + signal: parseInt(fields[1]) || 0, + security: fields[2], + active: fields[3] === "*" + }); + } + } + onRunningChanged: { + if (!running) { + netWidget.wifiNetworks = netWidget._pendingNets; + netWidget._pendingNets = []; + } + } + } + + Process { + id: wifiConnectProc + property string targetSsid: "" + command: [Commands.wifiConnect, targetSsid] + } + + Process { + id: netDisconnectProc + property string targetDevice: "" + command: [Commands.nmcli, "device", "disconnect", targetDevice] + } + + function openNetDropdown() { + bar.toggleDropdown(netDropdown, function() { + wifiScanProc.running = true; + let pos = netWidget.mapToItem(bar.contentItem, netWidget.width / 2, 0); + netDropdown.dropdownX = pos.x; + }); + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: netWidget.openNetDropdown() + onEntered: { + if (bar.activeDropdown) { + if (bar.activeDropdown !== netDropdown) netWidget.openNetDropdown(); + else bar.activeDropdown.resetAutoClose(); + } + } + } + } + + ${lib.optionalString isMacbook '' + // Battery + Item { + id: batteryWidget + width: batteryText.width + 4 + batteryIconText.width + height: 30 + + property int batteryLevel: 0 + property bool charging: false + property string batteryIcon: "\u{f008e}" + property real powerDraw: 0.0 + property real energyNow: 0.0 + property real energyFull: 0.0 + property string timeRemaining: "" + property string powerProfile: "balanced" + + function updateIcon() { + if (charging) { batteryIcon = "\u{f0084}"; return; } + if (batteryLevel >= 90) batteryIcon = "\u{f0079}"; + else if (batteryLevel >= 70) batteryIcon = "\u{f0082}"; + else if (batteryLevel >= 50) batteryIcon = "\u{f007f}"; + else if (batteryLevel >= 30) batteryIcon = "\u{f007c}"; + else if (batteryLevel >= 15) batteryIcon = "\u{f007a}"; + else batteryIcon = "\u{f008e}"; + } + + Timer { + interval: 5000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: { batteryProc.running = true; profileProc.running = true; } + } + + Process { + id: batteryProc + command: ["sh", "-c", "cat /sys/class/power_supply/BAT0/capacity; cat /sys/class/power_supply/BAT0/status; cat /sys/class/power_supply/BAT0/power_now 2>/dev/null || echo 0; cat /sys/class/power_supply/BAT0/energy_now 2>/dev/null || echo 0; cat /sys/class/power_supply/BAT0/energy_full 2>/dev/null || echo 0"] + stdout: SplitParser { + property int lineNum: 0 + onRead: data => { + let trimmed = data.trim(); + let num = parseInt(trimmed); + lineNum++; + if (lineNum === 1) { + if (!isNaN(num)) batteryWidget.batteryLevel = num; + } else if (lineNum === 2) { + batteryWidget.charging = (trimmed === "Charging"); + } else if (lineNum === 3) { + if (!isNaN(num)) batteryWidget.powerDraw = num / 1000000.0; + } else if (lineNum === 4) { + if (!isNaN(num)) batteryWidget.energyNow = num / 1000000.0; + } else if (lineNum === 5) { + if (!isNaN(num)) batteryWidget.energyFull = num / 1000000.0; + lineNum = 0; + if (batteryWidget.powerDraw > 0.5) { + let hours; + if (batteryWidget.charging) { + hours = (batteryWidget.energyFull - batteryWidget.energyNow) / batteryWidget.powerDraw; + } else { + hours = batteryWidget.energyNow / batteryWidget.powerDraw; + } + let h = Math.floor(hours); + let m = Math.round((hours - h) * 60); + batteryWidget.timeRemaining = h + "h " + m + "m"; + } else { + batteryWidget.timeRemaining = ""; + } + } + batteryWidget.updateIcon(); + } + } + } + + Process { + id: profileProc + command: [Commands.powerprofilesctl, "get"] + stdout: SplitParser { + onRead: data => { + batteryWidget.powerProfile = data.trim(); + } + } + } + + Process { + id: setProfileProc + property string target: "balanced" + command: [Commands.powerprofilesctl, "set", target] + } + + Row { + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + Text { + id: batteryText + text: batteryWidget.batteryLevel + "%" + color: batteryWidget.batteryLevel <= 15 ? Theme.base08 + : batteryWidget.batteryLevel <= 30 ? Theme.base0A + : Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + } + + Text { + id: batteryIconText + text: batteryWidget.batteryIcon + color: batteryWidget.batteryLevel <= 15 ? Theme.base08 + : batteryWidget.batteryLevel <= 30 ? Theme.base0A + : Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 14 + } + } + + function openBatteryDropdown() { + bar.toggleDropdown(batteryDropdown, function() { + batteryProc.running = true; + profileProc.running = true; + let pos = batteryWidget.mapToItem(bar.contentItem, batteryWidget.width / 2, 0); + batteryDropdown.dropdownX = pos.x; + }); + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: batteryWidget.openBatteryDropdown() + onEntered: { + if (bar.activeDropdown) { + if (bar.activeDropdown !== batteryDropdown) batteryWidget.openBatteryDropdown(); + else bar.activeDropdown.resetAutoClose(); + } + } + } + } + ''} + + // Tray icons + Row { + id: trayArea + spacing: 8 + height: 30 + anchors.verticalCenter: parent.verticalCenter + + HoverHandler { + onHoveredChanged: { + if (hovered && bar.activeDropdown) bar.activeDropdown.resetAutoClose(); + } + } + + Repeater { + model: SystemTray.items + + Item { + required property var modelData + width: 24 + height: 30 + + Image { + id: trayIcon + anchors.centerIn: parent + width: 16 + height: 16 + source: modelData.icon + sourceSize.width: 16 + sourceSize.height: 16 + smooth: true + mipmap: true + visible: false + } + + ColorOverlay { + anchors.fill: trayIcon + source: trayIcon + color: Theme.base05 + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + 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 + let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0); + 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); + contextMenu.dropdownX = pos.x; + contextMenu.trayItem = modelData; + menuOpener.menu = modelData.menu; + }); + } + } + } + } + } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (event) => { + if (modelData.hasMenu) { + bar.toggleDropdown(contextMenu, function() { + let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0); + contextMenu.dropdownX = pos.x; + contextMenu.trayItem = modelData; + menuOpener.menu = modelData.menu; + }); + } else { + modelData.activate(); + } + } + } + } + } + } + } + + // Reusable dropdown component + component BarDropdown: Item { + id: dropdown + property bool open: false + property bool closing: false + property real dropdownX: 0 + property real fullWidth: 200 + property real fullHeight: 200 + property int autoCloseMs: 1500 + property bool alignRight: false + property real dropdownHeight: _dropdownRect.height + default property alias content: dropdownContent.data + + function animateClose() { + if (!visible || closing) return; + closing = true; + open = false; + _autoClose.stop(); + _closeDelay.start(); + } + + function resetAutoClose() { + if (visible && !closing) _autoClose.restart(); + } + + x: alignRight ? bar.width - width : Math.min( + bar.width - width, + Math.max(0, dropdownX - (fullWidth + 16) / 2) + ) + y: 30 + visible: false + width: fullWidth + (alignRight ? 8 : 16) + height: fullHeight + 4 + (alignRight ? 8 : 0) + + onVisibleChanged: { + 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: 230 + onTriggered: { dropdown.visible = false; dropdown.closing = false; if (bar.activeDropdown === dropdown) bar.activeDropdown = null; } + } + + HoverHandler { + onHoveredChanged: { + if (hovered) _autoClose.stop(); + else _autoClose.restart(); + } + } + + // Left ear + Item { + anchors.right: _dropdownRect.left + anchors.top: parent.top + width: 8 + height: Math.min(8, _dropdownRect.height) + clip: true + visible: _dropdownRect.height >= 8 + Canvas { + anchors.top: parent.top + width: 8; height: 8 + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, 8, 8); + ctx.fillStyle = Theme.barBg; + ctx.beginPath(); + ctx.moveTo(0, 0); ctx.lineTo(8, 0); ctx.lineTo(8, 8); + ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); + ctx.closePath(); ctx.fill(); + // Border stroke along the curve + ctx.strokeStyle = Theme.base03; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); + ctx.stroke(); + } + } + } + + // Right ear (for centered dropdowns) + Item { + anchors.left: _dropdownRect.right + anchors.top: parent.top + width: 8 + height: Math.min(8, _dropdownRect.height) + clip: true + visible: _dropdownRect.height >= 8 && !dropdown.alignRight + Canvas { + anchors.top: parent.top + width: 8; height: 8 + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, 8, 8); + ctx.fillStyle = Theme.barBg; + ctx.beginPath(); + ctx.moveTo(0, 0); ctx.lineTo(8, 0); + ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true); + ctx.closePath(); ctx.fill(); + // Border stroke along the curve + ctx.strokeStyle = Theme.base03; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true); + ctx.stroke(); + } + } + } + + Rectangle { + id: _dropdownRect + anchors.right: dropdown.alignRight ? parent.right : undefined + anchors.horizontalCenter: dropdown.alignRight ? undefined : parent.horizontalCenter + anchors.top: parent.top + width: dropdown.fullWidth + height: dropdown.open ? dropdown.fullHeight : 0 + color: Theme.barBg + radius: 8 + topLeftRadius: 0 + topRightRadius: 0 + bottomRightRadius: dropdown.alignRight ? 0 : 8 + clip: true + + // Border outline (sides + bottom with rounded corners) + Canvas { + id: _dropdownBorder + anchors.fill: parent + onPaint: { + var ctx = getContext("2d"); + var w = width, h = height, r = 8; + ctx.clearRect(0, 0, w, h); + if (h < 1) return; + ctx.strokeStyle = Theme.base03; + ctx.lineWidth = 1; + ctx.beginPath(); + // Start below the top ear, go down left side + ctx.moveTo(0.5, r); + ctx.lineTo(0.5, h - r); + // Bottom-left curve + ctx.arc(r + 0.5, h - r - 0.5, r, Math.PI, Math.PI / 2, true); + // Bottom edge + if (dropdown.alignRight) { + // Stop 8px before right edge — bottom-right ear continues + ctx.lineTo(w - r, h - 0.5); + } else { + ctx.lineTo(w - r - 0.5, h - 0.5); + // Bottom-right curve + ctx.arc(w - r - 0.5, h - r - 0.5, r, Math.PI / 2, 0, true); + // Right side up (stop at ear height) + ctx.lineTo(w - 0.5, r); + } + ctx.stroke(); + } + // Repaint when size changes + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + } + + Behavior on height { + NumberAnimation { duration: 220; easing.type: Easing.OutCubic } + } + + Item { + id: dropdownContent + anchors.fill: parent + } + } + + // Bottom-right concave ear — connects dropdown bottom to right screen edge + Item { + visible: dropdown.alignRight && _dropdownRect.height >= 8 + anchors.right: _dropdownRect.right + anchors.top: _dropdownRect.bottom + width: 8 + height: Math.min(8, _dropdownRect.height) + clip: true + Canvas { + width: 8; height: 8 + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, 8, 8); + ctx.fillStyle = Theme.barBg; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(8, 0); + ctx.lineTo(8, 8); + ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); + ctx.fill(); + // Border stroke along the curve + ctx.strokeStyle = Theme.base03; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); + ctx.stroke(); + } + } + } + } + + // Context menu + BarDropdown { + id: contextMenu + alignRight: true + property var trayItem: null + fullWidth: menuItems.width + 24 + fullHeight: menuItems.height + 16 + + onVisibleChanged: { + if (!visible) menuOpener.menu = null; + } + + QsMenuOpener { + id: menuOpener + } + + Column { + id: menuItems + anchors.centerIn: parent + width: 200 + + Repeater { + model: menuOpener.children + + Rectangle { + required property var modelData + width: 200 + height: modelData.isSeparator ? 9 : 28 + color: !modelData.isSeparator && itemMouse.containsMouse && modelData.enabled + ? Theme.base02 : "transparent" + radius: modelData.isSeparator ? 0 : 4 + + Rectangle { + visible: modelData.isSeparator + anchors.centerIn: parent + width: parent.width - 20 + height: 1 + color: Theme.base03 + } + + RowLayout { + visible: !modelData.isSeparator + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + + Text { + Layout.fillWidth: true + text: modelData.text ?? "" + color: modelData.enabled ? Theme.base05 : Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 12 + elide: Text.ElideRight + } + + Text { + visible: modelData.buttonType !== QsMenuButtonType.None + text: modelData.checkState === Qt.Checked ? "\u2713" : "" + color: Theme.base0D + font.family: "FiraMono Nerd Font" + font.pixelSize: 12 + } + } + + MouseArea { + id: itemMouse + anchors.fill: parent + hoverEnabled: true + enabled: !modelData.isSeparator && modelData.enabled + onClicked: { + modelData.triggered(); + bar.closeAllDropdowns(); + } + } + } + } + } + } + + // Volume dropdown + BarDropdown { + id: volDropdown + alignRight: true + fullWidth: volDropdownCol.width + 28 + fullHeight: volDropdownCol.height + 20 + autoCloseMs: 3000 + + Column { + id: volDropdownCol + anchors.centerIn: parent + width: 260 + spacing: 8 + + // Master volume + Text { + text: "\u{f057e} Master" + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + font.weight: Font.Medium + } + + Row { + width: parent.width + spacing: 8 + + Rectangle { + id: masterSliderBg + width: parent.width - masterVolLabel.width - 8 + height: 20 + radius: 4 + color: Theme.base01 + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + width: volWidget.sink && volWidget.sink.audio + ? Math.min(1, volWidget.sink.audio.volume) * parent.width : 0 + height: parent.height + radius: 4 + color: volWidget.muted ? Theme.base03 : Theme.base0D + Behavior on width { NumberAnimation { duration: 80 } } + } + + MouseArea { + anchors.fill: parent + onPressed: (mouse) => setVolume(mouse) + onPositionChanged: (mouse) => { if (pressed) setVolume(mouse); } + function setVolume(mouse) { + if (!volWidget.sink || !volWidget.sink.audio) return; + let v = Math.max(0, Math.min(1, mouse.x / width)); + volWidget.sink.audio.volume = v; + } + } + } + + Text { + id: masterVolLabel + width: 36 + text: volWidget.vol + "%" + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + + // Mute button + Rectangle { + width: parent.width + height: 28 + color: masterMuteMa.containsMouse ? Theme.base02 : "transparent" + radius: 4 + + Text { + anchors.centerIn: parent + text: volWidget.muted ? "\u{f0581} Unmute" : "\u{f057e} Mute" + color: Theme.base05 + font.family: "FiraMono Nerd Font" + 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; + } + } + } + + // Separator + Rectangle { + width: parent.width - 20 + anchors.horizontalCenter: parent.horizontalCenter + height: 1 + color: Theme.base02 + visible: appStreamsCol.childrenRect.height > 0 + } + + // App streams header + Text { + visible: appStreamsCol.childrenRect.height > 0 + text: "\u{f0641} Applications" + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + font.weight: Font.Medium + } + + // Per-app streams + Column { + id: appStreamsCol + width: parent.width + spacing: 6 + + Repeater { + id: appStreamsRepeater + model: Pipewire.nodes + + Column { + required property var modelData + width: parent.width + spacing: 2 + visible: modelData.isStream && modelData.audio !== null + + PwObjectTracker { + objects: [modelData] + } + + Text { + text: modelData.properties["application.name"] || modelData.name || "Unknown" + color: Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + elide: Text.ElideRight + width: parent.width + } + + Row { + width: parent.width + spacing: 8 + + Rectangle { + width: parent.width - appVolLabel.width - 8 + height: 16 + radius: 3 + color: Theme.base01 + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + width: modelData.audio + ? Math.min(1, modelData.audio.volume) * parent.width : 0 + height: parent.height + radius: 3 + color: modelData.audio && modelData.audio.muted + ? Theme.base03 : Theme.base0C + Behavior on width { NumberAnimation { duration: 80 } } + } + + MouseArea { + anchors.fill: parent + onPressed: (mouse) => setVol(mouse) + onPositionChanged: (mouse) => { if (pressed) setVol(mouse); } + function setVol(mouse) { + if (!modelData.audio) return; + let v = Math.max(0, Math.min(1, mouse.x / width)); + modelData.audio.volume = v; + } + } + } + + Text { + id: appVolLabel + width: 36 + text: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : "0%" + color: Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 10 + horizontalAlignment: Text.AlignRight + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + } + } + } + + // Network dropdown + BarDropdown { + id: netDropdown + alignRight: true + fullWidth: netDropdownCol.width + 28 + fullHeight: netDropdownCol.height + 20 + + Column { + id: netDropdownCol + anchors.centerIn: parent + width: 220 + spacing: 4 + + Text { + width: parent.width + text: netWidget.netState === "connected" + ? "\u{f05a9} " + netWidget.netConn + : "\u{f05aa} Not connected" + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Rectangle { + visible: netWidget.netState === "connected" + width: parent.width + height: 28 + color: disconnectMouse.containsMouse ? Theme.base02 : "transparent" + radius: 4 + + Text { + anchors.centerIn: parent + text: "Disconnect" + color: Theme.base08 + font.family: "FiraMono Nerd Font" + font.pixelSize: 12 + } + + MouseArea { + id: disconnectMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + netDisconnectProc.targetDevice = netWidget.netDevice; + netDisconnectProc.running = true; + netWidget.netState = "disconnected"; + netWidget.netConn = ""; + netWidget.netIcon = "\u{f05aa}"; + bar.closeAllDropdowns(); + netRefreshDelay.start(); + } + } + } + + Rectangle { + width: parent.width - 20 + anchors.horizontalCenter: parent.horizontalCenter + height: 1 + color: Theme.base03 + } + + Text { + text: "Available networks" + color: Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + topPadding: 2 + } + + Repeater { + model: netWidget.wifiNetworks + + Rectangle { + required property var modelData + width: 220 + height: 32 + color: netItemMouse.containsMouse ? Theme.base02 : "transparent" + radius: 4 + + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.right: parent.right + anchors.rightMargin: 8 + spacing: 8 + + Text { + text: { + let s = modelData.signal; + if (s >= 75) return "\u{f05a9}"; + if (s >= 50) return "\u{f05a9}"; + if (s >= 25) return "\u{f05a9}"; + return "\u{f05aa}"; + } + color: modelData.active ? Theme.base0B : Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: modelData.ssid + color: modelData.active ? Theme.base0B : Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 12 + elide: Text.ElideRight + width: 140 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + visible: modelData.security !== "" && modelData.security !== "--" + text: "\u{f0341}" + color: Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: netItemMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + if (!modelData.active) { + wifiConnectProc.targetSsid = modelData.ssid; + wifiConnectProc.running = true; + netRefreshDelay.start(); + } + bar.closeAllDropdowns(); + } + } + } + } + } + } + + ${lib.optionalString isMacbook '' + // Battery dropdown + BarDropdown { + id: batteryDropdown + alignRight: true + fullWidth: batteryDropdownCol.width + 28 + fullHeight: batteryDropdownCol.height + 20 + + Column { + id: batteryDropdownCol + anchors.centerIn: parent + width: 200 + spacing: 8 + + Row { + width: parent.width + spacing: 8 + + Text { + text: batteryWidget.batteryIcon + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 18 + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + Text { + text: batteryWidget.batteryLevel + "%" + (batteryWidget.charging ? " — Charging" : "") + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + font.weight: Font.Medium + } + Text { + text: batteryWidget.powerDraw.toFixed(1) + " W" + + (batteryWidget.timeRemaining !== "" ? " \u2022 " + batteryWidget.timeRemaining + (batteryWidget.charging ? " to full" : " left") : "") + color: Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + } + } + } + + Rectangle { + width: parent.width - 10 + anchors.horizontalCenter: parent.horizontalCenter + height: 1 + color: Theme.base03 + } + + Text { + text: "Power Profile" + color: Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + } + + Row { + width: parent.width + spacing: 4 + + Repeater { + model: [ + { name: "power-saver", label: "\u{f0425}", tip: "Saver" }, + { name: "balanced", label: "\u{f0376}", tip: "Balanced" }, + { name: "performance", label: "\u{f0e0e}", tip: "Performance" } + ] + + Rectangle { + required property var modelData + width: (parent.width - 8) / 3 + height: 36 + radius: 6 + color: batteryWidget.powerProfile === modelData.name + ? Theme.base02 : profMouse.containsMouse + ? Theme.base01 : "transparent" + border.width: batteryWidget.powerProfile === modelData.name ? 1 : 0 + border.color: Theme.base03 + + Column { + anchors.centerIn: parent + spacing: 1 + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: modelData.label + color: batteryWidget.powerProfile === modelData.name + ? Theme.base0D : Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 14 + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: modelData.tip + color: Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 9 + } + } + + MouseArea { + id: profMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + setProfileProc.target = modelData.name; + setProfileProc.running = true; + batteryWidget.powerProfile = modelData.name; + } + } + } + } + } + } + } + ''} + + // Calendar popup + BarDropdown { + id: calPopup + dropdownX: bar.width / 2 + fullWidth: calCol.width + 8 + fullHeight: calCol.height + 4 + + Column { + id: calCol + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + + spacing: 8 + opacity: calPopup.open ? 1.0 : 0.0 + + Behavior on opacity { + NumberAnimation { duration: 150; easing.type: Easing.OutCubic } + } + + Text { + id: calTitle + anchors.horizontalCenter: parent.horizontalCenter + text: clockText.now.toLocaleDateString(Qt.locale(), "dddd, d MMMM yyyy") + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 16 + font.weight: Font.Medium + } + + Row { + id: weekdayRow + anchors.horizontalCenter: parent.horizontalCenter + spacing: 0 + Repeater { + model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] + Text { + required property var modelData + width: 32 + horizontalAlignment: Text.AlignHCenter + text: modelData + color: Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + } + } + } + + Grid { + columns: 7 + spacing: 0 + + Repeater { + id: calRepeater + model: 42 + + Rectangle { + required property int index + width: 32 + height: 26 + radius: 4 + color: { + let d = clockText.now; + let first = new Date(d.getFullYear(), d.getMonth(), 1); + let startDay = (first.getDay() + 6) % 7; + let dayNum = index - startDay + 1; + let daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); + return (dayNum === d.getDate() && dayNum >= 1 && dayNum <= daysInMonth) + ? Theme.base03 : "transparent"; + } + + Text { + anchors.centerIn: parent + text: { + let d = clockText.now; + let first = new Date(d.getFullYear(), d.getMonth(), 1); + let startDay = (first.getDay() + 6) % 7; + let dayNum = parent.index - startDay + 1; + let daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); + return (dayNum >= 1 && dayNum <= daysInMonth) ? dayNum.toString() : ""; + } + color: { + let d = clockText.now; + let first = new Date(d.getFullYear(), d.getMonth(), 1); + let startDay = (first.getDay() + 6) % 7; + let dayNum = parent.index - startDay + 1; + return (dayNum === d.getDate()) ? Theme.base05 : Theme.base04; + } + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + } + } + } + } + + Rectangle { + width: 7 * 32 + 8 + height: 1 + color: Theme.base02 + anchors.horizontalCenter: parent.horizontalCenter + } + + Row { + width: 7 * 32 + 8 + anchors.horizontalCenter: parent.horizontalCenter + Text { + text: "Notifications" + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + font.weight: Font.Medium + } + Item { Layout.fillWidth: true; width: 10 } + Text { + anchors.right: parent.right + text: bar.notifServer.trackedNotifications.values.length > 0 ? "Clear all" : "" + color: Theme.base04 + font.family: "FiraMono Nerd Font" + 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(); + } + } + } + } + } + + Column { + spacing: 4 + width: 7 * 32 + 8 + anchors.horizontalCenter: parent.horizontalCenter + + Text { + visible: bar.notifServer.trackedNotifications.values.length === 0 + text: "No notifications" + color: Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + anchors.horizontalCenter: parent.horizontalCenter + } + + Repeater { + model: bar.notifServer.trackedNotifications + + Rectangle { + id: notifItem + required property var modelData + width: 7 * 32 + 8 + height: notifCol.height + 12 + radius: 6 + color: Theme.base01 + + Column { + id: notifCol + anchors.left: parent.left + anchors.right: dismissBtn.left + anchors.top: parent.top + anchors.margins: 6 + spacing: 2 + + Text { + width: parent.width + text: notifItem.modelData.summary || notifItem.modelData.appName + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: notifItem.modelData.body || "" + color: Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 10 + elide: Text.ElideRight + maximumLineCount: 2 + wrapMode: Text.Wrap + visible: text !== "" + } + + Row { + spacing: 4 + visible: notifItem.modelData.actions.length > 0 + Repeater { + model: notifItem.modelData.actions + Rectangle { + required property var modelData + width: actionText.width + 12 + height: actionText.height + 4 + radius: 4 + color: actionMa.containsMouse ? Theme.base02 : Theme.base01 + border.width: 1 + border.color: Theme.base02 + Text { + id: actionText + anchors.centerIn: parent + text: modelData.text + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 10 + } + MouseArea { + id: actionMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: modelData.invoke() + } + } + } + } + } + + Text { + id: dismissBtn + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 6 + text: "\u{f0156}" + color: dismissMa.containsMouse ? Theme.base05 : Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 12 + MouseArea { + id: dismissMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: notifItem.modelData.dismiss() + } + } + } + } + } + } + } + + // ── Notification Toast (only on primary screen) ── + Item { + id: toastItem + visible: false + property var currentNotif: null + property bool toastOpen: false + readonly property var mutedApps: ["discord", "Discord", "Vesktop", "vesktop", "Spotify", "spotify", "vlc", "mpv"] + readonly property bool isPrimary: bar.screen === Quickshell.screens[0] + + x: Math.round(bar.width / 2 - width / 2) + y: 30 + width: _toastLeftEar.width + _toastRect.width + _toastRightEar.width + height: _toastRect.height + 4 + + Process { + id: notifSoundProc + command: [Commands.notifSound, "-i", "message"] + } + + Connections { + target: bar.shellRoot + function onNotificationReceived() { + if (toastItem.isPrimary) { + toastItem.showToast(bar.shellRoot.latestNotification); + } + } + } + + function showToast(notification) { + currentNotif = notification; + visible = true; + toastOpen = true; + _toastTimer.restart(); + if (!mutedApps.includes(notification.appName)) { + notifSoundProc.running = true; + } + } + + function dismiss() { + toastOpen = false; + _toastCloseDelay.start(); + } + + Timer { + id: _toastTimer + interval: 5000 + onTriggered: toastItem.dismiss() + } + + Timer { + id: _toastCloseDelay + interval: 230 + onTriggered: { toastItem.visible = false; toastItem.toastOpen = false; } + } + + HoverHandler { + onHoveredChanged: { + if (hovered) _toastTimer.stop(); + else _toastTimer.restart(); + } + } + + // Left inverse corner ear + Item { + id: _toastLeftEar + anchors.right: _toastRect.left + anchors.top: parent.top + width: 8 + height: Math.min(8, _toastRect.height) + clip: true + visible: _toastRect.height >= 8 + Canvas { + anchors.top: parent.top + width: 8; height: 8 + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, 8, 8); + ctx.fillStyle = Theme.barBg; + ctx.beginPath(); + ctx.moveTo(0, 0); ctx.lineTo(8, 0); ctx.lineTo(8, 8); + ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); + ctx.closePath(); ctx.fill(); + ctx.strokeStyle = Theme.base03; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(0, 8, 8, 0, -Math.PI / 2, true); + ctx.stroke(); + } + } + } + + // Right inverse corner ear + Item { + id: _toastRightEar + anchors.left: _toastRect.right + anchors.top: parent.top + width: 8 + height: Math.min(8, _toastRect.height) + clip: true + visible: _toastRect.height >= 8 + Canvas { + anchors.top: parent.top + width: 8; height: 8 + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, 8, 8); + ctx.fillStyle = Theme.barBg; + ctx.beginPath(); + ctx.moveTo(0, 0); ctx.lineTo(8, 0); + ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true); + ctx.closePath(); ctx.fill(); + ctx.strokeStyle = Theme.base03; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true); + ctx.stroke(); + } + } + } + + Rectangle { + id: _toastRect + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: 320 + height: toastItem.toastOpen ? toastCol.height + 16 : 0 + color: Theme.barBg + radius: 8 + topLeftRadius: 0 + topRightRadius: 0 + clip: true + + // Border outline (sides + bottom with rounded corners) + Canvas { + anchors.fill: parent + onPaint: { + var ctx = getContext("2d"); + var w = width, h = height, r = 8; + ctx.clearRect(0, 0, w, h); + if (h < 1) return; + ctx.strokeStyle = Theme.base03; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0.5, r); + ctx.lineTo(0.5, h - r); + ctx.arc(r + 0.5, h - r - 0.5, r, Math.PI, Math.PI / 2, true); + ctx.lineTo(w - r - 0.5, h - 0.5); + ctx.arc(w - r - 0.5, h - r - 0.5, r, Math.PI / 2, 0, true); + ctx.lineTo(w - 0.5, r); + ctx.stroke(); + } + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + } + + Behavior on height { + NumberAnimation { duration: 220; easing.type: Easing.OutCubic } + } + + Column { + id: toastCol + anchors.left: parent.left + anchors.right: toastDismiss.left + anchors.top: parent.top + anchors.margins: 8 + spacing: 2 + + Text { + width: parent.width + text: toastItem.currentNotif ? (toastItem.currentNotif.summary || toastItem.currentNotif.appName) : "" + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 12 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: toastItem.currentNotif ? (toastItem.currentNotif.body || "") : "" + color: Theme.base04 + font.family: "FiraMono Nerd Font" + font.pixelSize: 11 + elide: Text.ElideRight + maximumLineCount: 3 + wrapMode: Text.Wrap + visible: text !== "" + } + + Row { + spacing: 4 + visible: toastItem.currentNotif && toastItem.currentNotif.actions.length > 0 + Repeater { + model: toastItem.currentNotif ? toastItem.currentNotif.actions : [] + Rectangle { + required property var modelData + width: toastActionText.width + 12 + height: toastActionText.height + 6 + radius: 4 + color: toastActionMa.containsMouse ? Theme.base02 : Theme.base01 + border.width: 1 + border.color: Theme.base02 + Text { + id: toastActionText + anchors.centerIn: parent + text: modelData.text + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 10 + } + MouseArea { + id: toastActionMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { modelData.invoke(); toastItem.dismiss(); } + } + } + } + } + } + + Text { + id: toastDismiss + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + text: "\u{f0156}" + color: toastDismissMa.containsMouse ? Theme.base05 : Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + MouseArea { + id: toastDismissMa + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { toastItem.currentNotif.dismiss(); toastItem.dismiss(); } + } + } + } + } + } + ''; + }; + }; + }; + }; +} From a4351473d01e30b32f4ecf80bec5274a527d5336 Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 11 Jun 2026 10:00:02 +0100 Subject: [PATCH 5/6] common: enable flakes, drop duplicate host imports, import quickshell Co-Authored-By: Claude Fable 5 --- common.nix | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/common.nix b/common.nix index 310fa5b..48cb9c8 100644 --- a/common.nix +++ b/common.nix @@ -3,14 +3,12 @@ { imports = [ - # Hosts # - ./hosts/FredOS-Gaming.nix - ./hosts/FredOS-Macbook.nix - ./hosts/FredOS-Mediaserver.nix - + # Host modules are imported per-host by mkHost in flake.nix. + # Generic settings # ./settings/desktop.nix ./settings/hyprland.nix + ./settings/quickshell.nix ./settings/locale.nix ./settings/audio.nix ./settings/users.nix @@ -89,6 +87,10 @@ # Allow unfree packages nixpkgs.config.allowUnfree = true; + # 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" ]; + # Enable network-manager networking.networkmanager.enable = true; From 9671dfb7933340d9ff17f37258deb4e6f6b0a1c1 Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 11 Jun 2026 10:00:02 +0100 Subject: [PATCH 6/6] docs: update readme and CLAUDE.md for forgejo and 26.05 Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 23 ++++++-- readme.md | 157 +++++++++++++++++++++++------------------------------- 2 files changed, 86 insertions(+), 94 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 706dc1c..8287bfe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,18 +2,28 @@ This is a NixOS flake-based configuration for multiple hosts: - **FredOS-Gaming** — gaming desktop -- **FredOS-Mediaserver** — home media server +- **FredOS-Mediaserver** — home media server **and the home router** (nftables NAT/firewall in `services/router.nix`; `networking.firewall` is disabled on this host, WAN exposure comes from `ports.toml`) - **FredOS-Macbook** — MacBook laptop ## Structure -- `flake.nix` — flake inputs/outputs; all hosts use `nixpkgs` unstable +- `flake.nix` — flake inputs/outputs; all hosts track the `nixos-26.05` stable channel - `common.nix` — shared configuration across all hosts -- `hosts/` — per-host NixOS configuration modules +- `hosts/` — per-host NixOS configuration modules (imported per-host by `mkHost` in flake.nix) - `hosts/hardware/` — hardware-specific configuration - `home-manager/` — Home Manager configuration (via NixOS module) -- `services/` — modular service definitions imported by hosts -- `settings/` — shared settings/variables +- `services/` — modular service definitions, gated by hostname with `lib.mkIf` +- `settings/` — shared settings (desktop, hyprland, quickshell, stylix, …) +- `modules/crowdsec/` — vendored crowdsec modules from nixpkgs PR #446307; delete once that PR lands in the pinned channel +- `ports.toml` — WAN → LAN port forwards consumed by `services/router.nix` + +## Deployment + +Hosts never pull this repo locally — they rebuild from the Forgejo remote via the +`update` alias (`nixos-rebuild switch --refresh --flake git+https://forg.gregersen.it/rope/nixos`). +That means evaluation is **pure**: config can never read files outside the repo +(e.g. `/var/secrets`) at eval time. Secrets must be injected at service runtime +(see `services/crowdsec.nix` and `services/go2rtc.nix` for the pattern). ## Code Evaluation @@ -23,6 +33,9 @@ Always validate Nix expressions with `nix eval` before committing. For example: # Evaluate a specific attribute to check for syntax/type errors nix eval .#nixosConfigurations.FredOS-Gaming.config.system.stateVersion +# Full eval of a host without building +nix eval --raw .#nixosConfigurations.FredOS-Mediaserver.config.system.build.toplevel.drvPath + # Evaluate the full flake outputs to catch top-level errors nix eval .#nixosConfigurations --apply builtins.attrNames ``` diff --git a/readme.md b/readme.md index 8d004a3..d692548 100644 --- a/readme.md +++ b/readme.md @@ -1,86 +1,89 @@ # FredOS NixOS Configuration -Flake-based NixOS configuration for three machines, built and deployed directly from GitHub. No local config management required after initial setup. +Flake-based NixOS configuration for three machines, built and deployed directly from the Forgejo repo at `https://forg.gregersen.it/rope/nixos`. No local checkout required after initial setup. ## Machines | Hostname | Description | |---|---| -| FredOS-Gaming | AMD desktop, UEFI/systemd-boot | +| FredOS-Gaming | AMD desktop, UEFI/systemd-boot, CachyOS kernel | | FredOS-Macbook | Intel laptop, UEFI/systemd-boot | -| FredOS-Mediaserver | Intel server, BIOS/GRUB | +| FredOS-Mediaserver | Intel server, UEFI/systemd-boot — media services **and** the home router | ## Structure ``` -├── .github +├── .forgejo │ └── workflows -│ └── update.yml # Auto-updates flake.lock daily +│ └── update.yml # Auto-updates flake.lock daily (self-hosted runner) ├── apps -│ └── zen.nix # Zen browser config +│ └── zen.nix # Zen browser (flake input) ├── home-manager -│ ├── fred.nix # User-level Home Manager config -│ └── gnome-hm.nix # GNOME Home Manager settings +│ └── fred.nix # User-level Home Manager config ├── hosts │ ├── FredOS-Gaming.nix # Gaming: packages, Steam, boot options -│ ├── FredOS-Macbook.nix # Macbook: packages, power management, boot options -│ ├── FredOS-Mediaserver.nix # Mediaserver: packages, networking, SSH +│ ├── FredOS-Macbook.nix # Macbook: packages, power management, DWT daemon +│ ├── FredOS-Mediaserver.nix # Mediaserver: packages, SSH, auto-upgrade │ └── hardware -│ ├── FredOS-Gaming.nix # AMD GPU, kernel modules, filesystems, bootloader, hostname -│ ├── FredOS-Macbook.nix # Broadcom WiFi, Intel GPU, Bluetooth, filesystems, bootloader, hostname -│ └── FredOS-Mediaserver.nix # Intel CPU, data disks, mergerfs pool, GRUB, hostname +│ ├── FredOS-Gaming.nix # AMD GPU, CachyOS kernel overlay, filesystems, hostname +│ ├── FredOS-Macbook.nix # Broadcom WiFi, Intel GPU, filesystems, hostname +│ └── FredOS-Mediaserver.nix # NVIDIA NVENC, data disks, mergerfs pool, hostname +├── modules +│ └── crowdsec # Vendored crowdsec modules (nixpkgs PR #446307, still open) +├── scripts # Helper scripts wrapped as packages on the mediaserver ├── services │ ├── adguard.nix # Network-wide DNS ad blocking │ ├── arr-interconnect.nix # Cross-service API key wiring for *arr apps -│ ├── authelia.nix # SSO/2FA gateway (protects homepage & camera) +│ ├── authelia.nix # SSO/2FA gateway for the nginx vhosts │ ├── bazarr.nix # Subtitle management +│ ├── bazarr-sync.nix # Subtitle sync timers (podman container) │ ├── cloudflare-ddns.nix # Cloudflare dynamic DNS │ ├── code-server.nix # Browser-based VS Code IDE -│ ├── crowdsec.nix # Intrusion prevention / bouncer -│ ├── dr-server.nix # Disaster recovery / backup service -│ ├── forgejo-runner.nix # CI/CD runner for Forgejo -│ ├── game-servers.nix # Dockerised game servers (7 Days to Die) +│ ├── crowdsec.nix # Intrusion prevention + nftables bouncer + ntfy alerts +│ ├── dr-server.nix # Dungeon Runners game server (Wine) — currently disabled +│ ├── forgejo-runner.nix # CI runner for forg.gregersen.it +│ ├── frigate.nix # NVR with object detection +│ ├── game-servers.nix # Dockerised 7 Days to Die servers │ ├── go2rtc.nix # Camera/RTSP streaming │ ├── homepage.nix # Homepage dashboard with auto-extracted API keys │ ├── jellyfin.nix # Media server +│ ├── memos.nix # Flatnotes notes app (container) │ ├── nginx.nix # Reverse proxy + ACME wildcard cert via Cloudflare DNS-01 -│ ├── profilarr.nix # Quality profile manager for *arr apps +│ ├── profilarr.nix # Quality profile manager for *arr apps (container) │ ├── prowlarr.nix # Indexer manager │ ├── qbittorrent-nox.nix # Torrent client │ ├── radarr.nix # Movie management │ ├── router.nix # Mediaserver as home router (NAT, DHCP, nftables) │ ├── sabnzbd.nix # Usenet downloader -│ ├── server-permissions.nix # File/dir permission setup +│ ├── server-permissions.nix # Shared media dir permissions │ └── sonarr.nix # TV management ├── settings │ ├── audio.nix # PipeWire / audio config -│ ├── gnome.nix # GNOME desktop settings -│ ├── hyprland.nix # Hyprland Wayland compositor config +│ ├── desktop.nix # Display manager, theming, flatpak +│ ├── hyprland.nix # Hyprland compositor config (Lua), anyrun +│ ├── quickshell.nix # Quickshell bar/notifications (QML) │ ├── locale.nix # Locale, timezone, keyboard │ ├── shell.nix # Fish shell, powerline prompt, fastfetch, nerd fonts │ ├── stylix.nix # Unified colour theming (wallpaper-derived palette) -│ └── users.nix # User accounts +│ └── users.nix # User accounts, SSH keys +├── templates # CSS templates recoloured by stylix.nix ├── walls # Wallpapers +├── backup-server.sh # One-shot mediaserver state backup (run manually) +├── ports.toml # WAN → LAN port forwards consumed by router.nix ├── common.nix # Shared config imported by all hosts -├── flake.lock # Auto-generated, updated daily by GitHub Actions +├── flake.lock # Auto-generated, updated daily by Forgejo Actions └── flake.nix # Flake inputs and host definitions ``` ## Day-to-day usage -Edit files directly on GitHub, then on the machine run: +Edit files in the Forgejo repo (or locally and push), then on the machine run: ```bash update ``` -That's it. The alias is defined in `common.nix` and expands to: - -```bash -sudo nixos-rebuild switch --flake github:ediblerope/nixos-config --refresh --no-write-lock-file -``` - -Nix automatically matches the running machine's hostname to the correct `nixosConfigurations` entry. +The alias (defined in `common.nix`) runs `sudo nixos-rebuild switch --refresh --flake git+https://forg.gregersen.it/rope/nixos` with nix-output-monitor, then shows an `nvd diff` of what changed. The mediaserver also auto-upgrades daily at 05:15 (`system.autoUpgrade`). Other useful aliases: @@ -98,65 +101,38 @@ Boot the NixOS installer and complete the standard installation. ### 2. Enable flakes temporarily -Add this to `/etc/nixos/configuration.nix` and rebuild: +(The flake config enables them declaratively, but the stock installer config doesn't.) Add to `/etc/nixos/configuration.nix` and rebuild: ```nix nix.settings.experimental-features = [ "nix-command" "flakes" ]; ``` -```bash -sudo nixos-rebuild switch -``` +### 3. Create the hardware config in the repo -### 3. Create the hardware config on GitHub - -Copy the contents of `/etc/nixos/hardware-configuration.nix` and create `hosts/hardware/FredOS-NEWHOST.nix` on GitHub. Append the hostname and bootloader config to it: +Copy `/etc/nixos/hardware-configuration.nix` to `hosts/hardware/FredOS-NEWHOST.nix` and append the hostname and bootloader config: ```nix networking.hostName = "FredOS-NEWHOST"; - -# For UEFI/systemd-boot machines: boot.loader.systemd-boot.enable = true; boot.loader.efi.canTouchEfiVariables = true; - -# For BIOS/GRUB machines instead: -# boot.loader.grub.enable = true; -# boot.loader.grub.devices = [ "/dev/sda" ]; # verify with: sudo grub-probe --target=disk / ``` ### 4. Register the host in flake.nix -In `flake.nix` on GitHub, add to `nixosConfigurations`: - ```nix -FredOS-NEWHOST = mkHost "FredOS-NEWHOST"; +FredOS-NEWHOST = mkHost "FredOS-NEWHOST" []; ``` ### 5. Add host-specific config -Create `hosts/FredOS-NEWHOST.nix` on GitHub for any machine-specific packages or services: - -```nix -{ config, pkgs, lib, ... }: -{ - config = lib.mkIf (config.networking.hostName == "FredOS-NEWHOST") { - # host-specific packages and services here - }; -} -``` - -Then add it to the imports list in `common.nix`: - -```nix -./hosts/FredOS-NEWHOST.nix -``` +Create `hosts/FredOS-NEWHOST.nix` for machine-specific packages or services. `mkHost` imports it automatically — no changes to `common.nix` needed. ### 6. Switch to the flake -Run this once on the new machine with the explicit hostname: +Run once with the explicit hostname: ```bash -sudo nixos-rebuild switch --flake github:ediblerope/nixos-config#FredOS-NEWHOST --refresh --no-write-lock-file +sudo nixos-rebuild switch --refresh --flake git+https://forg.gregersen.it/rope/nixos#FredOS-NEWHOST ``` After this succeeds, the plain `update` alias works from then on. @@ -167,22 +143,19 @@ After this succeeds, the plain `update` alias works from then on. | Input | Source | |---|---| -| nixpkgs | `github:NixOS/nixpkgs/nixos-unstable` | -| nixpkgs-stable | `github:NixOS/nixpkgs/nixos-25.11` | -| home-manager-stable | `github:nix-community/home-manager/release-25.11` | +| nixpkgs | `github:NixOS/nixpkgs/nixos-26.05` | +| home-manager | `github:nix-community/home-manager/release-26.05` | +| stylix | `github:nix-community/stylix/release-26.05` | | zen-browser | `github:0xc000022070/zen-browser-flake` | -| nix-cachyos-kernel | `github:xddxdd/nix-cachyos-kernel/release` | +| nix-cachyos-kernel | `github:xddxdd/nix-cachyos-kernel/release` (own nixpkgs pin — keeps their kernel binary cache usable) | | proton-cachyos-nix | `github:powerofthe69/proton-cachyos-nix` | -| hyprland | `github:hyprwm/Hyprland` | -| stylix | `github:nix-community/stylix/release-25.11` | ## Mediaserver secrets -Several services on FredOS-Mediaserver require secrets that are stored on the machine (not in the repo). After a fresh deploy, create these before running `update`: +Several services on FredOS-Mediaserver require secrets stored on the machine (not in the repo). After a fresh deploy, create these before running `update`: ```bash # Cloudflare API token (used by DDNS and ACME wildcard cert) -# See services/cloudflare-ddns.md for token permissions echo -n 'your-cloudflare-api-token' | sudo tee /var/secrets/cloudflare-token sudo chmod 600 /var/secrets/cloudflare-token @@ -190,18 +163,23 @@ sudo chmod 600 /var/secrets/cloudflare-token echo -n 'rtsp://username:password@camera-ip:554/stream1' | sudo tee /var/secrets/go2rtc-rtsp-url sudo chmod 600 /var/secrets/go2rtc-rtsp-url -# Authelia secrets — auto-migrated from Docker on first deploy -# If migrating from Docker, ensure these exist at /home/fred/docker/authelia/: -# - configuration.yml (jwt_secret, session secret, storage key are extracted) -# - users_database.yml (copied to /var/lib/authelia-main/) -# For a fresh install, create manually: +# CrowdSec ntfy.sh alert topic (injected into the notification config at service start) +echo 'https://ntfy.sh/your-private-topic' | sudo tee /var/secrets/ntfy-url +sudo chmod 600 /var/secrets/ntfy-url + +# Forgejo Actions runner registration token (one-time use, KEY=value format) +echo 'TOKEN=YOUR_REGISTRATION_TOKEN' | sudo tee /var/secrets/forgejo-runner-token +sudo chmod 600 /var/secrets/forgejo-runner-token + +# Authelia secrets — readable by the authelia-main group sudo mkdir -p /var/secrets/authelia echo -n 'random-jwt-secret' | sudo tee /var/secrets/authelia/jwt_secret echo -n 'random-session-secret' | sudo tee /var/secrets/authelia/session_secret echo -n 'random-storage-encryption-key' | sudo tee /var/secrets/authelia/storage_encryption_key -sudo chmod 600 /var/secrets/authelia/* +sudo chown root:authelia-main /var/secrets/authelia/* +sudo chmod 640 /var/secrets/authelia/* -# Authelia user database (for a fresh install) +# Authelia user database # Create users_database.yml with this structure: # --- # users: @@ -219,7 +197,7 @@ sudo chown authelia-main:authelia-main /var/lib/authelia-main/users_database.yml ## Migrating to a new server -When moving FredOS-Mediaserver to new hardware, back up these state directories from the old server: +When moving FredOS-Mediaserver to new hardware, back up these state directories from the old server (`backup-server.sh` automates most of it): ```bash # Service databases and config (stop services first) @@ -232,7 +210,7 @@ When moving FredOS-Mediaserver to new hardware, back up these state directories /var/lib/authelia-main/ # User database and session storage # Secrets -/var/secrets/ # Cloudflare token, go2rtc RTSP URL, Authelia secrets +/var/secrets/ # See "Mediaserver secrets" above # Media files /mnt/storage/ # The mergerfs pool (torrents, media libraries, audiobooks) @@ -241,17 +219,18 @@ When moving FredOS-Mediaserver to new hardware, back up these state directories Steps: 1. Install NixOS on the new server -2. Create `hosts/hardware/FredOS-Mediaserver.nix` from the new `/etc/nixos/hardware-configuration.nix` (new disk UUIDs, bootloader config) +2. Update `hosts/hardware/FredOS-Mediaserver.nix` from the new `/etc/nixos/hardware-configuration.nix` (new disk UUIDs, bootloader config) 3. Set up the mergerfs pool and mount at `/mnt/storage` 4. Restore `/var/secrets/` (see Mediaserver secrets section above) -5. Run `sudo nixos-rebuild switch --flake github:ediblerope/nixos-config#FredOS-Mediaserver` +5. Run `sudo nixos-rebuild switch --refresh --flake git+https://forg.gregersen.it/rope/nixos#FredOS-Mediaserver` 6. Stop all services, restore the `/var/lib/` directories listed above, then start services 7. Update Cloudflare DNS if the server's public IP changed -If starting fresh instead of migrating, the services will self-initialize with empty databases. You'll need to redo initial setup in each web UI (add media libraries in Jellyfin, set root folders in Sonarr/Radarr, configure qBittorrent download paths, etc.). The `arr-interconnect` service will auto-wire the connections between them. +If starting fresh instead of migrating, the services self-initialize with empty databases — redo initial setup in each web UI; `arr-interconnect` auto-wires the connections between them. ## Notes -- `hosts/hardware/` files are committed to the repo — they contain UUIDs and disk layout but no sensitive credentials -- Host-specific behaviour is gated with `lib.mkIf (config.networking.hostName == "...")` or `lib.elem config.networking.hostName [...]` -- GitHub API rate limit (60 req/hour unauthenticated) can occasionally be hit if running `update` many times in quick succession during active config changes — wait ~15 minutes and retry +- The mediaserver is also the home router: `services/router.nix` owns nftables NAT/firewall and `networking.firewall` is **disabled** on that host. WAN exposure is controlled solely by `ports.toml`; LAN traffic is trusted wholesale. +- `hosts/hardware/` files are committed — they contain UUIDs and disk layout but no sensitive credentials +- Host-specific behaviour is gated with `lib.mkIf (config.networking.hostName == "...")` or `lib.elem config.networking.hostName [...]`; host modules themselves are selected by `mkHost` in `flake.nix` +- `modules/crowdsec/` vendors the crowdsec module rewrite from nixpkgs PR #446307 — delete it (and the `disabledModules`/`imports` lines in `services/crowdsec.nix`) once that PR lands in the pinned channel