diff --git a/dotfiles/.config/quickshell/config/SystemUpdates.qml b/dotfiles/.config/quickshell/config/SystemUpdates.qml new file mode 100644 index 0000000..46d88f9 --- /dev/null +++ b/dotfiles/.config/quickshell/config/SystemUpdates.qml @@ -0,0 +1,72 @@ +pragma Singleton +import QtQuick +import Quickshell.Io + +// Shared state for SystemUpdate.qml, the bar's package-update indicator. +// Bar.qml is instantiated once per screen (see shell.qml's Variants), so +// without this being a singleton, plugging in extra monitors would mean +// running `checkupdates` that many times in parallel on every poll — which +// isn't just wasteful, it's actively unreliable: concurrent invocations +// race over checkupdates' own sync directory and can intermittently report +// 0 pending updates. One shared Process here means exactly one `checkupdates` +// run at a time, and every bar's icon reflects the same result. +QtObject { + id: root + + // checkupdates syncs a throwaway copy of the pacman db, so it's not + // free to run often — matches the old waybar custom/updater module's + // ~21.7 minute poll interval (see .config/waybar/config). + readonly property int refreshIntervalMs: 1300 * 1000 + + property int updateCount: 0 + property bool checking: false + property bool updating: false + + function refresh() { + if (_checkProc.running) return + root.checking = true + _checkProc.running = true + } + + // scriptPath: absolute path to update-system.sh (resolved by the caller + // via Quickshell.shellPath, which isn't available as a plain import + // here without pulling in the whole Quickshell singleton for one call). + function runUpdate(scriptPath) { + if (_updateProc.running) return + root.updating = true + _updateProc.command = [scriptPath] + _updateProc.running = true + } + + // QtObject has no default property, so these are attached via property + // assignment rather than as plain child elements (same as Colors.qml's + // FileView). + readonly property Process _checkProc: Process { + command: ["sh", "-c", "checkupdates 2>/dev/null | wc -l"] + stdout: StdioCollector { + id: checkCollector + onStreamFinished: { + root.updateCount = parseInt(checkCollector.text.trim(), 10) || 0 + root.checking = false + } + } + } + + // Runs update-system.sh, which opens its own terminal and blocks until + // that terminal is closed — so this only exits once the user's actually + // done updating, which is exactly when a re-check matters. + readonly property Process _updateProc: Process { + onExited: { + root.updating = false + root.refresh() + } + } + + readonly property Timer _timer: Timer { + interval: root.refreshIntervalMs + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.refresh() + } +} diff --git a/dotfiles/.config/quickshell/modules/bar/PinnedTray.qml b/dotfiles/.config/quickshell/modules/bar/PinnedTray.qml index ff44f62..9ce69b3 100644 --- a/dotfiles/.config/quickshell/modules/bar/PinnedTray.qml +++ b/dotfiles/.config/quickshell/modules/bar/PinnedTray.qml @@ -60,9 +60,13 @@ RowLayout { IconImage { anchors.fill: parent + // modelData.icon is a QUrl when it comes from + // Qt.resolvedUrl() (Bitwarden/Sunshine's local assets), not + // a QString — coerce to string before using String methods + // on it, or this throws instead of ever matching. source: slot.running ? slot.trayItem.icon - : (slot.modelData.icon.includes("://") + : (String(slot.modelData.icon).includes("://") ? slot.modelData.icon : Quickshell.iconPath(slot.modelData.icon)) opacity: slot.running ? 1.0 : 0.5 diff --git a/dotfiles/.config/quickshell/modules/bar/SystemUpdate.qml b/dotfiles/.config/quickshell/modules/bar/SystemUpdate.qml new file mode 100644 index 0000000..41e99fb --- /dev/null +++ b/dotfiles/.config/quickshell/modules/bar/SystemUpdate.qml @@ -0,0 +1,226 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import "../../config" + +// Package update indicator — replaces the old waybar custom/updater module +// (see .config/waybar/config and its scripts/update-distro.sh). The actual +// polling/state lives in the SystemUpdates singleton (config/), shared +// across every monitor's bar; this is just the icon + panel UI. Clicking +// opens a panel showing the pending count with a button that runs the +// actual update in a terminal (scripts/update-system.sh, ported from the +// same waybar script, distro-detection and all). +Item { + id: root + implicitWidth: row.implicitWidth + 2 + implicitHeight: Appearance.barHeight + + // The bar's own PanelWindow — same barWindow-anchoring convention as + // every other popup-having module here (Volume.qml, PowerButton.qml, + // etc.), so the panel opens in a fixed spot rather than following this + // icon's exact position in its RowLayout. + required property var barWindow + + RowLayout { + id: row + anchors.centerIn: parent + spacing: 4 + + Text { + id: label + // Nerd Font glyph (package-variant) normally, matching the old + // waybar custom/updater module's icon — not a Material Symbols + // ligature name like the rest of this bar, so it needs the Nerd + // Font instead of iconFontFamilyRounded. While a check is running + // (the periodic poll or a right-click-triggered refresh) this + // swaps to the same spinner glyph/animation as + // BluetoothIndicator.qml's scan spinner. + font.family: SystemUpdates.checking ? Appearance.iconFontFamilyRounded : Appearance.fontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: SystemUpdates.checking ? 15 : 22 + color: SystemUpdates.updateCount > 0 ? Colors.primary : Colors.foreground + text: SystemUpdates.checking ? "progress_activity" : "󰏖" + + RotationAnimation on rotation { + running: SystemUpdates.checking + loops: Animation.Infinite + from: 0 + to: 360 + duration: 900 + // Otherwise the icon is left tilted at whatever angle the + // spin stopped on once checking finishes and it swaps back + // to the static glyph. + onRunningChanged: if (!running) label.rotation = 0 + } + } + + Text { + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: label.color + text: SystemUpdates.updateCount + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) { + SystemUpdates.refresh() + } else { + panel.visible = !panel.visible + grab.active = panel.visible + } + } + } + + PopupWindow { + id: panel + visible: false + color: "transparent" + // Same contentWidth/contentPadding split as the other panels + // (Volume.qml, PowerButton.qml, etc.) rather than a single magic + // implicitWidth. + readonly property int contentWidth: 200 + readonly property int contentPadding: 16 + implicitWidth: contentWidth + contentPadding * 2 + implicitHeight: mainColumn.implicitHeight + contentPadding * 2 + + anchor { + window: root.barWindow + // Unlike anchor.item, anchor.window doesn't default rect to the + // window's own bounds — set it explicitly so edges/gravity below + // resolve against the full bar. Bottom alone (no Left/Right) + // anchors to the bar's bottom-center, which lines up with this + // icon since it sits in the bar's centered RowLayout. + rect.x: 0 + rect.y: 0 + rect.width: root.barWindow.width + rect.height: root.barWindow.height + edges: Edges.Bottom + gravity: Edges.Bottom + margins.bottom: -4 + } + + HyprlandFocusGrab { + id: grab + windows: [panel] + onCleared: panel.visible = false + } + + Rectangle { + anchors.fill: parent + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 3 + + Column { + id: mainColumn + anchors.fill: parent + anchors.margins: panel.contentPadding + spacing: 10 + + RowLayout { + width: parent.width + spacing: 8 + + Text { + text: label.text + font.family: Appearance.fontFamily + font.pixelSize: 23 + color: Colors.foreground + } + + Text { + Layout.fillWidth: true + text: "System Update" + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + } + + Text { + width: parent.width + wrapMode: Text.WordWrap + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + color: Colors.foreground + text: { + if (SystemUpdates.updating) return "Update running in a terminal…" + if (SystemUpdates.checking) return "Checking for updates…" + if (SystemUpdates.updateCount === 0) return "System is up to date." + return SystemUpdates.updateCount + (SystemUpdates.updateCount === 1 ? " update available." : " updates available.") + } + } + + // Same selectable-row shape as this project's other panel + // actions (e.g. PowerButton.qml's menu entries): radius 0, + // fixed-width icon column, fillWidth label, offset hover + // halo rather than one drawn on the row's own edge. + Rectangle { + id: updateBtn + width: parent.width + radius: 0 + implicitHeight: btnContent.implicitHeight + 12 + enabled: !SystemUpdates.updating + opacity: SystemUpdates.updating ? 0.5 : 1 + color: btnMouse.containsMouse ? Colors.primary : "transparent" + + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: btnMouse.containsMouse ? 1 : 0 + } + + RowLayout { + id: btnContent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 6 + spacing: 8 + + Text { + Layout.preferredWidth: 20 + horizontalAlignment: Text.AlignLeft + text: "download" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: btnMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + Layout.fillWidth: true + text: "Update Now" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + color: btnMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + } + + MouseArea { + id: btnMouse + anchors.fill: parent + enabled: !SystemUpdates.updating + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + SystemUpdates.runUpdate(Quickshell.shellPath("scripts/update-system.sh")) + panel.visible = false + grab.active = false + } + } + } + } + } + } +} diff --git a/dotfiles/.config/quickshell/scripts/update-system.sh b/dotfiles/.config/quickshell/scripts/update-system.sh new file mode 100755 index 0000000..faccdff --- /dev/null +++ b/dotfiles/.config/quickshell/scripts/update-system.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Launched by SystemUpdate.qml (top bar, next to the clock) — opens a +# terminal running whatever update command fits this distro, then notifies +# once it's done. Ported from the old waybar custom/updater module +# (.config/waybar/scripts/update-distro.sh); kept multi-distro even though +# this machine is Arch, since these dotfiles aren't. +# +# The window it opens is floated + centered by a matching Hyprland rule +# (system-update-terminal, in .config/hypr/UserConfigs/WindowRules.conf), +# same treatment as Bitwarden's window, rather than tiling into whatever +# workspace you happened to be on. + +iDIR="$HOME/.config/swaync/images" + +if ! command -v foot &> /dev/null; then + notify-send -i "$iDIR/error.png" "Need foot:" "foot terminal not found. Please install foot terminal." + exit 1 +fi + +# Printed inside the terminal before the actual update command runs, so it +# reads as "updating" from the first frame rather than looking like an +# empty/stuck window while pacman/apt/etc. take a moment to start printing. +# +# foot runs this in a fresh `bash -c`, which doesn't inherit shell functions +# from this script's own environment (export -f only reaches bash processes +# forked from *this* one, not whatever foot execs) — so instead, the +# already-colored text is built once here with real escape bytes ($'...'), +# then %q-quoted into a self-contained `echo '...'` that reproduces those +# same bytes verbatim when the child bash parses it. +msg=" Updating System — please wait " +line=$(printf '=%.0s' $(seq 1 ${#msg})) +banner_text=$'\033[1;36m'"$line"$'\033[0m\n\033[1;36m'"$msg"$'\033[0m\n\033[1;36m'"$line"$'\033[0m\n\n' +BANNER="echo $(printf '%q' "$banner_text")" + +if command -v paru &> /dev/null; then + CMD="paru -Syu" + LABEL="Arch-based system" +elif command -v yay &> /dev/null; then + CMD="yay -Syu" + LABEL="Arch-based system" +elif command -v dnf &> /dev/null; then + CMD="sudo dnf update --refresh -y" + LABEL="Fedora system" +elif command -v apt &> /dev/null; then + CMD="sudo apt update && sudo apt upgrade -y" + LABEL="Debian/Ubuntu system" +elif command -v zypper &> /dev/null; then + CMD="sudo zypper dup -y" + LABEL="openSUSE system" +else + notify-send -i "$iDIR/error.png" -u critical "Unsupported system" "This script does not support your distribution." + exit 1 +fi + +# Keeps the terminal open (whether the update succeeded or failed — read +# always runs regardless, since it's ; not &&) until you press Enter, +# instead of foot closing the instant the update command exits and taking +# any output/errors with it. +WAIT_PROMPT="echo; read -p 'Update finished — press Enter to close...'" + +foot -T update bash -c "$BANNER; $CMD; $WAIT_PROMPT" +notify-send -i "$iDIR/profile-pic.jpg" -u low "$LABEL" 'has been updated.'