diff options
| -rw-r--r-- | desktop/modules/updates/UpdatesModule.qml | 97 | ||||
| -rw-r--r-- | desktop/modules/updates/UpdatesPage.qml | 131 | ||||
| -rw-r--r-- | desktop/shell.qml | 2 |
3 files changed, 230 insertions, 0 deletions
diff --git a/desktop/modules/updates/UpdatesModule.qml b/desktop/modules/updates/UpdatesModule.qml new file mode 100644 index 0000000..f7b1f39 --- /dev/null +++ b/desktop/modules/updates/UpdatesModule.qml @@ -0,0 +1,97 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Io +import QtQuick +import "../.." + +// Pending updates, sorted by updatesctl into the next action. Nothing runs +// while the drawer is closed: the tile's lifetime is the check's, as with +// KDE Connect. A result older than 30 minutes is followed by a refresh, +// which runs nvchecker. +Module { + id: mod + + name: "updates" + label: "Updates" + + // linux-slackware, present in Inconsolata Nerd Font. + icon: "\uf318" + + property var doc: null + property bool checking: false + + readonly property int toInstall: doc + ? doc.slackware.upgrade.length + doc.slackware.new.length + + doc.slackware.removed.length + doc.upgrade.length + : 0 + readonly property int toBuild: doc ? doc.build.length : 0 + + active: toInstall > 0 + + property bool polling: false + onPollingChanged: if (polling) run(false) + + function run(refresh) { + if (proc.running && mod.checking) return; // never cut nvchecker short + proc.command = ["/usr/bin/python3", + `${Quickshell.shellDir}/modules/updates/updatesctl`].concat(refresh ? ["--refresh"] : []); + proc.running = false; + // After the stop: a killed run can still finish its stream, and + // its handler clears `checking`. + mod.checking = refresh; + proc.running = true; + } + + property Process proc: Process { + stdout: StdioCollector { + onStreamFinished: { + const refreshed = mod.checking; + mod.checking = false; + try { mod.doc = JSON.parse(this.text); } catch (e) { return; } + // Only after a cached read: a refresh whose nvchecker failed + // leaves `checked` old and would otherwise loop. + if (!refreshed && mod.polling && Date.now() / 1000 - mod.doc.checked > 1800) + mod.run(true); + } + } + } + + tileContent: Component { + Text { + Component.onCompleted: mod.polling = true + Component.onDestruction: mod.polling = false + + width: parent ? parent.width : implicitWidth + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: mod.active ? Theme.text : Theme.subtext + text: { + if (!mod.doc) return "checking…"; + const parts = []; + if (mod.toInstall) parts.push(mod.toInstall + " to install"); + if (mod.toBuild) parts.push(mod.toBuild + " to build"); + return parts.join(" · ") || "up to date"; + } + } + } + + page: Component { + Page { + title: "Updates" + UpdatesPage { + width: parent.width + up: mod + } + } + } +} diff --git a/desktop/modules/updates/UpdatesPage.qml b/desktop/modules/updates/UpdatesPage.qml new file mode 100644 index 0000000..967b0e0 --- /dev/null +++ b/desktop/modules/updates/UpdatesPage.qml @@ -0,0 +1,131 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick +import "../.." + +// One section per action, in the order they are done, each hidden when it +// has nothing and no error. Read-only: installing and building stay in the +// terminal. +Column { + id: page + + required property var up + readonly property var d: up.doc + + spacing: 16 + + function tracked(list) { + return list.map(r => ({ name: r.name, repo: r.repo, ver: r.from + " → " + r.to })); + } + + readonly property var sections: !d ? [] : [ + { title: "Slackware upgrades", err: d.errors.mirror || "", + rows: d.slackware.upgrade.map(r => ({ name: r.name, repo: "", ver: r.from + " → " + r.to })) }, + { title: "New in Slackware", err: "", + rows: d.slackware.new.map(r => ({ name: r.name, repo: "", ver: r.version })) }, + { title: "Removed from Slackware", err: "", + rows: d.slackware.removed.map(r => ({ name: r.name, repo: "", ver: "removed " + r.date })) }, + { title: "Upgrade here", err: d.errors.repo || "", rows: tracked(d.upgrade) }, + { title: "Awaiting build", err: "", rows: tracked(d.build) }, + { title: "Upstream newer", err: d.errors.nvchecker || "", rows: tracked(d.upstream) }, + { title: "App updates", err: "", rows: tracked(d.apps) }, + ] + + function ago(t) { + const m = Math.round((Date.now() / 1000 - t) / 60); + return m < 1 ? "just now" : m < 60 ? m + " min ago" : Math.round(m / 60) + " h ago"; + } + + Text { + visible: page.d !== null && page.up.toInstall === 0 && page.up.toBuild === 0 + && page.d.upstream.length === 0 && page.d.apps.length === 0 + text: "Up to date" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + } + + Repeater { + model: page.sections + + Column { + required property var modelData + width: page.width + spacing: 4 + visible: modelData.rows.length > 0 || modelData.err !== "" + + Text { + text: modelData.title + (modelData.rows.length ? " " + modelData.rows.length : "") + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } + } + + Text { + visible: modelData.err !== "" + text: modelData.err + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3; italic: true } + } + + Repeater { + model: modelData.rows + + Item { + required property var modelData + width: page.width + height: 22 + + Text { + anchors { left: parent.left; right: repo.left; rightMargin: 8; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + textFormat: Text.PlainText + text: modelData.name + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + Text { + id: repo + anchors { right: ver.left; rightMargin: 12; verticalCenter: parent.verticalCenter } + text: modelData.repo + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + Text { + id: ver + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + textFormat: Text.PlainText + text: modelData.ver + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + } + } + } + } + + Row { + spacing: 10 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: page.up.checking ? "checking…" + : page.d && page.d.checked ? "checked " + page.ago(page.d.checked) : "" + color: Theme.subtext + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + } + + Button { + text: "Refresh" + enabled: !page.up.checking + onClicked: page.up.run(true) + } + } +} diff --git a/desktop/shell.qml b/desktop/shell.qml index 537ce71..55e7377 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -27,6 +27,7 @@ import "modules/network" import "modules/perf" import "modules/sound" import "modules/status" +import "modules/updates" import "modules/vm" import "island" @@ -62,6 +63,7 @@ ShellRoot { StatusModule {}, PerfModule {}, ClipboardModule {}, + UpdatesModule {}, VmModule {}, AiModule {}, AppearanceModule {}, |
