diff --git a/Commons/Settings.qml b/Commons/Settings.qml index 771cbf9..2e63d53 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -129,7 +129,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..4fd397d --- /dev/null +++ b/Modules/Bar/Widgets/ArchUpdater.qml @@ -0,0 +1,48 @@ +import qs.Commons +import qs.Services +import qs.Widgets + +NIconButton { + id: root + 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/Services/ArchUpdaterService.qml b/Services/ArchUpdaterService.qml new file mode 100644 index 0000000..ea52728 --- /dev/null +++ b/Services/ArchUpdaterService.qml @@ -0,0 +1,183 @@ +pragma Singleton +import Quickshell +import QtQuick +import Quickshell.Io + +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 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 () { + var exitCode = arguments[0]; + killTimer.stop(); + if (exitCode !== 0 && exitCode !== 2) { + updateService.failureCount++; + 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 + } + } + + + 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) { + console.error("[UpdateService] Update check killed (timeout)"); + updateService.notify(qsTr("Update check killed"), qsTr("Process took too long")); + } + } + } +}