From 85e9031df865c9d46817b2fc3d5b484a9f8689b5 Mon Sep 17 00:00:00 2001 From: Anas Khalifa Date: Sun, 24 Aug 2025 00:49:41 +0300 Subject: [PATCH 1/6] Add ArchUpdater widget and service; update settings for Arch updates --- Commons/Settings.qml | 3 +- Commons/WidgetLoader.qml | 2 +- Modules/Bar/Widgets/ArchUpdater.qml | 49 ++++++ Modules/SettingsPanel/Tabs/BarTab.qml | 11 ++ Services/ArchUpdaterService.qml | 222 ++++++++++++++++++++++++++ 5 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 Modules/Bar/Widgets/ArchUpdater.qml create mode 100644 Services/ArchUpdaterService.qml diff --git a/Commons/Settings.qml b/Commons/Settings.qml index accd50b..3600c78 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -123,6 +123,7 @@ Singleton { property bool showActiveWindowIcon: true property bool alwaysShowBatteryPercentage: false property real backgroundOpacity: 1.0 + property bool showArchUpdater: true property list monitors: [] // Widget configuration for modular bar system @@ -130,7 +131,7 @@ Singleton { widgets: JsonObject { property list left: ["SystemMonitor", "ActiveWindow", "MediaMini"] property list center: ["Workspace"] - property list right: ["ScreenRecorderIndicator", "Tray", "NotificationHistory", "WiFi", "Bluetooth", "Battery", "Volume", "Brightness", "Clock", "SidePanelToggle"] + property list right: ["ScreenRecorderIndicator", "Tray", "ArchUpdater", "NotificationHistory", "WiFi", "Bluetooth", "Battery", "Volume", "Brightness", "Clock", "SidePanelToggle"] } } diff --git a/Commons/WidgetLoader.qml b/Commons/WidgetLoader.qml index 872f64d..0a2b01c 100644 --- a/Commons/WidgetLoader.qml +++ b/Commons/WidgetLoader.qml @@ -65,7 +65,7 @@ QtObject { // This is where you should add your Modules/Bar/Widgets/ // so it gets registered in the BarTab function discoverAvailableWidgets() { - const widgetFiles = ["ActiveWindow", "Battery", "Bluetooth", "Brightness", "Clock", "KeyboardLayout", "MediaMini", "NotificationHistory", "PowerProfile", "ScreenRecorderIndicator", "SidePanelToggle", "SystemMonitor", "Tray", "Volume", "WiFi", "Workspace"] + const widgetFiles = ["ActiveWindow", "ArchUpdater", "Battery", "Bluetooth", "Brightness", "Clock", "KeyboardLayout", "MediaMini", "NotificationHistory", "PowerProfile", "ScreenRecorderIndicator", "SidePanelToggle", "SystemMonitor", "Tray", "Volume", "WiFi", "Workspace"] const availableWidgets = [] diff --git a/Modules/Bar/Widgets/ArchUpdater.qml b/Modules/Bar/Widgets/ArchUpdater.qml new file mode 100644 index 0000000..66378ff --- /dev/null +++ b/Modules/Bar/Widgets/ArchUpdater.qml @@ -0,0 +1,49 @@ +import qs.Commons +import qs.Services +import qs.Widgets + +NIconButton { + id: root + visible: Settings.data.bar.showArchUpdater && ArchUpdaterService.isArchBased + sizeMultiplier: 0.8 + + colorBg: Color.mSurfaceVariant + colorFg: Color.mOnSurface + colorBorder: Color.transparent + colorBorderHover: Color.transparent + + icon: !ArchUpdaterService.ready ? "block" : (ArchUpdaterService.busy ? "sync" : (ArchUpdaterService.updatePackages.length > 0 ? "system_update" : "task_alt")) + + tooltipText: { + if (!ArchUpdaterService.isArchBased) + return "Arch users already ran 'sudo pacman -Syu' for breakfast."; + if (!ArchUpdaterService.checkupdatesAvailable) + return "Please install pacman-contrib to use this feature."; + if (ArchUpdaterService.busy) + return "Checking for updates…"; + + var count = ArchUpdaterService.updatePackages.length; + if (count === 0) + return "No updates available"; + + var header = count === 1 ? "One package can be upgraded:" : (count + " packages can be upgraded:"); + + var list = ArchUpdaterService.updatePackages || []; + var s = ""; + var limit = Math.min(list.length, 10); + for (var i = 0; i < limit; ++i) { + var p = list[i]; + s += (i ? "\n" : "") + (p.name + ": " + p.oldVersion + " → " + p.newVersion); + } + if (list.length > 10) + s += "\n… and " + (list.length - 10) + " more"; + + return header + "\n" + s; + } + + onClicked: { + if (!ArchUpdaterService.ready || ArchUpdaterService.busy) + return; + ArchUpdaterService.runUpdate(); + } +} diff --git a/Modules/SettingsPanel/Tabs/BarTab.qml b/Modules/SettingsPanel/Tabs/BarTab.qml index 906d958..214408e 100644 --- a/Modules/SettingsPanel/Tabs/BarTab.qml +++ b/Modules/SettingsPanel/Tabs/BarTab.qml @@ -127,6 +127,17 @@ ColumnLayout { Settings.data.bar.alwaysShowBatteryPercentage = checked } } + + NToggle { + visible: ArchUpdaterService.isArchBased + label: "Show Arch Updater" + description: "Show the Arch Linux updates widget." + checked: Settings.data.bar.showArchUpdater + onToggled: checked => { + Settings.data.bar.showArchUpdater = checked + } + } + NDivider { Layout.fillWidth: true diff --git a/Services/ArchUpdaterService.qml b/Services/ArchUpdaterService.qml new file mode 100644 index 0000000..dbe9459 --- /dev/null +++ b/Services/ArchUpdaterService.qml @@ -0,0 +1,222 @@ +pragma Singleton +import Quickshell +import QtQuick +import Quickshell.Io +import qs.Commons + +Singleton { + id: updateService + property bool isArchBased: false + property bool checkupdatesAvailable: false + readonly property bool ready: isArchBased && checkupdatesAvailable + readonly property bool busy: pkgProc.running + readonly property int updates: updatePackages.length + property var updatePackages: [] + property double lastSync: 0 + property bool lastWasFull: false + property int failureCount: 0 + readonly property int failureThreshold: 5 + readonly property int quickTimeoutMs: 12 * 1000 + readonly property int minuteMs: 60 * 1000 + readonly property int pollInterval: 1 * minuteMs + readonly property int syncInterval: 15 * minuteMs + property int lastNotifiedUpdates: 0 + + property var updateCommand: ["xdg-terminal-exec", "--title=System Updates", "-e", "sh", "-c", "sudo pacman -Syu; printf '\n\nUpdate finished. Press Enter to exit...'; read _"] + + PersistentProperties { + id: cache + reloadableId: "ArchCheckerCache" + + property string cachedUpdatePackagesJson: "[]" + property double cachedLastSync: 0 + } + + Component.onCompleted: { + const persisted = JSON.parse(cache.cachedUpdatePackagesJson || "[]"); + if (persisted.length) + updatePackages = _clonePackageList(persisted); + if (cache.cachedLastSync > 0) + lastSync = cache.cachedLastSync; + } + + function runUpdate() { + if (updates > 0) { + Quickshell.execDetached(updateCommand); + } else { + doPoll(true); + } + } + + function notify(title, body) { + const app = "UpdateService"; + const icon = "system-software-update"; + Quickshell.execDetached(["notify-send", "-a", app, "-i", icon, String(title || ""), String(body || "")]); + } + + function startUpdateProcess(cmd) { + pkgProc.command = cmd; + pkgProc.running = true; + killTimer.interval = lastWasFull ? 60 * 1000 : minuteMs; + killTimer.restart(); + } + + function doPoll(forceFull = false) { + if (busy) + return; + const full = forceFull || (Date.now() - lastSync > syncInterval); + lastWasFull = full; + + pkgProc.command = full ? ["checkupdates", "--nocolor"] : ["checkupdates", "--nosync", "--nocolor"]; + pkgProc.running = true; + killTimer.restart(); + } + + Process { + id: pacmanCheck + running: true + command: ["sh", "-c", "p=$(command -v pacman >/dev/null && echo yes || echo no); c=$(command -v checkupdates >/dev/null && echo yes || echo no); echo \"$p $c\""] + stdout: StdioCollector { + onStreamFinished: { + const parts = (text || "").trim().split(/\s+/); + updateService.isArchBased = (parts[0] === "yes"); + updateService.checkupdatesAvailable = (parts[1] === "yes"); + if (updateService.ready) { + updateService.doPoll(); + pollTimer.start(); + } + } + } + } + + Process { + id: pkgProc + onExited: function (exitCode, exitStatus) { + killTimer.stop(); + if (exitCode !== 0 && exitCode !== 2) { + updateService.failureCount++; + Logger.warn("UpdateService", `checkupdates failed (code: ${exitCode}, status: ${exitStatus})`); + if (updateService.failureCount >= updateService.failureThreshold) { + updateService.notify(qsTr("Update check failed"), qsTr(`Exit code: ${exitCode} (failed ${updateService.failureCount} times)`)); + updateService.failureCount = 0; + } + updateService.updatePackages = []; + } + } + + stdout: StdioCollector { + id: out + onStreamFinished: { + if (!pkgProc.running || updateService.busy) + return; + killTimer.stop(); + + const parsed = updateService._parseUpdateOutput(out.text); + updateService.updatePackages = parsed.pkgs; + + if (updateService.lastWasFull) { + updateService.lastSync = Date.now(); + } + + cache.cachedUpdatePackagesJson = JSON.stringify(updateService._clonePackageList(updateService.updatePackages)); + cache.cachedLastSync = updateService.lastSync; + updateService._summarizeAndNotify(parsed.pkgs, updateService.updates); + } + } + stderr: StdioCollector { + id: err + onStreamFinished: { + const stderrText = (err.text || "").trim(); + if (stderrText) { + Logger.warn("UpdateService", "stderr:", stderrText); + updateService.failureCount++; + updateService._notifyOnFailureThreshold(stderrText); + } else { + updateService.failureCount = 0; + } + } + } + } + + function _notifyOnFailureThreshold(body) { + if (failureCount >= failureThreshold) { + notify(qsTr("Update check failed"), String(body || "")); + failureCount = 0; + return true; + } + return false; + } + + function _clonePackageList(list) { + const src = Array.isArray(list) ? list : []; + return src.map(p => ({ + name: String(p.name || ""), + oldVersion: String(p.oldVersion || ""), + newVersion: String(p.newVersion || "") + })); + } + + function _parseUpdateOutput(rawText) { + const raw = (rawText || "").trim(); + const lines = raw ? raw.split(/\r?\n/) : []; + const pkgs = []; + for (let i = 0; i < lines.length; ++i) { + const m = lines[i].match(/^(\S+)\s+([^\s]+)\s+->\s+([^\s]+)$/); + if (m) { + pkgs.push({ + name: m[1], + oldVersion: m[2], + newVersion: m[3] + }); + } + } + return { + raw, + pkgs + }; + } + + function _summarizeAndNotify() { + if (updates === 0) { + lastNotifiedUpdates = 0; + return; + } + if (updates <= lastNotifiedUpdates) + return; + const added = updates - lastNotifiedUpdates; + const msg = added === 1 ? qsTr("One new package can be upgraded (") + updates + qsTr(")") : `${added} ${qsTr("new packages can be upgraded (")} ${updates} ${qsTr(")")}`; + notify(qsTr("Updates Available"), msg); + lastNotifiedUpdates = updates; + } + + Timer { + id: pollTimer + interval: updateService.pollInterval + repeat: true + onTriggered: { + if (!updateService.ready) + return; + updateService.doPoll(); + } + } + + Timer { + id: killTimer + interval: updateService.lastWasFull ? updateService.minuteMs : updateService.quickTimeoutMs + repeat: false + onTriggered: { + if (pkgProc.running) { + Logger.error("UpdateService", "Update check killed (timeout)"); + updateService.notify(qsTr("Update check killed"), qsTr("Process took too long")); + } + } + } + + onUpdatePackagesChanged: { + cache.cachedUpdatePackagesJson = JSON.stringify(_clonePackageList(updatePackages)); + } + + onLastSyncChanged: { + cache.cachedLastSync = lastSync; + } +} From 55fd6361c5605d0459bb75e47070cc04aee3bc4e Mon Sep 17 00:00:00 2001 From: Anas Khalifa Date: Sun, 24 Aug 2025 00:58:41 +0300 Subject: [PATCH 2/6] Remove unnecessary tooltip for non-Arch users in ArchUpdater widget since we now gate the toggle itself to be visiable only in archbased --- Modules/Bar/Widgets/ArchUpdater.qml | 2 -- 1 file changed, 2 deletions(-) diff --git a/Modules/Bar/Widgets/ArchUpdater.qml b/Modules/Bar/Widgets/ArchUpdater.qml index 66378ff..5a2e359 100644 --- a/Modules/Bar/Widgets/ArchUpdater.qml +++ b/Modules/Bar/Widgets/ArchUpdater.qml @@ -15,8 +15,6 @@ NIconButton { icon: !ArchUpdaterService.ready ? "block" : (ArchUpdaterService.busy ? "sync" : (ArchUpdaterService.updatePackages.length > 0 ? "system_update" : "task_alt")) tooltipText: { - if (!ArchUpdaterService.isArchBased) - return "Arch users already ran 'sudo pacman -Syu' for breakfast."; if (!ArchUpdaterService.checkupdatesAvailable) return "Please install pacman-contrib to use this feature."; if (ArchUpdaterService.busy) From 1d625ac09887f60f44c7db8cb2167731267216e4 Mon Sep 17 00:00:00 2001 From: Anas Khalifa Date: Sun, 24 Aug 2025 01:03:04 +0300 Subject: [PATCH 3/6] opsies haha --- Modules/SettingsPanel/Tabs/BarTab.qml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/Modules/SettingsPanel/Tabs/BarTab.qml b/Modules/SettingsPanel/Tabs/BarTab.qml index 214408e..005a93f 100644 --- a/Modules/SettingsPanel/Tabs/BarTab.qml +++ b/Modules/SettingsPanel/Tabs/BarTab.qml @@ -128,17 +128,6 @@ ColumnLayout { } } - NToggle { - visible: ArchUpdaterService.isArchBased - label: "Show Arch Updater" - description: "Show the Arch Linux updates widget." - checked: Settings.data.bar.showArchUpdater - onToggled: checked => { - Settings.data.bar.showArchUpdater = checked - } - } - - NDivider { Layout.fillWidth: true Layout.topMargin: Style.marginL * scaling From f85bdfead97d165952a0e588c7c6ed51ebfff1c8 Mon Sep 17 00:00:00 2001 From: Anas Khalifa Date: Sun, 24 Aug 2025 01:24:44 +0300 Subject: [PATCH 4/6] restore the fun easter egg when not archbased since it can be enabled when it's not --- Commons/Settings.qml | 1 - Modules/Bar/Widgets/ArchUpdater.qml | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Commons/Settings.qml b/Commons/Settings.qml index 3600c78..074ee60 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -123,7 +123,6 @@ Singleton { property bool showActiveWindowIcon: true property bool alwaysShowBatteryPercentage: false property real backgroundOpacity: 1.0 - property bool showArchUpdater: true property list monitors: [] // Widget configuration for modular bar system diff --git a/Modules/Bar/Widgets/ArchUpdater.qml b/Modules/Bar/Widgets/ArchUpdater.qml index 5a2e359..4fd397d 100644 --- a/Modules/Bar/Widgets/ArchUpdater.qml +++ b/Modules/Bar/Widgets/ArchUpdater.qml @@ -4,7 +4,6 @@ import qs.Widgets NIconButton { id: root - visible: Settings.data.bar.showArchUpdater && ArchUpdaterService.isArchBased sizeMultiplier: 0.8 colorBg: Color.mSurfaceVariant @@ -15,6 +14,8 @@ NIconButton { icon: !ArchUpdaterService.ready ? "block" : (ArchUpdaterService.busy ? "sync" : (ArchUpdaterService.updatePackages.length > 0 ? "system_update" : "task_alt")) tooltipText: { + if (!ArchUpdaterService.isArchBased) + return "Arch users already ran 'sudo pacman -Syu' for breakfast."; if (!ArchUpdaterService.checkupdatesAvailable) return "Please install pacman-contrib to use this feature."; if (ArchUpdaterService.busy) From 348b6edbc9655640b2d90604967bbbd71bf4efbd Mon Sep 17 00:00:00 2001 From: Anas Khalifa Date: Sun, 24 Aug 2025 01:28:39 +0300 Subject: [PATCH 5/6] remove spaces to revert original state --- Modules/SettingsPanel/Tabs/BarTab.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/SettingsPanel/Tabs/BarTab.qml b/Modules/SettingsPanel/Tabs/BarTab.qml index 005a93f..906d958 100644 --- a/Modules/SettingsPanel/Tabs/BarTab.qml +++ b/Modules/SettingsPanel/Tabs/BarTab.qml @@ -127,7 +127,7 @@ ColumnLayout { Settings.data.bar.alwaysShowBatteryPercentage = checked } } - + NDivider { Layout.fillWidth: true Layout.topMargin: Style.marginL * scaling From c14e72ebbdf1096d1570a495bc06bb1355f20ed6 Mon Sep 17 00:00:00 2001 From: Anas Khalifa Date: Sun, 24 Aug 2025 16:29:19 +0300 Subject: [PATCH 6/6] more cleanup with forgotten things --- Services/ArchUpdaterService.qml | 73 ++++++++------------------------- 1 file changed, 17 insertions(+), 56 deletions(-) diff --git a/Services/ArchUpdaterService.qml b/Services/ArchUpdaterService.qml index dbe9459..ea52728 100644 --- a/Services/ArchUpdaterService.qml +++ b/Services/ArchUpdaterService.qml @@ -2,7 +2,6 @@ pragma Singleton import Quickshell import QtQuick import Quickshell.Io -import qs.Commons Singleton { id: updateService @@ -54,13 +53,6 @@ Singleton { Quickshell.execDetached(["notify-send", "-a", app, "-i", icon, String(title || ""), String(body || "")]); } - function startUpdateProcess(cmd) { - pkgProc.command = cmd; - pkgProc.running = true; - killTimer.interval = lastWasFull ? 60 * 1000 : minuteMs; - killTimer.restart(); - } - function doPoll(forceFull = false) { if (busy) return; @@ -91,61 +83,38 @@ Singleton { Process { id: pkgProc - onExited: function (exitCode, exitStatus) { + onExited: function () { + var exitCode = arguments[0]; killTimer.stop(); if (exitCode !== 0 && exitCode !== 2) { updateService.failureCount++; - Logger.warn("UpdateService", `checkupdates failed (code: ${exitCode}, status: ${exitStatus})`); + console.warn("[UpdateService] checkupdates failed (code:", exitCode, ")"); if (updateService.failureCount >= updateService.failureThreshold) { updateService.notify(qsTr("Update check failed"), qsTr(`Exit code: ${exitCode} (failed ${updateService.failureCount} times)`)); updateService.failureCount = 0; } updateService.updatePackages = []; + return; } + + updateService.failureCount = 0; + const parsed = updateService._parseUpdateOutput(out.text); + updateService.updatePackages = parsed.pkgs; + + if (updateService.lastWasFull) { + updateService.lastSync = Date.now(); + } + + cache.cachedUpdatePackagesJson = JSON.stringify(updateService._clonePackageList(updateService.updatePackages)); + cache.cachedLastSync = updateService.lastSync; + updateService._summarizeAndNotify(); } stdout: StdioCollector { id: out - onStreamFinished: { - if (!pkgProc.running || updateService.busy) - return; - killTimer.stop(); - - const parsed = updateService._parseUpdateOutput(out.text); - updateService.updatePackages = parsed.pkgs; - - if (updateService.lastWasFull) { - updateService.lastSync = Date.now(); - } - - cache.cachedUpdatePackagesJson = JSON.stringify(updateService._clonePackageList(updateService.updatePackages)); - cache.cachedLastSync = updateService.lastSync; - updateService._summarizeAndNotify(parsed.pkgs, updateService.updates); - } - } - stderr: StdioCollector { - id: err - onStreamFinished: { - const stderrText = (err.text || "").trim(); - if (stderrText) { - Logger.warn("UpdateService", "stderr:", stderrText); - updateService.failureCount++; - updateService._notifyOnFailureThreshold(stderrText); - } else { - updateService.failureCount = 0; - } - } } } - function _notifyOnFailureThreshold(body) { - if (failureCount >= failureThreshold) { - notify(qsTr("Update check failed"), String(body || "")); - failureCount = 0; - return true; - } - return false; - } function _clonePackageList(list) { const src = Array.isArray(list) ? list : []; @@ -206,17 +175,9 @@ Singleton { repeat: false onTriggered: { if (pkgProc.running) { - Logger.error("UpdateService", "Update check killed (timeout)"); + console.error("[UpdateService] Update check killed (timeout)"); updateService.notify(qsTr("Update check killed"), qsTr("Process took too long")); } } } - - onUpdatePackagesChanged: { - cache.cachedUpdatePackagesJson = JSON.stringify(_clonePackageList(updatePackages)); - } - - onLastSyncChanged: { - cache.cachedLastSync = lastSync; - } }