diff --git a/Modules/ArchUpdaterPanel/ArchUpdaterPanel.qml b/Modules/ArchUpdaterPanel/ArchUpdaterPanel.qml new file mode 100644 index 0000000..70a23a3 --- /dev/null +++ b/Modules/ArchUpdaterPanel/ArchUpdaterPanel.qml @@ -0,0 +1,227 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import qs.Commons +import qs.Services +import qs.Widgets + +NPanel { + id: root + panelWidth: 380 * scaling + panelHeight: 500 * scaling + panelAnchorRight: true + + // Auto-refresh when service updates + Connections { + target: ArchUpdaterService + function onUpdatePackagesChanged() { + // Force UI update when packages change + if (root.visible) { + // Small delay to ensure data is fully updated + Qt.callLater(() => { + // Force a UI update by triggering a property change + ArchUpdaterService.updatePackages = ArchUpdaterService.updatePackages; + }, 100); + } + } + } + + panelContent: Rectangle { + color: Color.mSurface + radius: Style.radiusL * scaling + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginL * scaling + spacing: Style.marginM * scaling + + // Header + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM * scaling + + NIcon { + text: "system_update" + font.pointSize: Style.fontSizeXXL * scaling + color: Color.mPrimary + } + + Text { + text: "System Updates" + font.pointSize: Style.fontSizeL * scaling + font.family: Settings.data.ui.fontDefault + font.weight: Style.fontWeightBold + color: Color.mOnSurface + Layout.fillWidth: true + } + + NIconButton { + icon: "close" + tooltipText: "Close" + sizeMultiplier: 0.8 + onClicked: root.close() + } + } + + NDivider { Layout.fillWidth: true } + + // Update summary + Text { + text: ArchUpdaterService.updatePackages.length + " package" + (ArchUpdaterService.updatePackages.length !== 1 ? "s" : "") + " can be updated" + font.pointSize: Style.fontSizeL * scaling + font.family: Settings.data.ui.fontDefault + font.weight: Style.fontWeightMedium + color: Color.mOnSurface + Layout.fillWidth: true + } + + // Package selection info + Text { + text: ArchUpdaterService.selectedPackagesCount + " of " + ArchUpdaterService.updatePackages.length + " packages selected" + font.pointSize: Style.fontSizeS * scaling + font.family: Settings.data.ui.fontDefault + color: Color.mOnSurfaceVariant + Layout.fillWidth: true + } + + // Package list + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Color.mSurfaceVariant + radius: Style.radiusM * scaling + + ListView { + id: packageListView + anchors.fill: parent + anchors.margins: Style.marginS * scaling + clip: true + model: ArchUpdaterService.updatePackages + spacing: Style.marginXS * scaling + + delegate: Rectangle { + width: packageListView.width + height: 50 * scaling + color: Color.transparent + radius: Style.radiusS * scaling + + RowLayout { + anchors.fill: parent + anchors.margins: Style.marginS * scaling + spacing: Style.marginS * scaling + + // Checkbox for selection + NIconButton { + id: checkbox + icon: "check_box_outline_blank" + onClicked: { + const isSelected = ArchUpdaterService.isPackageSelected(modelData.name); + if (isSelected) { + ArchUpdaterService.togglePackageSelection(modelData.name); + icon = "check_box_outline_blank"; + colorFg = Color.mOnSurfaceVariant; + } else { + ArchUpdaterService.togglePackageSelection(modelData.name); + icon = "check_box"; + colorFg = Color.mPrimary; + } + } + colorBg: Color.transparent + colorFg: Color.mOnSurfaceVariant + Layout.preferredWidth: 30 * scaling + Layout.preferredHeight: 30 * scaling + + Component.onCompleted: { + // Set initial state + if (ArchUpdaterService.isPackageSelected(modelData.name)) { + icon = "check_box"; + colorFg = Color.mPrimary; + } + } + } + + // Package info + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXXS * scaling + + Text { + text: modelData.name + font.pointSize: Style.fontSizeM * scaling + font.family: Settings.data.ui.fontDefault + font.weight: Style.fontWeightMedium + color: Color.mOnSurface + Layout.fillWidth: true + } + + Text { + text: modelData.oldVersion + " → " + modelData.newVersion + font.pointSize: Style.fontSizeS * scaling + font.family: Settings.data.ui.fontDefault + color: Color.mOnSurfaceVariant + Layout.fillWidth: true + } + } + } + } + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + } + } + + + + // Action buttons + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS * scaling + + NIconButton { + icon: "refresh" + tooltipText: "Check for updates" + onClicked: { + ArchUpdaterService.doPoll(); + } + colorBg: Color.mSurfaceVariant + colorFg: Color.mOnSurface + Layout.fillWidth: true + Layout.preferredHeight: 35 * scaling + } + + NIconButton { + icon: ArchUpdaterService.updateInProgress ? "hourglass_empty" : "system_update" + tooltipText: ArchUpdaterService.updateInProgress ? "Update in progress..." : "Update all packages" + enabled: !ArchUpdaterService.updateInProgress + onClicked: { + ArchUpdaterService.runUpdate(); + } + colorBg: ArchUpdaterService.updateInProgress ? Color.mSurfaceVariant : Color.mPrimary + colorFg: ArchUpdaterService.updateInProgress ? Color.mOnSurfaceVariant : Color.mOnPrimary + Layout.fillWidth: true + Layout.preferredHeight: 35 * scaling + } + + NIconButton { + icon: ArchUpdaterService.updateInProgress ? "hourglass_empty" : "settings" + tooltipText: ArchUpdaterService.updateInProgress ? "Update in progress..." : "Update selected packages" + enabled: !ArchUpdaterService.updateInProgress && ArchUpdaterService.selectedPackagesCount > 0 + onClicked: { + if (ArchUpdaterService.selectedPackagesCount > 0) { + ArchUpdaterService.runSelectiveUpdate(); + } + } + colorBg: ArchUpdaterService.updateInProgress ? Color.mSurfaceVariant : + (ArchUpdaterService.selectedPackagesCount > 0 ? Color.mSecondary : Color.mSurfaceVariant) + colorFg: ArchUpdaterService.updateInProgress ? Color.mOnSurfaceVariant : + (ArchUpdaterService.selectedPackagesCount > 0 ? Color.mOnSecondary : Color.mOnSurfaceVariant) + Layout.fillWidth: true + Layout.preferredHeight: 35 * scaling + } + } + } + } +} diff --git a/Modules/Bar/Widgets/ArchUpdater.qml b/Modules/Bar/Widgets/ArchUpdater.qml index 4fd397d..4881966 100644 --- a/Modules/Bar/Widgets/ArchUpdater.qml +++ b/Modules/Bar/Widgets/ArchUpdater.qml @@ -1,48 +1,71 @@ import qs.Commons import qs.Services import qs.Widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts NIconButton { id: root sizeMultiplier: 0.8 + readonly property real scaling: ScalingService.scale(screen) + 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")) + // Enhanced icon states with better visual feedback + icon: { + if (ArchUpdaterService.busy) return "sync" + if (ArchUpdaterService.updatePackages.length > 0) { + // Show different icons based on update count + const count = ArchUpdaterService.updatePackages.length + if (count > 50) return "system_update_alt" // Many updates + if (count > 10) return "system_update" // Moderate updates + return "system_update" // Few updates + } + return "task_alt" + } + // Enhanced tooltip with more information 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"; + return "System is up to date ✓"; 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); + var limit = Math.min(list.length, 8); // Reduced to 8 for better readability 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"; + if (list.length > 8) + s += "\n… and " + (list.length - 8) + " more"; - return header + "\n" + s; + return header + "\n\n" + s + "\n\nClick to update system"; } + // Enhanced click behavior with confirmation onClicked: { - if (!ArchUpdaterService.ready || ArchUpdaterService.busy) + if (ArchUpdaterService.busy) return; - ArchUpdaterService.runUpdate(); + + if (ArchUpdaterService.updatePackages.length > 0) { + // Show confirmation dialog for updates + PanelService.updatePanel.toggle(screen); + } else { + // Just refresh if no updates available + ArchUpdaterService.doPoll(); + } } + + } diff --git a/Services/ArchUpdaterService.qml b/Services/ArchUpdaterService.qml index ea52728..ed392ef 100644 --- a/Services/ArchUpdaterService.qml +++ b/Services/ArchUpdaterService.qml @@ -5,179 +5,149 @@ 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 + + // Core properties + readonly property bool busy: checkupdatesProcess.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(); - } - + property var selectedPackages: [] + property int selectedPackagesCount: 0 + property bool updateInProgress: false + + // Process for checking updates 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(); + id: checkupdatesProcess + command: ["checkupdates"] + onExited: function(exitCode) { 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 = []; + 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: { + parseCheckupdatesOutput(text); + } } } - - - 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]+)$/); + + // Parse checkupdates output + function parseCheckupdatesOutput(output) { + const lines = output.trim().split('\n').filter(line => line.trim()); + const packages = []; + + for (const line of lines) { + const m = line.match(/^(\S+)\s+([^\s]+)\s+->\s+([^\s]+)$/); if (m) { - pkgs.push({ + packages.push({ name: m[1], oldVersion: m[2], - newVersion: m[3] + newVersion: m[3], + description: `${m[1]} ${m[2]} -> ${m[3]}` }); } } - return { - raw, - pkgs - }; + + updatePackages = packages; } - - function _summarizeAndNotify() { + + // Check for updates + function doPoll() { + if (busy) return; + checkupdatesProcess.running = true; + } + + // Update all packages + function runUpdate() { if (updates === 0) { - lastNotifiedUpdates = 0; + doPoll(); 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; + + updateInProgress = true; + Quickshell.execDetached(["pkexec", "pacman", "-Syu", "--noconfirm"]); + + // Refresh after updates with multiple attempts + refreshAfterUpdate(); } - + + // Update selected packages + function runSelectiveUpdate() { + if (selectedPackages.length === 0) return; + + updateInProgress = true; + const command = ["pkexec", "pacman", "-S", "--noconfirm"].concat(selectedPackages); + Quickshell.execDetached(command); + + // Clear selection and refresh + selectedPackages = []; + selectedPackagesCount = 0; + refreshAfterUpdate(); + } + + // Package selection functions + function togglePackageSelection(packageName) { + const index = selectedPackages.indexOf(packageName); + if (index > -1) { + selectedPackages.splice(index, 1); + } else { + selectedPackages.push(packageName); + } + selectedPackagesCount = selectedPackages.length; + } + + function selectAllPackages() { + selectedPackages = updatePackages.map(pkg => pkg.name); + selectedPackagesCount = selectedPackages.length; + } + + function deselectAllPackages() { + selectedPackages = []; + selectedPackagesCount = 0; + } + + function isPackageSelected(packageName) { + return selectedPackages.indexOf(packageName) > -1; + } + + // Robust refresh after updates + function refreshAfterUpdate() { + // First refresh attempt after 3 seconds + Qt.callLater(() => { + doPoll(); + }, 3000); + + // Second refresh attempt after 8 seconds + Qt.callLater(() => { + doPoll(); + }, 8000); + + // Third refresh attempt after 15 seconds + Qt.callLater(() => { + doPoll(); + updateInProgress = false; + }, 15000); + + // Final refresh attempt after 30 seconds + Qt.callLater(() => { + doPoll(); + }, 30000); + } + + // Notification helper + function notify(title, body) { + Quickshell.execDetached(["notify-send", "-a", "UpdateService", "-i", "system-software-update", title, body]); + } + + // Auto-poll every 15 minutes Timer { - id: pollTimer - interval: updateService.pollInterval + interval: 15 * 60 * 1000 // 15 minutes 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")); - } - } + running: true + onTriggered: doPoll() } + + // Initial check + Component.onCompleted: doPoll() } diff --git a/Services/PanelService.qml b/Services/PanelService.qml index e2d82f7..435a45a 100644 --- a/Services/PanelService.qml +++ b/Services/PanelService.qml @@ -11,6 +11,9 @@ Singleton { // A ref. to the lockScreen, so it's accessible from other services property var lockScreen: null + // A ref. to the updatePanel, so it's accessible from other services + property var updatePanel: null + // Currently opened panel property var openedPanel: null diff --git a/shell.qml b/shell.qml index 8ce8361..6eafab7 100644 --- a/shell.qml +++ b/shell.qml @@ -27,6 +27,7 @@ import qs.Modules.PowerPanel import qs.Modules.SidePanel import qs.Modules.Toast import qs.Modules.WiFiPanel +import qs.Modules.ArchUpdaterPanel import qs.Services import qs.Widgets @@ -79,6 +80,10 @@ ShellRoot { id: bluetoothPanel } + ArchUpdaterPanel { + id: updatePanel + } + ToastManager {} IPCManager {} @@ -90,6 +95,9 @@ ShellRoot { // Save a ref. to our lockScreen so we can access it from services PanelService.lockScreen = lockScreen + // Save a ref. to our updatePanel so we can access it from services + PanelService.updatePanel = updatePanel + // Ensure our singleton is created as soon as possible so we start fetching weather asap LocationService.init() }