Image Caching: full refactor base on ImageMagick and fallback to QML. Allows support for a lot more image formats.

This commit is contained in:
Lemmy
2025-12-25 17:31:12 -05:00
parent 4179f9c9f8
commit 09671d1a2c
15 changed files with 781 additions and 733 deletions
-6
View File
@@ -29,9 +29,6 @@ Singleton {
readonly property string shellName: "noctalia"
readonly property string configDir: Quickshell.env("NOCTALIA_CONFIG_DIR") || (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/" + shellName + "/"
readonly property string cacheDir: Quickshell.env("NOCTALIA_CACHE_DIR") || (Quickshell.env("XDG_CACHE_HOME") || Quickshell.env("HOME") + "/.cache") + "/" + shellName + "/"
readonly property string cacheDirImages: cacheDir + "images/"
readonly property string cacheDirImagesWallpapers: cacheDir + "images/wallpapers/"
readonly property string cacheDirImagesNotifications: cacheDir + "images/notifications/"
readonly property string settingsFile: Quickshell.env("NOCTALIA_SETTINGS_FILE") || (configDir + "settings.json")
readonly property string defaultLocation: "Tokyo"
readonly property string defaultAvatar: Quickshell.env("HOME") + "/.face"
@@ -50,9 +47,6 @@ Singleton {
Quickshell.execDetached(["mkdir", "-p", configDir]);
Quickshell.execDetached(["mkdir", "-p", cacheDir]);
Quickshell.execDetached(["mkdir", "-p", cacheDirImagesWallpapers]);
Quickshell.execDetached(["mkdir", "-p", cacheDirImagesNotifications]);
// Ensure PAM config file exists in configDir (create once, never override)
ensurePamConfig();
+3 -3
View File
@@ -313,7 +313,7 @@ Variants {
Qt.callLater(setWallpaperInitial);
return;
}
if (!WallpaperCacheService || !WallpaperCacheService.initialized) {
if (!ImageCacheService || !ImageCacheService.initialized) {
Qt.callLater(setWallpaperInitial);
return;
}
@@ -323,7 +323,7 @@ Variants {
const targetWidth = Math.round(modelData.width * compositorScale);
const targetHeight = Math.round(modelData.height * compositorScale);
WallpaperCacheService.getPreprocessed(wallpaperPath, modelData.name, targetWidth, targetHeight, function (cachedPath, success) {
ImageCacheService.getFullscreen(wallpaperPath, modelData.name, targetWidth, targetHeight, function (cachedPath, success) {
if (success) {
futureWallpaper = cachedPath;
} else {
@@ -340,7 +340,7 @@ Variants {
const targetWidth = Math.round(modelData.width * compositorScale);
const targetHeight = Math.round(modelData.height * compositorScale);
WallpaperCacheService.getPreprocessed(originalPath, modelData.name, targetWidth, targetHeight, function (cachedPath, success) {
ImageCacheService.getFullscreen(originalPath, modelData.name, targetWidth, targetHeight, function (cachedPath, success) {
if (success) {
futureWallpaper = cachedPath;
} else {
+1 -1
View File
@@ -58,7 +58,7 @@ Loader {
if (!wallpaper)
return;
// Use 1280x720 for overview since it's heavily blurred anyway
WallpaperCacheService.getPreprocessed(wallpaper, modelData.name, 1280, 720, function (path, success) {
ImageCacheService.getFullscreen(wallpaper, modelData.name, 1280, 720, function (path, success) {
cachedWallpaper = path;
});
}
+12 -6
View File
@@ -161,14 +161,15 @@ Loader {
return;
}
if (!WallpaperCacheService || !WallpaperCacheService.initialized) {
// Fallback to original if services not ready
resolvedWallpaperPath = WallpaperService.getWallpaper(screen.name) || "";
const originalPath = WallpaperService.getWallpaper(screen.name) || "";
if (originalPath === "") {
resolvedWallpaperPath = "";
return;
}
resolvedWallpaperPath = WallpaperService.getWallpaper(screen.name) || "";
if (resolvedWallpaperPath === "") {
if (!ImageCacheService || !ImageCacheService.initialized) {
// Fallback to original if services not ready
resolvedWallpaperPath = originalPath;
return;
}
@@ -179,9 +180,14 @@ Loader {
return;
}
WallpaperCacheService.getPreprocessed(resolvedWallpaperPath, screen.name, targetWidth, targetHeight, function (cachedPath, success) {
// Don't set resolvedWallpaperPath until cache is ready
// This prevents loading the original huge image
ImageCacheService.getFullscreen(originalPath, screen.name, targetWidth, targetHeight, function (cachedPath, success) {
if (success) {
resolvedWallpaperPath = cachedPath;
} else {
// Only fall back to original if caching failed
resolvedWallpaperPath = originalPath;
}
});
@@ -3,6 +3,7 @@ import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import qs.Commons
import qs.Services.UI
import qs.Widgets
ColumnLayout {
@@ -133,7 +134,7 @@ ColumnLayout {
id: imagePicker
title: I18n.tr("bar.widget-settings.control-center.select-custom-icon")
selectionMode: "files"
nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.pnm", "*.bmp"]
nameFilters: ImageCacheService.basicImageFilters
initialPath: Quickshell.env("HOME")
onAccepted: paths => {
if (paths.length > 0) {
+11 -1
View File
@@ -16,8 +16,16 @@ ColumnLayout {
property string currentVersion: UpdateService.currentVersion
property var contributors: GitHubService.contributors
property string commitInfo: ""
property int avatarCacheVersion: 0
readonly property int topContributorsCount: 20
Connections {
target: GitHubService
function onCachedAvatarsChanged() {
root.avatarCacheVersion++;
}
}
readonly property bool isGitVersion: root.currentVersion.endsWith("-git")
spacing: Style.marginL
@@ -334,9 +342,11 @@ ColumnLayout {
Image {
anchors.fill: parent
source: {
// Depend on avatarCacheVersion to trigger re-evaluation
var _ = root.avatarCacheVersion;
// Try cached circular version first
var username = root.contributors[index].login;
var cached = GitHubService.cachedCircularAvatars[username];
var cached = GitHubService.getAvatarPath(username);
if (cached) {
wrapper.isRounded = true;
return cached;
+1 -1
View File
@@ -53,7 +53,7 @@ ColumnLayout {
title: I18n.tr("settings.general.profile.select-avatar")
selectionMode: "files"
initialPath: Settings.preprocessPath(Settings.data.general.avatarImage).substr(0, Settings.preprocessPath(Settings.data.general.avatarImage).lastIndexOf("/")) || Quickshell.env("HOME")
nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.pnm", "*.bmp"]
nameFilters: ImageCacheService.basicImageFilters
onAccepted: paths => {
if (paths.length > 0) {
Settings.data.general.avatarImage = paths[0];
@@ -767,7 +767,6 @@ SmartPanel {
NImageCached {
id: img
imagePath: wallpaperPath
cacheFolder: Settings.cacheDirImagesWallpapers
anchors.fill: parent
}
+23 -260
View File
@@ -4,6 +4,7 @@ import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.UI
// GitHub API logic for contributors
Singleton {
@@ -18,17 +19,10 @@ Singleton {
property string latestVersion: I18n.tr("system.unknown-version")
property var contributors: []
// Avatar caching properties
property var cachedCircularAvatars: ({}) // username → file:// path
property var cacheMetadata: ({}) // Loaded from metadata.json
property var avatarQueue: []
property bool isProcessingAvatars: false
property bool metadataLoaded: false
// Avatar caching properties (simplified - uses ImageCacheService)
property var cachedAvatars: ({}) // username → file:// path
property bool avatarsCached: false // Track if we've already processed avatars
readonly property string avatarCacheDir: Settings.cacheDirImages + "contributors/"
readonly property string metadataPath: avatarCacheDir + "metadata.json"
property bool isInitialized: false
FileView {
@@ -37,7 +31,7 @@ Singleton {
printErrors: false
watchChanges: false // Disable to prevent reload on our own writes
Component.onCompleted: {
loadCacheMetadata();
// Data loading handled by FileView onLoaded
}
onLoaded: {
if (!root.isInitialized) {
@@ -164,280 +158,49 @@ Singleton {
}
// --------------------------------
// Avatar Caching Functions
// Avatar Caching Functions (simplified - uses ImageCacheService)
// --------------------------------
function loadCacheMetadata() {
var loadProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
command: ["cat", "${metadataPath}"]
}
`, root, "LoadMetadata");
loadProcess.stdout = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
StdioCollector {}
`, loadProcess, "StdoutCollector");
loadProcess.stdout.onStreamFinished.connect(function () {
try {
var text = loadProcess.stdout.text;
if (text && text.trim()) {
cacheMetadata = JSON.parse(text);
Logger.d("GitHubService", "Loaded cache metadata:", Object.keys(cacheMetadata).length, "entries");
// Populate cachedCircularAvatars from metadata
for (var username in cacheMetadata) {
var entry = cacheMetadata[username];
cachedCircularAvatars[username] = "file://" + entry.cached_path;
}
metadataLoaded = true;
Logger.d("GitHubService", "Cache metadata loaded successfully");
} else {
Logger.d("GitHubService", "No existing cache metadata found (empty response)");
cacheMetadata = {};
metadataLoaded = true;
}
} catch (e) {
Logger.w("GitHubService", "Failed to parse cache metadata:", e);
cacheMetadata = {};
metadataLoaded = true;
}
loadProcess.destroy();
});
loadProcess.exited.connect(function (exitCode) {
if (exitCode !== 0) {
// File doesn't exist, initialize empty
cacheMetadata = {};
metadataLoaded = true;
Logger.d("GitHubService", "Initializing empty cache metadata");
}
});
loadProcess.running = true;
}
function saveCacheMetadata() {
Quickshell.execDetached(["mkdir", "-p", avatarCacheDir]);
var jsonContent = JSON.stringify(cacheMetadata, null, 2);
// Use printf with base64 encoding to safely handle special characters
var base64Content = Qt.btoa(jsonContent); // Base64 encode
var saveProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
command: ["sh", "-c", "echo '${base64Content}' | base64 -d > '${metadataPath}'"]
}
`, root, "SaveMetadata_" + Date.now());
saveProcess.exited.connect(function (exitCode) {
if (exitCode === 0) {
Logger.d("GitHubService", "Saved cache metadata");
} else {
Logger.e("GitHubService", "Failed to save cache metadata, exit code:", exitCode);
}
saveProcess.destroy();
});
saveProcess.running = true;
function getAvatarPath(username) {
return cachedAvatars[username] || "";
}
function cacheTopContributorAvatars() {
if (contributors.length === 0)
return;
// Mark that we've processed avatars for this contributor set
avatarsCached = true;
Quickshell.execDetached(["mkdir", "-p", avatarCacheDir]);
// Build queue of avatars that need processing
avatarQueue = [];
var currentTop20 = {};
for (var i = 0; i < Math.min(contributors.length, 20); i++) {
var contributor = contributors[i];
var username = contributor.login;
var avatarUrl = contributor.avatar_url;
var circularPath = avatarCacheDir + username + "_circular.png";
currentTop20[username] = true;
// Check if we need to process this avatar
var needsProcessing = false;
var reason = "";
if (!cacheMetadata[username]) {
// New user in top 20
needsProcessing = true;
reason = "new user";
} else if (cacheMetadata[username].avatar_url !== avatarUrl) {
// Avatar URL changed (user updated their GitHub avatar)
needsProcessing = true;
reason = "avatar URL changed";
} else {
// Already cached - add to map
cachedCircularAvatars[username] = "file://" + circularPath;
}
if (needsProcessing) {
Logger.d("GitHubService", "Queueing avatar for", username, "-", reason);
avatarQueue.push({
username: username,
avatarUrl: avatarUrl,
circularPath: circularPath
});
}
// Use closure to capture username
(function(uname, url) {
ImageCacheService.getCircularAvatar(url, uname, function(cachedPath, success) {
if (success) {
cachedAvatars[uname] = "file://" + cachedPath;
cachedAvatarsChanged();
}
});
})(username, avatarUrl);
}
// Cleanup: Remove metadata for users no longer in top 20
var removedUsers = [];
for (var cachedUsername in cacheMetadata) {
if (!currentTop20[cachedUsername]) {
removedUsers.push(cachedUsername);
// Delete cached circular file
var pathToDelete = cacheMetadata[cachedUsername].cached_path;
Quickshell.execDetached(["rm", "-f", pathToDelete]);
delete cacheMetadata[cachedUsername];
delete cachedCircularAvatars[cachedUsername];
}
}
if (removedUsers.length > 0) {
Logger.d("GitHubService", "Cleaned up avatars for users no longer in top 20:", removedUsers.join(", "));
saveCacheMetadata();
}
// Start processing queue
if (avatarQueue.length > 0) {
Logger.i("GitHubService", "Processing", avatarQueue.length, "avatar(s)");
processNextAvatar();
} else {
Logger.d("GitHubService", "All avatars already cached");
cachedCircularAvatarsChanged(); // Notify AboutTab
}
}
function processNextAvatar() {
if (avatarQueue.length === 0 || isProcessingAvatars)
return;
isProcessingAvatars = true;
var item = avatarQueue.shift();
Logger.d("GitHubService", "Downloading avatar for", item.username);
// Download original avatar
var tempPath = avatarCacheDir + item.username + "_temp.png";
downloadAvatar(item.avatarUrl, tempPath, function (success) {
if (success) {
// Render circular version
renderCircularAvatar(tempPath, item.circularPath, item.username, item.avatarUrl);
} else {
Logger.e("GitHubService", "Failed to download avatar for", item.username);
isProcessingAvatars = false;
processNextAvatar();
}
});
}
function downloadAvatar(url, destPath, callback) {
var downloadCmd = `curl -L -s -o '${destPath}' '${url}' || wget -q -O '${destPath}' '${url}'`;
var downloadProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
command: ["sh", "-c", "${downloadCmd}"]
}
`, root, "Download_" + Date.now());
downloadProcess.exited.connect(function (exitCode) {
callback(exitCode === 0);
downloadProcess.destroy();
});
downloadProcess.running = true;
}
function renderCircularAvatar(inputPath, outputPath, username, avatarUrl) {
Logger.d("GitHubService", "Rendering circular avatar for", username);
// Use ImageMagick to create a circular avatar with proper alpha transparency
var convertProcess = Qt.createQmlObject(`
import QtQuick
import Quickshell.Io
Process {
command: ["magick", "${inputPath}", "-resize", "256x256^", "-gravity", "center", "-extent", "256x256", "-alpha", "set", "(", "+clone", "-channel", "A", "-evaluate", "set", "0", "+channel", "-fill", "white", "-draw", "circle 128,128 128,0", ")", "-compose", "DstIn", "-composite", "${outputPath}"]
}
`, root, "Convert_" + Date.now());
convertProcess.exited.connect(function (exitCode) {
var success = exitCode === 0;
if (success) {
// Update cache metadata
cacheMetadata[username] = {
avatar_url: avatarUrl,
cached_path: outputPath,
cached_at: Date.now()
};
cachedCircularAvatars[username] = "file://" + outputPath;
cachedCircularAvatarsChanged();
saveCacheMetadata();
Logger.d("GitHubService", "Cached circular avatar for", username);
} else {
Logger.e("GitHubService", "Failed to render circular avatar for", username);
}
// Clean up temp file
Quickshell.execDetached(["rm", "-f", inputPath]);
// Process next in queue
isProcessingAvatars = false;
processNextAvatar();
convertProcess.destroy();
});
convertProcess.running = true;
}
// --------------------------------
// Hook into contributors change - only process once
onContributorsChanged: {
if (contributors.length > 0 && !avatarsCached) {
// Wait for metadata to load before processing
if (metadataLoaded) {
Qt.callLater(cacheTopContributorAvatars);
} else {
// Metadata not loaded yet, wait for it
metadataLoadedWatcher.start();
}
if (contributors.length > 0 && !avatarsCached && ImageCacheService.initialized) {
Qt.callLater(cacheTopContributorAvatars);
}
}
// Wait for metadata to be loaded before caching avatars
Timer {
id: metadataLoadedWatcher
interval: 100
repeat: true
onTriggered: {
if (metadataLoaded && contributors.length > 0 && !avatarsCached) {
stop();
// Also watch for ImageCacheService to become initialized
Connections {
target: ImageCacheService
function onInitializedChanged() {
if (ImageCacheService.initialized && contributors.length > 0 && !avatarsCached) {
Qt.callLater(cacheTopContributorAvatars);
}
}
+27 -84
View File
@@ -32,60 +32,11 @@ Singleton {
// Internal state
property var activeNotifications: ({}) // Maps internal ID to {notification, watcher, metadata}
property var quickshellIdToInternalId: ({})
property var imageQueue: []
// Rate limiting for notification sounds (minimum 100ms between sounds)
property var lastSoundTime: 0
readonly property int minSoundInterval: 100
PanelWindow {
implicitHeight: 0
implicitWidth: 0
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "noctalia-notification-image-renderer"
color: Color.transparent
mask: Region {}
Image {
id: cacher
width: 64
height: 64
visible: true
cache: false
asynchronous: true
mipmap: true
antialiasing: true
onStatusChanged: {
if (imageQueue.length === 0) {
return;
}
const req = imageQueue[0];
if (status === Image.Ready) {
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDirImagesNotifications]);
grabToImage(result => {
if (result.saveToFile(req.dest))
updateImagePath(req.imageId, req.dest);
processNextImage();
});
} else if (status === Image.Error) {
processNextImage();
}
}
function processNextImage() {
imageQueue.shift();
if (imageQueue.length > 0) {
source = imageQueue[0].src;
} else {
source = "";
}
}
}
}
// Notification server
property var notificationServerLoader: null
@@ -431,7 +382,7 @@ Singleton {
const image = n.image || getIcon(n.appIcon);
const imageId = generateImageId(n, image);
queueImage(image, imageId);
queueImage(image, n.appName || "", n.summary || "", id);
return {
"id": id,
@@ -443,7 +394,7 @@ Singleton {
"timestamp": time,
"progress": 1.0,
"originalImage": image,
"cachedImage": imageId ? (Settings.cacheDirImagesNotifications + imageId + ".png") : image,
"cachedImage": image, // Start with original, update when cached
"actionsJson": JSON.stringify((n.actions || []).map(a => ({
"text": a.text || "Action",
"identifier": a.identifier || ""
@@ -545,35 +496,26 @@ Singleton {
}
// Image handling
function queueImage(path, imageId) {
if (!path || !path.startsWith("image://") || !imageId)
function queueImage(path, appName, summary, notificationId) {
if (!path || !path.startsWith("image://") || !notificationId)
return;
const dest = Settings.cacheDirImagesNotifications + imageId + ".png";
for (const req of imageQueue) {
if (req.imageId === imageId)
return;
}
imageQueue.push({
"src": path,
"dest": dest,
"imageId": imageId
});
if (imageQueue.length === 1)
cacher.source = path;
ImageCacheService.getNotificationIcon(path, appName, summary, function(cachedPath, success) {
if (success && cachedPath) {
updateImagePath(notificationId, "file://" + cachedPath);
}
});
}
function updateImagePath(id, path) {
updateModel(activeList, id, "cachedImage", path);
updateModel(historyList, id, "cachedImage", path);
function updateImagePath(notificationId, path) {
updateModel(activeList, notificationId, "cachedImage", path);
updateModel(historyList, notificationId, "cachedImage", path);
saveHistory();
}
function updateModel(model, id, prop, value) {
function updateModel(model, notificationId, prop, value) {
for (var i = 0; i < model.count; i++) {
if (model.get(i).id === id) {
if (model.get(i).id === notificationId) {
model.setProperty(i, prop, value);
break;
}
@@ -587,8 +529,9 @@ Singleton {
while (historyList.count > maxHistory) {
const old = historyList.get(historyList.count - 1);
// Only delete cached images that are in our cache directory
if (old.cachedImage && old.cachedImage.startsWith(Settings.cacheDirImagesNotifications)) {
Quickshell.execDetached(["rm", "-f", old.cachedImage]);
const cachedPath = old.cachedImage ? old.cachedImage.replace(/^file:\/\//, "") : "";
if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) {
Quickshell.execDetached(["rm", "-f", cachedPath]);
}
historyList.remove(historyList.count - 1);
}
@@ -644,12 +587,10 @@ Singleton {
for (const item of adapter.notifications || []) {
const time = new Date(item.timestamp);
// Use the cached image if it exists and starts with file://, otherwise use originalImage
let cachedImage = item.cachedImage || "";
if (item.originalImage && item.originalImage.startsWith("image://") && !cachedImage) {
const imageId = generateImageId(item, item.originalImage);
if (imageId) {
cachedImage = Settings.cacheDirImagesNotifications + imageId + ".png";
}
if (!cachedImage || (!cachedImage.startsWith("file://") && !cachedImage.startsWith("/"))) {
cachedImage = item.originalImage || "";
}
historyList.append({
@@ -826,8 +767,9 @@ Singleton {
const notif = historyList.get(i);
if (notif.id === notificationId) {
// Only delete cached images that are in our cache directory
if (notif.cachedImage && notif.cachedImage.startsWith(Settings.cacheDirImagesNotifications)) {
Quickshell.execDetached(["rm", "-f", notif.cachedImage]);
const cachedPath = notif.cachedImage ? notif.cachedImage.replace(/^file:\/\//, "") : "";
if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) {
Quickshell.execDetached(["rm", "-f", cachedPath]);
}
historyList.remove(i);
saveHistory();
@@ -841,8 +783,9 @@ Singleton {
if (historyList.count > 0) {
const oldest = historyList.get(historyList.count - 1);
// Only delete cached images that are in our cache directory
if (oldest.cachedImage && oldest.cachedImage.startsWith(Settings.cacheDirImagesNotifications)) {
Quickshell.execDetached(["rm", "-f", oldest.cachedImage]);
const cachedPath = oldest.cachedImage ? oldest.cachedImage.replace(/^file:\/\//, "") : "";
if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) {
Quickshell.execDetached(["rm", "-f", cachedPath]);
}
historyList.remove(historyList.count - 1);
saveHistory();
@@ -853,7 +796,7 @@ Singleton {
function clearHistory() {
try {
Quickshell.execDetached(["sh", "-c", `rm -rf "${Settings.cacheDirImagesNotifications}"*`]);
Quickshell.execDetached(["sh", "-c", `rm -rf "${ImageCacheService.notificationsDir}"*`]);
} catch (e) {
Logger.e("Notifications", "Failed to clear cache directory:", e);
}
+680
View File
@@ -0,0 +1,680 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import "../../Helpers/sha256.js" as Checksum
import qs.Commons
Singleton {
id: root
// -------------------------------------------------
// Public Properties
// -------------------------------------------------
property bool imageMagickAvailable: false
property bool initialized: false
// Cache directories
readonly property string baseDir: Settings.cacheDir + "images/"
readonly property string wpThumbDir: baseDir + "wallpapers/thumbnails/"
readonly property string wpLargeDir: baseDir + "wallpapers/large/"
readonly property string notificationsDir: baseDir + "notifications/"
readonly property string contributorsDir: baseDir + "contributors/"
// Supported image formats - extended list when ImageMagick is available
readonly property var basicImageFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.bmp"]
readonly property var extendedImageFilters: [
"*.jpg", "*.jpeg", "*.png", "*.gif", "*.bmp",
"*.webp", "*.avif", "*.heic", "*.heif",
"*.tiff", "*.tif", "*.pnm", "*.pgm", "*.ppm", "*.pbm",
"*.svg", "*.svgz",
"*.ico", "*.icns",
"*.jxl", "*.jp2", "*.j2k",
"*.exr", "*.hdr",
"*.dds", "*.tga"
]
readonly property var imageFilters: imageMagickAvailable ? extendedImageFilters : basicImageFilters
// Check if a file format needs conversion (not natively supported by Qt)
function needsConversion(filePath) {
const ext = "*." + filePath.toLowerCase().split('.').pop();
return !basicImageFilters.includes(ext);
}
// -------------------------------------------------
// Internal State
// -------------------------------------------------
property var pendingRequests: ({})
property var fallbackQueue: []
property bool fallbackProcessing: false
// -------------------------------------------------
// Signals
// -------------------------------------------------
signal cacheHit(string cacheKey, string cachedPath)
signal cacheMiss(string cacheKey)
signal processingComplete(string cacheKey, string cachedPath)
signal processingFailed(string cacheKey, string error)
// -------------------------------------------------
// Initialization
// -------------------------------------------------
function init() {
Logger.i("ImageCache", "Service started");
createDirectories();
checkMagickProcess.running = true;
}
function createDirectories() {
Quickshell.execDetached(["mkdir", "-p", wpThumbDir]);
Quickshell.execDetached(["mkdir", "-p", wpLargeDir]);
Quickshell.execDetached(["mkdir", "-p", notificationsDir]);
Quickshell.execDetached(["mkdir", "-p", contributorsDir]);
}
// -------------------------------------------------
// Public API: Get Thumbnail (256x256)
// -------------------------------------------------
function getThumbnail(sourcePath, callback) {
if (!sourcePath || sourcePath === "") {
callback("", false);
return;
}
getMtime(sourcePath, function(mtime) {
const cacheKey = generateThumbnailKey(sourcePath, mtime);
const cachedPath = wpThumbDir + cacheKey + ".jpg";
processRequest(cacheKey, cachedPath, sourcePath, callback, function() {
if (imageMagickAvailable) {
startThumbnailProcessing(sourcePath, cachedPath, cacheKey);
} else {
queueFallbackProcessing(sourcePath, cachedPath, cacheKey, 256);
}
});
});
}
// -------------------------------------------------
// Public API: Get Fullscreen Wallpaper
// -------------------------------------------------
function getFullscreen(sourcePath, screenName, width, height, callback) {
if (!sourcePath || sourcePath === "") {
callback("", false);
return;
}
if (!imageMagickAvailable) {
Logger.d("ImageCache", "ImageMagick not available, using original:", sourcePath);
callback(sourcePath, false);
return;
}
// Fast dimension check - skip processing if image is smaller than screen AND format is Qt-native
getImageDimensions(sourcePath, function(imgWidth, imgHeight) {
const isSmaller = imgWidth > 0 && imgHeight > 0 && imgWidth <= width && imgHeight <= height;
if (isSmaller) {
// Only skip if format is natively supported by Qt
if (!needsConversion(sourcePath)) {
Logger.d("ImageCache", `Image ${imgWidth}x${imgHeight} <= screen ${width}x${height}, using original`);
callback(sourcePath, false);
return;
}
Logger.d("ImageCache", `Image needs conversion despite being smaller than screen`);
}
// Use actual image dimensions if smaller (convert without upscaling), otherwise use screen dimensions
const targetWidth = isSmaller ? imgWidth : width;
const targetHeight = isSmaller ? imgHeight : height;
getMtime(sourcePath, function(mtime) {
const cacheKey = generateLargeKey(sourcePath, screenName, width, height, mtime);
const cachedPath = wpLargeDir + cacheKey + ".jpg";
processRequest(cacheKey, cachedPath, sourcePath, callback, function() {
startLargeProcessing(sourcePath, cachedPath, targetWidth, targetHeight, cacheKey);
});
});
});
}
// -------------------------------------------------
// Public API: Get Notification Icon (64x64)
// -------------------------------------------------
function getNotificationIcon(imageUri, appName, summary, callback) {
if (!imageUri || imageUri === "") {
callback("", false);
return;
}
// File paths are used directly, not cached
if (imageUri.startsWith("/") || imageUri.startsWith("file://")) {
callback(imageUri, false);
return;
}
const cacheKey = generateNotificationKey(imageUri, appName, summary);
const cachedPath = notificationsDir + cacheKey + ".png";
processRequest(cacheKey, cachedPath, imageUri, callback, function() {
// Notifications always use Qt fallback (image:// URIs can't be read by ImageMagick)
queueFallbackProcessing(imageUri, cachedPath, cacheKey, 64);
});
}
// -------------------------------------------------
// Public API: Get Circular Avatar (256x256)
// -------------------------------------------------
function getCircularAvatar(url, username, callback) {
if (!url || !username) {
callback("", false);
return;
}
const cacheKey = username;
const cachedPath = contributorsDir + username + "_circular.png";
processRequest(cacheKey, cachedPath, url, callback, function() {
if (imageMagickAvailable) {
downloadAndProcessAvatar(url, username, cachedPath, cacheKey);
} else {
// No fallback for circular avatars without ImageMagick
Logger.w("ImageCache", "Circular avatars require ImageMagick");
notifyCallbacks(cacheKey, "", false);
}
});
}
// -------------------------------------------------
// Cache Key Generation
// -------------------------------------------------
function generateThumbnailKey(sourcePath, mtime) {
const keyString = sourcePath + "@256x256@" + (mtime || "unknown");
return Checksum.sha256(keyString);
}
function generateLargeKey(sourcePath, screenName, width, height, mtime) {
const keyString = sourcePath + "@" + screenName + "@" + width + "x" + height + "@" + (mtime || "unknown");
return Checksum.sha256(keyString);
}
function generateNotificationKey(imageUri, appName, summary) {
if (imageUri.startsWith("image://qsimage/")) {
return Checksum.sha256(appName + "|" + summary);
}
return Checksum.sha256(imageUri);
}
// -------------------------------------------------
// Request Processing (with coalescing)
// -------------------------------------------------
function processRequest(cacheKey, cachedPath, sourcePath, callback, processFn) {
// Check if already processing this request
if (pendingRequests[cacheKey]) {
pendingRequests[cacheKey].callbacks.push(callback);
Logger.d("ImageCache", "Coalescing request for:", cacheKey);
return;
}
// Check cache first
checkFileExists(cachedPath, function(exists) {
if (exists) {
Logger.d("ImageCache", "Cache hit:", cachedPath);
callback(cachedPath, true);
cacheHit(cacheKey, cachedPath);
return;
}
// Re-check pendingRequests (race condition fix)
if (pendingRequests[cacheKey]) {
pendingRequests[cacheKey].callbacks.push(callback);
return;
}
// Start new processing
Logger.d("ImageCache", "Cache miss, processing:", sourcePath);
cacheMiss(cacheKey);
pendingRequests[cacheKey] = {
callbacks: [callback],
sourcePath: sourcePath
};
processFn();
});
}
function notifyCallbacks(cacheKey, path, success) {
const request = pendingRequests[cacheKey];
if (request) {
request.callbacks.forEach(function(cb) {
cb(path, success);
});
delete pendingRequests[cacheKey];
}
if (success) {
processingComplete(cacheKey, path);
} else {
processingFailed(cacheKey, "Processing failed");
}
}
// -------------------------------------------------
// ImageMagick Processing: Thumbnail
// -------------------------------------------------
function startThumbnailProcessing(sourcePath, outputPath, cacheKey) {
const srcEsc = sourcePath.replace(/'/g, "'\\''");
const dstEsc = outputPath.replace(/'/g, "'\\''");
const command = `magick -define jpeg:size=512x512 '${srcEsc}' -auto-orient -thumbnail '256x256^' -gravity center -extent 256x256 -quality 85 '${dstEsc}'`;
runProcess(command, cacheKey, outputPath, sourcePath);
}
// -------------------------------------------------
// ImageMagick Processing: Large
// -------------------------------------------------
function startLargeProcessing(sourcePath, outputPath, width, height, cacheKey) {
const srcEsc = sourcePath.replace(/'/g, "'\\''");
const dstEsc = outputPath.replace(/'/g, "'\\''");
const doubleWidth = width * 2;
const doubleHeight = height * 2;
const command = `magick -define jpeg:size=${doubleWidth}x${doubleHeight} '${srcEsc}' -auto-orient -thumbnail '${width}x${height}^' -quality 95 '${dstEsc}'`;
runProcess(command, cacheKey, outputPath, sourcePath);
}
// -------------------------------------------------
// ImageMagick Processing: Circular Avatar
// -------------------------------------------------
function downloadAndProcessAvatar(url, username, outputPath, cacheKey) {
const tempPath = contributorsDir + username + "_temp.png";
const tempEsc = tempPath.replace(/'/g, "'\\''");
const urlEsc = url.replace(/'/g, "'\\''");
// Download first
const downloadCmd = `curl -L -s -o '${tempEsc}' '${urlEsc}' || wget -q -O '${tempEsc}' '${urlEsc}'`;
const processString = `
import QtQuick
import Quickshell.Io
Process {
command: ["bash", "-c", ""]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
try {
const downloadProcess = Qt.createQmlObject(processString, root, "DownloadProcess_" + cacheKey);
downloadProcess.command = ["bash", "-c", downloadCmd];
downloadProcess.exited.connect(function(exitCode) {
downloadProcess.destroy();
if (exitCode !== 0) {
Logger.e("ImageCache", "Failed to download avatar for", username);
notifyCallbacks(cacheKey, "", false);
return;
}
// Now process with ImageMagick
processCircularAvatar(tempPath, outputPath, cacheKey);
});
downloadProcess.running = true;
} catch (e) {
Logger.e("ImageCache", "Failed to create download process:", e);
notifyCallbacks(cacheKey, "", false);
}
}
function processCircularAvatar(inputPath, outputPath, cacheKey) {
const srcEsc = inputPath.replace(/'/g, "'\\''");
const dstEsc = outputPath.replace(/'/g, "'\\''");
// ImageMagick command for circular crop with alpha
const command = `magick '${srcEsc}' -resize 256x256^ -gravity center -extent 256x256 -alpha set \\( +clone -channel A -evaluate set 0 +channel -fill white -draw 'circle 128,128 128,0' \\) -compose DstIn -composite '${dstEsc}'`;
const processString = `
import QtQuick
import Quickshell.Io
Process {
command: ["bash", "-c", ""]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
try {
const processObj = Qt.createQmlObject(processString, root, "CircularProcess_" + cacheKey);
processObj.command = ["bash", "-c", command];
processObj.exited.connect(function(exitCode) {
// Clean up temp file
Quickshell.execDetached(["rm", "-f", inputPath]);
if (exitCode !== 0) {
Logger.e("ImageCache", "Failed to create circular avatar");
notifyCallbacks(cacheKey, "", false);
} else {
Logger.d("ImageCache", "Circular avatar created:", outputPath);
notifyCallbacks(cacheKey, outputPath, true);
}
processObj.destroy();
});
processObj.running = true;
} catch (e) {
Logger.e("ImageCache", "Failed to create circular process:", e);
Quickshell.execDetached(["rm", "-f", inputPath]);
notifyCallbacks(cacheKey, "", false);
}
}
// -------------------------------------------------
// Generic Process Runner
// -------------------------------------------------
function runProcess(command, cacheKey, outputPath, sourcePath) {
const processString = `
import QtQuick
import Quickshell.Io
Process {
property string cacheKey: ""
property string cachedPath: ""
command: ["bash", "-c", ""]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
try {
const processObj = Qt.createQmlObject(processString, root, "ImageProcess_" + cacheKey);
processObj.cacheKey = cacheKey;
processObj.cachedPath = outputPath;
processObj.command = ["bash", "-c", command];
processObj.exited.connect(function(exitCode) {
if (exitCode !== 0) {
const stderrText = processObj.stderr.text || "";
Logger.e("ImageCache", "Processing failed:", stderrText);
notifyCallbacks(cacheKey, sourcePath, false);
} else {
Logger.d("ImageCache", "Processing complete:", outputPath);
notifyCallbacks(cacheKey, outputPath, true);
}
processObj.destroy();
});
processObj.running = true;
} catch (e) {
Logger.e("ImageCache", "Failed to create process:", e);
notifyCallbacks(cacheKey, sourcePath, false);
}
}
// -------------------------------------------------
// Qt Fallback Renderer
// -------------------------------------------------
PanelWindow {
id: fallbackRenderer
implicitWidth: 0
implicitHeight: 0
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "noctalia-image-cache-renderer"
color: Color.transparent
mask: Region {}
Image {
id: fallbackImage
property string cacheKey: ""
property string destPath: ""
property int targetSize: 256
width: targetSize
height: targetSize
visible: true
cache: false
asynchronous: true
fillMode: Image.PreserveAspectCrop
mipmap: true
antialiasing: true
onStatusChanged: {
if (!cacheKey) return;
if (status === Image.Ready) {
grabToImage(function(result) {
if (result.saveToFile(destPath)) {
Logger.d("ImageCache", "Fallback cache created:", destPath);
root.notifyCallbacks(cacheKey, destPath, true);
} else {
Logger.e("ImageCache", "Failed to save fallback cache");
root.notifyCallbacks(cacheKey, "", false);
}
processNextFallback();
});
} else if (status === Image.Error) {
Logger.e("ImageCache", "Fallback image load failed");
root.notifyCallbacks(cacheKey, "", false);
processNextFallback();
}
}
function processNextFallback() {
cacheKey = "";
destPath = "";
source = "";
if (fallbackQueue.length > 0) {
const next = fallbackQueue.shift();
cacheKey = next.cacheKey;
destPath = next.destPath;
targetSize = next.size;
source = next.sourcePath;
} else {
fallbackProcessing = false;
}
}
}
}
function queueFallbackProcessing(sourcePath, destPath, cacheKey, size) {
fallbackQueue.push({
sourcePath: sourcePath,
destPath: destPath,
cacheKey: cacheKey,
size: size
});
if (!fallbackProcessing) {
fallbackProcessing = true;
const item = fallbackQueue.shift();
fallbackImage.cacheKey = item.cacheKey;
fallbackImage.destPath = item.destPath;
fallbackImage.targetSize = item.size;
fallbackImage.source = item.sourcePath;
}
}
// -------------------------------------------------
// Utility Functions
// -------------------------------------------------
function getMtime(filePath, callback) {
const pathEsc = filePath.replace(/'/g, "'\\''");
const processString = `
import QtQuick
import Quickshell.Io
Process {
command: ["stat", "-c", "%Y", "${pathEsc}"]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
try {
const processObj = Qt.createQmlObject(processString, root, "MtimeProcess");
processObj.exited.connect(function(exitCode) {
const mtime = exitCode === 0 ? processObj.stdout.text.trim() : "";
processObj.destroy();
callback(mtime);
});
processObj.running = true;
} catch (e) {
Logger.e("ImageCache", "Failed to get mtime:", e);
callback("");
}
}
function checkFileExists(filePath, callback) {
const pathEsc = filePath.replace(/'/g, "'\\''");
const processString = `
import QtQuick
import Quickshell.Io
Process {
command: ["test", "-f", "${pathEsc}"]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
try {
const processObj = Qt.createQmlObject(processString, root, "FileExistsProcess");
processObj.exited.connect(function(exitCode) {
processObj.destroy();
callback(exitCode === 0);
});
processObj.running = true;
} catch (e) {
Logger.e("ImageCache", "Failed to check file:", e);
callback(false);
}
}
function getImageDimensions(filePath, callback) {
const pathEsc = filePath.replace(/'/g, "'\\''");
const processString = `
import QtQuick
import Quickshell.Io
Process {
command: ["identify", "-ping", "-format", "%w %h", "${pathEsc}[0]"]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
try {
const processObj = Qt.createQmlObject(processString, root, "IdentifyProcess");
processObj.exited.connect(function(exitCode) {
let width = 0, height = 0;
if (exitCode === 0) {
const parts = processObj.stdout.text.trim().split(" ");
if (parts.length >= 2) {
width = parseInt(parts[0], 10) || 0;
height = parseInt(parts[1], 10) || 0;
}
}
processObj.destroy();
callback(width, height);
});
processObj.running = true;
} catch (e) {
Logger.e("ImageCache", "Failed to get image dimensions:", e);
callback(0, 0);
}
}
// -------------------------------------------------
// Cache Invalidation
// -------------------------------------------------
function invalidateThumbnail(sourcePath) {
Logger.i("ImageCache", "Invalidating thumbnail for:", sourcePath);
// Since cache keys include hash, we'd need to track mappings
// For simplicity, clear all thumbnails
clearThumbnails();
}
function invalidateLarge(sourcePath, screenName) {
Logger.i("ImageCache", "Invalidating large for:", sourcePath);
clearLarge();
}
function invalidateNotification(imageId) {
const path = notificationsDir + imageId + ".png";
Quickshell.execDetached(["rm", "-f", path]);
}
function invalidateAvatar(username) {
const path = contributorsDir + username + "_circular.png";
Quickshell.execDetached(["rm", "-f", path]);
}
// -------------------------------------------------
// Clear Cache Functions
// -------------------------------------------------
function clearAll() {
Logger.i("ImageCache", "Clearing all cache");
clearThumbnails();
clearLarge();
clearNotifications();
clearContributors();
}
function clearThumbnails() {
Logger.i("ImageCache", "Clearing thumbnails cache");
Quickshell.execDetached(["rm", "-rf", wpThumbDir]);
Quickshell.execDetached(["mkdir", "-p", wpThumbDir]);
}
function clearLarge() {
Logger.i("ImageCache", "Clearing large cache");
Quickshell.execDetached(["rm", "-rf", wpLargeDir]);
Quickshell.execDetached(["mkdir", "-p", wpLargeDir]);
}
function clearNotifications() {
Logger.i("ImageCache", "Clearing notifications cache");
Quickshell.execDetached(["rm", "-rf", notificationsDir]);
Quickshell.execDetached(["mkdir", "-p", notificationsDir]);
}
function clearContributors() {
Logger.i("ImageCache", "Clearing contributors cache");
Quickshell.execDetached(["rm", "-rf", contributorsDir]);
Quickshell.execDetached(["mkdir", "-p", contributorsDir]);
}
// -------------------------------------------------
// ImageMagick Detection
// -------------------------------------------------
Process {
id: checkMagickProcess
command: ["which", "magick"]
running: false
stdout: StdioCollector {}
stderr: StdioCollector {}
onExited: function(exitCode) {
root.imageMagickAvailable = (exitCode === 0);
root.initialized = true;
if (root.imageMagickAvailable) {
Logger.i("ImageCache", "ImageMagick available");
} else {
Logger.w("ImageCache", "ImageMagick not found, using Qt fallback");
}
}
}
}
-337
View File
@@ -1,337 +0,0 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import "../../Helpers/sha256.js" as Checksum
import qs.Commons
Singleton {
id: root
readonly property string cacheDir: Settings.cacheDirImagesWallpapers + "preprocessed/"
property bool imageMagickAvailable: false
property bool initialized: false
// Track pending preprocessing operations
// key: cacheKey, value: { callbacks: [], sourcePath: string, screenName: string }
property var pendingRequests: ({})
// Signals
signal preprocessComplete(string cacheKey, string cachedPath, string screenName)
signal preprocessFailed(string cacheKey, string error, string screenName)
// -------------------------------------------------
function init() {
Logger.i("WallpaperCache", "Service started");
Quickshell.execDetached(["mkdir", "-p", cacheDir]);
checkMagickProcess.running = true;
}
// -------------------------------------------------
// Main API: Request preprocessed wallpaper
// callback signature: function(cachedPath: string, success: bool)
function getPreprocessed(sourcePath, screenName, width, height, callback) {
if (!sourcePath || sourcePath === "") {
callback("", false);
return;
}
if (!imageMagickAvailable) {
// Fallback: return original path
Logger.d("WallpaperCache", "ImageMagick not available, using original:", sourcePath);
callback(sourcePath, false);
return;
}
// Fast dimension check using identify -ping (reads only header, not pixels)
getImageDimensions(sourcePath, function (imgWidth, imgHeight) {
if (imgWidth > 0 && imgHeight > 0 && imgWidth <= width && imgHeight <= height) {
// Image is smaller than or equal to screen - no preprocessing needed
Logger.d("WallpaperCache", `Image ${imgWidth}x${imgHeight} <= screen ${width}x${height}, using original`);
callback(sourcePath, false);
return;
}
// Image is larger - proceed with preprocessing
proceedWithPreprocessing(sourcePath, screenName, width, height, callback);
});
}
// -------------------------------------------------
function proceedWithPreprocessing(sourcePath, screenName, width, height, callback) {
// Get mtime for cache invalidation
getMtime(sourcePath, function (mtime) {
const cacheKey = generateCacheKey(sourcePath, width, height, mtime);
const cachedPath = cacheDir + cacheKey + ".jpg";
// Check if already processing this exact request
if (pendingRequests[cacheKey]) {
pendingRequests[cacheKey].callbacks.push({
callback: callback,
screenName: screenName
});
Logger.d("WallpaperCache", "Coalescing request for:", cacheKey);
return;
}
// Check cache first
checkFileExists(cachedPath, function (exists) {
if (exists) {
Logger.d("WallpaperCache", "Cache hit:", cachedPath);
callback(cachedPath, true);
return;
}
// Re-check pendingRequests in case another request started processing
// while we were checking file existence (race condition fix)
if (pendingRequests[cacheKey]) {
pendingRequests[cacheKey].callbacks.push({
callback: callback,
screenName: screenName
});
Logger.d("WallpaperCache", "Coalescing request (late):", cacheKey);
return;
}
// Start new processing
Logger.d("WallpaperCache", `Preprocessing ${sourcePath} to ${width}x${height}`);
pendingRequests[cacheKey] = {
callbacks: [
{
callback: callback,
screenName: screenName
}
],
sourcePath: sourcePath
};
startPreprocessing(sourcePath, cachedPath, width, height, cacheKey, screenName);
});
});
}
// -------------------------------------------------
function generateCacheKey(sourcePath, width, height, mtime) {
const keyString = sourcePath + "@" + width + "x" + height + "@" + (mtime || "unknown");
return Checksum.sha256(keyString);
}
// -------------------------------------------------
function buildCommand(sourcePath, outputPath, width, height) {
// Escape paths for shell
const srcEsc = sourcePath.replace(/'/g, "'\\''");
const dstEsc = outputPath.replace(/'/g, "'\\''");
// Use -define jpeg:size for faster JPEG loading (decoder hint)
// -thumbnail is faster than -resize (strips metadata, uses faster algorithms)
// The ^ ensures image covers target (smaller dimension fits exactly)
// -auto-orient applies EXIF orientation before processing
// Small images are filtered out before this function is called
// The shader will handle actual fill mode (crop/fit/center/stretch)
const doubleWidth = width * 2;
const doubleHeight = height * 2;
return `convert -define jpeg:size=${doubleWidth}x${doubleHeight} '${srcEsc}' -auto-orient -thumbnail '${width}x${height}^' -quality 95 '${dstEsc}'`;
}
// -------------------------------------------------
function startPreprocessing(sourcePath, outputPath, width, height, cacheKey, screenName) {
const command = buildCommand(sourcePath, outputPath, width, height);
// Create dynamic process for this request
const processString = `
import QtQuick
import Quickshell.Io
Process {
property string cacheKey: ""
property string cachedPath: ""
property string screenName: ""
command: ["bash", "-c", ""]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
try {
const processObj = Qt.createQmlObject(processString, root, "PreprocessProcess_" + cacheKey);
processObj.cacheKey = cacheKey;
processObj.cachedPath = outputPath;
processObj.screenName = screenName;
processObj.command = ["bash", "-c", command];
processObj.exited.connect(function (exitCode) {
handleProcessComplete(processObj, exitCode);
});
processObj.running = true;
} catch (e) {
Logger.e("WallpaperCache", "Failed to create process:", e);
notifyCallbacks(cacheKey, sourcePath, false);
}
}
// -------------------------------------------------
function handleProcessComplete(processObj, exitCode) {
const cacheKey = processObj.cacheKey;
const cachedPath = processObj.cachedPath;
const screenName = processObj.screenName;
const stderrText = processObj.stderr.text || "";
if (exitCode !== 0) {
Logger.e("WallpaperCache", "Preprocessing failed:", stderrText);
const sourcePath = pendingRequests[cacheKey] ? pendingRequests[cacheKey].sourcePath : "";
notifyCallbacks(cacheKey, sourcePath, false);
preprocessFailed(cacheKey, stderrText, screenName);
} else {
Logger.d("WallpaperCache", "Preprocessing complete:", cachedPath);
notifyCallbacks(cacheKey, cachedPath, true);
preprocessComplete(cacheKey, cachedPath, screenName);
}
// Clean up
processObj.destroy();
}
// -------------------------------------------------
function notifyCallbacks(cacheKey, path, success) {
const request = pendingRequests[cacheKey];
if (request) {
request.callbacks.forEach(function (item) {
item.callback(path, success);
});
delete pendingRequests[cacheKey];
}
}
// -------------------------------------------------
function getMtime(filePath, callback) {
const pathEsc = filePath.replace(/'/g, "'\\''");
const processString = `
import QtQuick
import Quickshell.Io
Process {
command: ["stat", "-c", "%Y", "${pathEsc}"]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
try {
const processObj = Qt.createQmlObject(processString, root, "MtimeProcess");
processObj.exited.connect(function (exitCode) {
const mtime = exitCode === 0 ? processObj.stdout.text.trim() : "";
processObj.destroy();
callback(mtime);
});
processObj.running = true;
} catch (e) {
Logger.e("WallpaperCache", "Failed to get mtime:", e);
callback("");
}
}
// -------------------------------------------------
function checkFileExists(filePath, callback) {
const pathEsc = filePath.replace(/'/g, "'\\''");
const processString = `
import QtQuick
import Quickshell.Io
Process {
command: ["test", "-f", "${pathEsc}"]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
try {
const processObj = Qt.createQmlObject(processString, root, "FileExistsProcess");
processObj.exited.connect(function (exitCode) {
processObj.destroy();
callback(exitCode === 0);
});
processObj.running = true;
} catch (e) {
Logger.e("WallpaperCache", "Failed to check file:", e);
callback(false);
}
}
// -------------------------------------------------
// Fast image dimension check using identify -ping (reads only header, not pixel data)
function getImageDimensions(filePath, callback) {
const pathEsc = filePath.replace(/'/g, "'\\''");
const processString = `
import QtQuick
import Quickshell.Io
Process {
command: ["identify", "-ping", "-format", "%w %h", "${pathEsc}[0]"]
stdout: StdioCollector {}
stderr: StdioCollector {}
}
`;
try {
const processObj = Qt.createQmlObject(processString, root, "IdentifyProcess");
processObj.exited.connect(function (exitCode) {
let width = 0, height = 0;
if (exitCode === 0) {
const parts = processObj.stdout.text.trim().split(" ");
if (parts.length >= 2) {
width = parseInt(parts[0], 10) || 0;
height = parseInt(parts[1], 10) || 0;
}
}
processObj.destroy();
callback(width, height);
});
processObj.running = true;
} catch (e) {
Logger.e("WallpaperCache", "Failed to get image dimensions:", e);
callback(0, 0);
}
}
// -------------------------------------------------
// Utility: Clear cache for a specific source
function invalidateForSource(sourcePath) {
// Since cache keys include the source path hash, we'd need to track mappings
// For simplicity, this clears the entire cache
Logger.i("WallpaperCache", "Invalidating cache for:", sourcePath);
clearAllCache();
}
// -------------------------------------------------
// Utility: Clear entire cache
function clearAllCache() {
Logger.i("WallpaperCache", "Clearing all cache");
Quickshell.execDetached(["rm", "-rf", cacheDir]);
Quickshell.execDetached(["mkdir", "-p", cacheDir]);
}
// -------------------------------------------------
// Check if ImageMagick is available
Process {
id: checkMagickProcess
command: ["which", "convert"]
running: false
stdout: StdioCollector {}
stderr: StdioCollector {}
onExited: function (exitCode) {
root.imageMagickAvailable = (exitCode === 0);
root.initialized = true;
if (root.imageMagickAvailable) {
Logger.i("WallpaperCache", "ImageMagick available");
} else {
Logger.w("WallpaperCache", "ImageMagick not found, caching disabled");
}
}
}
}
+2 -1
View File
@@ -5,6 +5,7 @@ import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Services.UI
Singleton {
id: root
@@ -551,7 +552,7 @@ Singleton {
property string currentDirectory: root.getMonitorDirectory(screenName)
folder: "file://" + currentDirectory
nameFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.pnm", "*.bmp"]
nameFilters: ImageCacheService.imageFilters
showDirs: false
sortField: FolderListModel.Name
+18 -30
View File
@@ -1,50 +1,38 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "../Helpers/sha256.js" as Checksum
import qs.Commons
import qs.Services.UI
Image {
id: root
property string imagePath: ""
property string imageHash: ""
property string cacheFolder: Settings.cacheDirImages
property int maxCacheDimension: 384
readonly property string cachePath: imageHash ? `${cacheFolder}${imageHash}@${maxCacheDimension}x${maxCacheDimension}.png` : ""
property int maxCacheDimension: 256
asynchronous: true
fillMode: Image.PreserveAspectCrop
sourceSize.width: maxCacheDimension
sourceSize.height: maxCacheDimension
smooth: true
onImagePathChanged: {
if (imagePath) {
imageHash = Checksum.sha256(imagePath);
// Logger.i("NImageCached", imagePath, imageHash)
} else {
if (!imagePath) {
source = "";
imageHash = "";
return;
}
}
onCachePathChanged: {
if (imageHash && cachePath) {
// Try to load the cached version, failure will be detected below in onStatusChanged
// Failure is expected and warnings are ok in the console. Don't try to improve without consulting.
source = cachePath;
}
}
onStatusChanged: {
if (source == cachePath && status === Image.Error) {
// Cached image was not available, show the original
if (!ImageCacheService.initialized) {
// Service not ready yet, use original
source = imagePath;
} else if (source == imagePath && status === Image.Ready && imageHash && cachePath) {
// Original image is shown and fully loaded, time to cache it
const grabPath = cachePath;
if (visible && width > 0 && height > 0 && Window.window && Window.window.visible)
grabToImage(res => {
return res.saveToFile(grabPath);
});
return;
}
ImageCacheService.getThumbnail(imagePath, function(cachedPath, success) {
if (!root) return; // Component was destroyed
if (success) {
root.source = cachedPath;
} else {
root.source = imagePath;
}
});
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ ShellRoot {
Component.onCompleted: {
Logger.i("Shell", "---------------------------");
WallpaperService.init();
WallpaperCacheService.init();
ImageCacheService.init();
AppThemeService.init();
ColorSchemeService.init();
LocationService.init();