diff --git a/shell/Ui/BackgroundMedia.qml b/shell/Ui/BackgroundMedia.qml index 6ca02273..8640769f 100644 --- a/shell/Ui/BackgroundMedia.qml +++ b/shell/Ui/BackgroundMedia.qml @@ -8,6 +8,9 @@ Item { property string path: "" property int version: 0 + // Unversioned images cache by path. A caller whose version changes with every + // new file, as the lock's does, may cache too: the version is in the URL. + property bool cached: version === 0 // A caller that knows the size its screen needs, in physical pixels, sets // constrainDecode and decodeSize. The image then waits for that size rather // than decoding at the file's native size first. @@ -35,7 +38,7 @@ Item { source: !root.constrainDecode || root.decodeSize.width > 0 ? root.imageUrl : "" fillMode: Image.PreserveAspectCrop asynchronous: true - cache: root.version === 0 + cache: root.cached sourceSize.width: root.constrainDecode ? root.decodeSize.width : (root.version > 0 ? width : 0) sourceSize.height: root.constrainDecode ? root.decodeSize.height : (root.version > 0 ? height : 0) } diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index 7084fb0f..999a0a04 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -96,6 +96,13 @@ Item { anchors.fill: parent path: root.loadBackground ? (root.video ? root.videoPosterPath : root.backgroundPath) : "" version: root.backgroundVersion + // Decode only once sized, at the lock's own size: an unsized first + // request decoded the file at its native resolution, then again once + // sized. That size is what the lock service keeps decoded ahead of the + // lock, so the first frame has the wallpaper. + cached: true + constrainDecode: true + decodeSize: Qt.size(width, height) } MultiEffect { diff --git a/shell/plugins/lock/Service.qml b/shell/plugins/lock/Service.qml index 6e1f703e..247495a4 100644 --- a/shell/plugins/lock/Service.qml +++ b/shell/plugins/lock/Service.qml @@ -30,6 +30,9 @@ Item { property string backgroundPath: "" property string videoPosterPath: "" property int backgroundVersion: 0 + // The wallpaper file's mtime and size. The lock caches its wallpaper by + // version, so a file overwritten in place must bump the version too. + property string backgroundSignature: "" property string lastEvent: "init" property string lastEventAt: "" property bool displaysBlank: false @@ -407,6 +410,32 @@ Item { } } + // The lock only starts decoding its wallpaper once locked, and a machine + // suspending right after locking froze that decode partway: waking showed + // the password field on a bare background, then the wallpaper popped in. + // Keep each screen's lock wallpaper decoded in the image cache ahead of + // time, as the lock view requests it (same URL, the screen's logical size, + // PreserveAspectCrop), so the lock draws it on its first frame. + readonly property string lockWallpaperPath: videoBackground ? videoPosterPath : backgroundPath + readonly property string lockWallpaperUrl: lockWallpaperPath && !Util.isVideoPath(lockWallpaperPath) + ? Util.fileUrl(lockWallpaperPath) + (backgroundVersion ? "?v=" + backgroundVersion : "") + : "" + + Variants { + model: Quickshell.screens + + Image { + required property var modelData + visible: false + source: root.lockWallpaperUrl + sourceSize.width: modelData.width + sourceSize.height: modelData.height + fillMode: Image.PreserveAspectCrop + asynchronous: true + cache: true + } + } + Timer { id: fingerprintRetryTimer interval: 250 @@ -416,14 +445,20 @@ Item { Process { id: readlinkProc - command: ["readlink", "-f", root.currentBackgroundLink] + command: ["bash", "-c", "path=$(readlink -f -- \"$1\") && printf '%s\\n%s\\n' \"$path\" \"$(stat -Lc %Y:%s -- \"$path\" 2>/dev/null)\"", "_", root.currentBackgroundLink] stdout: StdioCollector { waitForEnd: true onStreamFinished: { - var next = String(text || "").trim() + var lines = String(text || "").split("\n") + var next = String(lines[0] || "").trim() + var signature = String(lines[1] || "").trim() if (next !== root.backgroundPath) { root.videoPosterPath = "" root.backgroundPath = next + root.backgroundSignature = signature + root.backgroundVersion += 1 + } else if (signature !== root.backgroundSignature) { + root.backgroundSignature = signature root.backgroundVersion += 1 } root.refreshPoster() diff --git a/test/shell.d/lock-wallpaper-preload-test.sh b/test/shell.d/lock-wallpaper-preload-test.sh new file mode 100644 index 00000000..8d0cc46a --- /dev/null +++ b/test/shell.d/lock-wallpaper-preload-test.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8') +const media = read('shell/Ui/BackgroundMedia.qml') +const view = read('shell/plugins/lock/LockView.qml') +const service = read('shell/plugins/lock/Service.qml') + +// The lock draws its wallpaper on the first frame only if the image it asks +// for is already in the cache: same URL, same requested size, same fill mode. +assert( + media.includes('property bool cached: version === 0') && media.includes('cache: root.cached'), + 'background media caches unversioned images, and versioned ones on request' +) +assert( + /version: root\.backgroundVersion\s*(\/\/.*\n\s*)*cached: true\s*constrainDecode: true\s*decodeSize: Qt\.size\(width, height\)/.test(view), + 'the lock wallpaper waits for its size and reads from the cache' +) +assert( + service.includes('readonly property string lockWallpaperPath: videoBackground ? videoPosterPath : backgroundPath') && + service.includes('? Util.fileUrl(lockWallpaperPath) + (backgroundVersion ? "?v=" + backgroundVersion : "")') && + media.includes('Util.fileUrl(path) + (version ? "?v=" + version : "")'), + 'the lock service preloads the exact URL the lock view requests' +) +assert( + /model: Quickshell\.screens\s*Image \{\s*required property var modelData\s*visible: false\s*source: root\.lockWallpaperUrl\s*sourceSize\.width: modelData\.width\s*sourceSize\.height: modelData\.height\s*fillMode: Image\.PreserveAspectCrop\s*asynchronous: true\s*cache: true/.test(service), + 'the lock service keeps each screen\'s lock wallpaper decoded at the lock\'s size and fill' +) + +// A wallpaper overwritten in place keeps its path, so the version, which is +// part of the cached URL, follows the file's mtime and size as well. +assert( + service.includes('stat -Lc %Y:%s') && + /\} else if \(signature !== root\.backgroundSignature\) \{\s*root\.backgroundSignature = signature\s*root\.backgroundVersion \+= 1/.test(service), + 'an overwritten wallpaper bumps the lock wallpaper version' +) +JS