Files
DankMaterialShell/quickshell/Services/SystemUpdateService.qml
T
bbedward f482642880 sysupdate: check only on demand, run at low priority, fix the settings page
scrollbar: interface style toggle and no scrollbars in the dash
dash: never allow scrollbars
cc: widget sheet cards, header close button and content-sized dialogs
2026-09-29 16:30:04 -04:00

393 lines
14 KiB
QML

pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
import qs.Services
Singleton {
id: root
property int refCount: 0
// `Ref { modules: ["releases"] }` also holds the feed document; a plain Ref only keeps the daemon polling.
property int releasesRefCount: 0
readonly property bool pollWanted: refCount > 0 || SettingsData.updaterNotify
function addRef(modules) {
refCount++;
if (modules.includes("releases"))
releasesRefCount++;
}
function removeRef(modules) {
refCount = Math.max(0, refCount - 1);
if (modules.includes("releases"))
releasesRefCount = Math.max(0, releasesRefCount - 1);
}
property bool sysupdateAvailable: false
property var availableUpdates: []
property var _rawUpdates: []
property bool isChecking: false
property bool isUpgrading: false
property bool hasError: false
property string errorMessage: ""
property string errorHint: ""
property string errorCode: ""
property var backends: []
property string distribution: ""
property string distributionPretty: ""
property string pkgManager: ""
property bool distributionSupported: false
property var recentLog: []
property int intervalSeconds: 86400
property int lastCheckUnix: 0
property int nextCheckUnix: 0
property string shellInstallMethod: "unknown"
property string shellPackageName: ""
property string shellChannel: "unknown"
property string shellRunning: ""
property string shellInstalled: ""
property int shellGitBuild: 0
property bool restartPending: false
property bool rebootRecommended: false
property var rebootPackages: []
property var _rawShellUpdate: null
// From the filtered list, so an AUR-off/ignored dms package doesn't advertise.
readonly property var shellUpdate: _rawShellUpdate ? (availableUpdates.find(p => p.name === _rawShellUpdate.name) ?? null) : null
// -1 while unknown (stable channel or no feed yet)
property int commitsBehind: -1
property var releases: null
readonly property int updateCount: availableUpdates.length
readonly property var systemUpdates: availableUpdates.filter(p => !shellUpdate || p.name !== shellUpdate.name)
// The feed never flips this: DMS only updates through the package manager.
readonly property bool shellUpdateAvailable: shellUpdate !== null
readonly property string shellUpdateVersion: shellUpdate?.toVersion || ""
// Notes follow the version the repo offers (or the running one), never the feed's newest tag.
readonly property string notesVersion: (shellUpdateVersion || shellRunning).replace(/^v/, "")
readonly property var notesRelease: {
const list = releases?.releases ?? [];
const mm = notesVersion.match(/^\d+\.\d+/)?.[0] ?? "";
return list.find(r => r.version === notesVersion) ?? list.find(r => mm !== "" && (r.version === mm || r.version.startsWith(mm + "."))) ?? null;
}
readonly property bool shellManagedExternally: shellInstallMethod === "nix"
readonly property bool helperAvailable: sysupdateAvailable && backends.length > 0
readonly property bool useCustomCommand: SettingsData.updaterUseCustomCommand && (SettingsData.updaterCustomCommand || "").trim().length > 0
// Arch repository packages must upgrade together.
readonly property bool systemHoldsAllowed: !["pacman", "paru", "yay", "shelly"].includes(pkgManager)
function canIgnorePackage(pkg) {
if (!pkg)
return false;
if (pkgManager === "shelly")
return pkg.repo === "flatpak";
return systemHoldsAllowed || pkg.repo !== "system";
}
Connections {
target: DMSService
function onCapabilitiesReceived() {
root.checkCapabilities();
}
function onConnectionStateChanged() {
if (DMSService.isConnected) {
root.checkCapabilities();
} else {
root.sysupdateAvailable = false;
root._startupCheckDone = false;
}
Qt.callLater(() => root._maybeStartupCheck());
}
function onSysupdateStateUpdate(data) {
root._applyState(data);
}
}
Connections {
target: SettingsData
function onUpdaterCheckOnStartChanged() {
Qt.callLater(() => root._maybeStartupCheck());
}
function onUpdaterAllowAURChanged() {
root._refilter();
}
function onUpdaterIgnoredPackagesChanged() {
root._refilter();
}
function on_HasLoadedChanged() {
Qt.callLater(() => root._maybeStartupCheck());
}
}
Component.onCompleted: {
if (DMSService.dmsAvailable) {
checkCapabilities();
}
Qt.callLater(() => root._maybeStartupCheck());
}
function checkCapabilities() {
if (!DMSService.capabilities || !Array.isArray(DMSService.capabilities)) {
sysupdateAvailable = false;
Qt.callLater(() => root._maybeStartupCheck());
return;
}
const has = DMSService.capabilities.includes("sysupdate");
if (has && !sysupdateAvailable) {
sysupdateAvailable = true;
requestState();
// The daemon persists its last check but not the interval; re-apply it on every fresh connection.
setInterval(SettingsData.updaterIntervalSeconds);
} else if (!has) {
sysupdateAvailable = false;
}
Qt.callLater(() => root._maybeStartupCheck());
}
function requestState() {
if (!DMSService.isConnected || !sysupdateAvailable) {
return;
}
DMSService.sysupdateGetState(resp => {
if (resp && resp.result) {
_applyState(resp.result);
}
});
}
function _applyState(data) {
if (!data) {
return;
}
backends = data.backends || [];
const systemBackend = backends.find(b => b.repo === "system" || b.repo === "ostree");
pkgManager = systemBackend ? systemBackend.id : (backends.length > 0 ? backends[0].id : "");
_rawUpdates = data.packages || [];
availableUpdates = _filterUpdates(_rawUpdates);
distribution = data.distro || "";
distributionPretty = data.distroPretty || "";
distributionSupported = (backends.length > 0);
recentLog = data.recentLog || [];
intervalSeconds = data.intervalSeconds || 86400;
const checked = data.lastCheckUnix || 0;
const freshCheck = _stateSeeded && checked > lastCheckUnix;
_stateSeeded = true;
lastCheckUnix = checked;
nextCheckUnix = data.nextCheckUnix || 0;
const shell = data.shell || {};
shellInstallMethod = shell.installMethod || "unknown";
shellPackageName = shell.packageName || "";
shellChannel = shell.channel || "unknown";
shellRunning = shell.running || "";
shellInstalled = shell.installed || "";
shellGitBuild = shell.gitBuild || 0;
restartPending = shell.restartPending === true;
const reboot = data.reboot || {};
rebootRecommended = reboot.recommended === true;
rebootPackages = reboot.packages || [];
_rawShellUpdate = shell.updatePackage || null;
commitsBehind = typeof shell.commitsBehind === "number" ? shell.commitsBehind : -1;
const phase = data.phase || "idle";
switch (phase) {
case "refreshing":
isChecking = true;
isUpgrading = false;
break;
case "upgrading":
isChecking = false;
isUpgrading = true;
break;
default:
isChecking = false;
isUpgrading = false;
}
if (data.error) {
hasError = true;
errorMessage = data.error.message || "";
errorCode = data.error.code || "";
errorHint = data.error.hint || "";
} else {
hasError = false;
errorMessage = "";
errorCode = "";
errorHint = "";
}
if (freshCheck)
_maybeNotify();
}
function _filterUpdates(pkgs) {
const ignored = SettingsData.updaterIgnoredPackages || [];
return (pkgs || []).filter(p => {
if (!SettingsData.updaterAllowAUR && p.repo === "aur")
return false;
if (!canIgnorePackage(p))
return true;
return ignored.indexOf(p.name) === -1;
});
}
function _refilter() {
availableUpdates = _filterUpdates(_rawUpdates);
}
// Only when the count grows past the last announcement.
function _maybeNotify() {
if (!SettingsData.updaterNotify || isChecking || isUpgrading)
return;
if (updateCount === 0) {
if (SessionData.updaterNotifiedCount !== 0)
SessionData.set("updaterNotifiedCount", 0);
return;
}
if (updateCount <= SessionData.updaterNotifiedCount)
return;
const now = Math.floor(Date.now() / 1000);
if (now - SessionData.updaterNotifiedUnix < SettingsData.updaterNotifyMinSeconds)
return;
if (_notifyInFlight)
return;
_notifyInFlight = true;
const count = updateCount;
DMSService.notifySend({
"summary": count === 1 ? I18n.tr("%1 update", "singular, %1 is 1, available system update count").arg(count) : I18n.tr("%1 updates", "plural, %1 is a count of available system updates").arg(count),
"body": I18n.tr("Software updates are ready to install."),
"icon": "system-software-update",
"actionLabel": I18n.tr("Settings"),
"actionArgs": ["ipc", "call", "settings", "openWith", "updater"]
}, resp => {
root._notifyInFlight = false;
if (!resp || resp.error)
return;
SessionData.set("updaterNotifiedUnix", Math.floor(Date.now() / 1000));
SessionData.set("updaterNotifiedCount", count);
});
}
property bool _notifyInFlight: false
// The first state after a connect is the persisted list, not a check that just ran.
property bool _stateSeeded: false
function ignorePackage(name) {
if (!name)
return false;
if (pkgManager === "shelly" && !_rawUpdates.some(p => p.name === name && canIgnorePackage(p)))
return false;
const list = (SettingsData.updaterIgnoredPackages || []).slice();
if (list.indexOf(name) !== -1)
return true;
list.push(name);
SettingsData.set("updaterIgnoredPackages", list);
return true;
}
function unignorePackage(name) {
if (!name)
return;
const list = (SettingsData.updaterIgnoredPackages || []).filter(p => p !== name);
SettingsData.set("updaterIgnoredPackages", list);
}
function checkForUpdates() {
DMSService.sysupdateRefresh(false, null);
if (releasesRefCount > 0)
loadReleases(true);
}
function loadReleases(force) {
if (!DMSService.isConnected || !sysupdateAvailable)
return;
DMSService.sysupdateReleases(force, resp => {
// A late reply must not repopulate a feed the last Ref already dropped.
if (root.releasesRefCount > 0 && resp && resp.result)
root.releases = resp.result;
});
}
function restartShell() {
Quickshell.execDetached(["dms", "restart"]);
}
function runUpdates(opts) {
const params = opts || {};
params.ignored = SettingsData.updaterIgnoredPackages || [];
if (useCustomCommand) {
params.customCommand = SettingsData.updaterCustomCommand.trim();
const termArgs = (SettingsData.updaterTerminalAdditionalParams || "").trim();
if (termArgs.length > 0) {
params.terminalArgs = termArgs.split(/\s+/);
}
}
DMSService.sysupdateUpgrade(params, null);
}
function cancelUpdates() {
DMSService.sysupdateCancel(null);
}
function setInterval(seconds) {
DMSService.sysupdateSetInterval(seconds, null);
}
property bool _startupCheckDone: false
function _maybeStartupCheck() {
if (!pollWanted) {
_startupCheckDone = false;
return;
}
if (!SettingsData.updaterCheckOnStart)
return;
if (_startupCheckDone)
return;
if (!DMSService.isConnected || !sysupdateAvailable)
return;
_startupCheckDone = true;
Qt.callLater(() => DMSService.sysupdateRefresh(false, null, true));
}
onPollWantedChanged: {
if (!pollWanted)
_startupCheckDone = false;
Qt.callLater(() => root._syncAcquire());
Qt.callLater(() => root._maybeStartupCheck());
}
onReleasesRefCountChanged: {
if (releasesRefCount <= 0)
releases = null;
else if (releases === null)
loadReleases(false);
}
onSysupdateAvailableChanged: {
_syncAcquire();
if (sysupdateAvailable && releasesRefCount > 0 && releases === null)
loadReleases(false);
}
property bool _acquired: false
// Releasing the ref parks the daemon scheduler; its deadline is kept, so checks resume on AC.
readonly property bool pausedOnBattery: SettingsData.updaterPauseOnBattery && BatteryService.batteryAvailable && !BatteryService.isPluggedIn
onPausedOnBatteryChanged: _syncAcquire()
function _syncAcquire() {
const want = pollWanted && sysupdateAvailable && !pausedOnBattery;
if (want === _acquired) {
return;
}
_acquired = want;
if (want) {
DMSService.sysupdateAcquire(null);
return;
}
DMSService.sysupdateRelease(null);
}
}