From 85e9031df865c9d46817b2fc3d5b484a9f8689b5 Mon Sep 17 00:00:00 2001 From: Anas Khalifa Date: Sun, 24 Aug 2025 00:49:41 +0300 Subject: [PATCH] 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; + } +}