From 77e0f5da3255f3a4e5441cd5a8fc2fd32a0df0a9 Mon Sep 17 00:00:00 2001 From: moWerk Date: Tue, 31 Mar 2026 20:15:48 +0200 Subject: [PATCH 1/4] Add WatchfaceHelper singleton for user-space watchface management Introduces WatchfaceHelper as a QML singleton registered under org.asteroid.settings 1.0. Provides network-backed file downloads via QNetworkAccessManager, directory creation, watchface removal, font cache rebuilding and launcher restart. All filesystem writes are enforced against a whitelist of user-writable paths mirroring what the ./watchface deploy script writes: the XDG GenericDataLocation asteroid-launcher subtree, ~/.fonts/ and the QStandardPaths cache location. Any write outside these paths is blocked and logged. readFile() is additionally restricted to the cache location only, preventing arbitrary filesystem reads from QML. Path helpers userWatchfacePath(), userAssetPath(), userFontsPath() and cachePath() expose the resolved paths to QML consumers. restartSession() runs fc-cache -f synchronously before issuing systemctl --user restart asteroid-launcher so newly installed fonts are visible immediately after the compositor restarts. Adds Qt5::Network to CMakeLists target_link_libraries. --- src/CMakeLists.txt | 7 +- src/WatchfaceHelper.cpp | 214 ++++++++++++++++++++++++++++++++++++++++ src/WatchfaceHelper.h | 113 +++++++++++++++++++++ src/main.cpp | 3 + 4 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 src/WatchfaceHelper.cpp create mode 100644 src/WatchfaceHelper.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4ccaf5ed..572ec898 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -14,12 +14,14 @@ set(SRC sysinfo.cpp taptowake.cpp tilttowake.cpp - volumecontrol.cpp) + volumecontrol.cpp + WatchfaceHelper.cpp) set(HEADERS sysinfo.h taptowake.h tilttowake.h - volumecontrol.h) + volumecontrol.h + WatchfaceHelper.h) add_library(asteroid-settings ${SRC} ${HEADERS} resources.qrc ${CMAKE_CURRENT_BINARY_DIR}/mceiface.h @@ -34,6 +36,7 @@ target_link_libraries(asteroid-settings PRIVATE Qt5::Quick Qt5::DBus Qt5::Multimedia + Qt5::Network AsteroidApp) install(TARGETS asteroid-settings diff --git a/src/WatchfaceHelper.cpp b/src/WatchfaceHelper.cpp new file mode 100644 index 00000000..f92b6493 --- /dev/null +++ b/src/WatchfaceHelper.cpp @@ -0,0 +1,214 @@ +/* + * Copyright (C) 2026 - Timo Könnecke + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "WatchfaceHelper.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +static const QStringList PREVIEW_SIZES = { + QStringLiteral("112"), QStringLiteral("128"), + QStringLiteral("144"), QStringLiteral("160"), QStringLiteral("182") +}; + +WatchfaceHelper *WatchfaceHelper::s_instance = nullptr; + +WatchfaceHelper::WatchfaceHelper(QObject *parent) +: QObject(parent) +, m_nam(new QNetworkAccessManager(this)) +{ + s_instance = this; + // Ensure user watchface and cache directories exist on first run + QDir().mkpath(userWatchfacePath()); + QDir().mkpath(cachePath()); +} + +WatchfaceHelper *WatchfaceHelper::instance() +{ + if (!s_instance) + s_instance = new WatchfaceHelper(); + return s_instance; +} + +QObject *WatchfaceHelper::qmlInstance(QQmlEngine *, QJSEngine *) +{ + return instance(); +} + +// ── Path helpers ────────────────────────────────────────────────────────────── + +QString WatchfaceHelper::userDataPath() const +{ + return QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + + QStringLiteral("/asteroid-launcher/"); +} + +QString WatchfaceHelper::userWatchfacePath() const +{ + return userDataPath() + QStringLiteral("watchfaces/"); +} + +QString WatchfaceHelper::userAssetPath() const +{ + return QStringLiteral("file://") + userDataPath(); +} + +QString WatchfaceHelper::userFontsPath() const +{ + return QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + + QStringLiteral("/.fonts/"); +} + +QString WatchfaceHelper::cachePath() const +{ + return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + + QStringLiteral("/watchface-store/"); +} + +bool WatchfaceHelper::isPathAllowed(const QString &path) const +{ + if (path.startsWith(cachePath())) return true; + if (path.startsWith(userDataPath())) return true; + if (path.startsWith(userFontsPath())) return true; + return false; +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +void WatchfaceHelper::downloadFile(const QString &url, const QString &destPath) +{ + if (!isPathAllowed(destPath)) { + qWarning() << "WatchfaceHelper: blocked write attempt to" << destPath; + emit downloadComplete(destPath, false); + return; + } + + QUrl qurl(url); + QNetworkRequest req(qurl); + req.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + QNetworkReply *reply = m_nam->get(req); + + connect(reply, &QNetworkReply::downloadProgress, + this, [this, destPath](qint64 recv, qint64 total) { + emit downloadProgress(destPath, recv, total); + }); + + connect(reply, &QNetworkReply::finished, + this, [this, reply, destPath]() { + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + qWarning() << "WatchfaceHelper: download error for" + << destPath << ":" << reply->errorString(); + emit downloadComplete(destPath, false); + return; + } + + const QFileInfo fi(destPath); + if (!QDir().mkpath(fi.absolutePath())) { + qWarning() << "WatchfaceHelper: cannot create directory" + << fi.absolutePath(); + emit downloadComplete(destPath, false); + return; + } + + QFile file(destPath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + qWarning() << "WatchfaceHelper: cannot open for writing:" << destPath; + emit downloadComplete(destPath, false); + return; + } + + file.write(reply->readAll()); + file.close(); + emit downloadComplete(destPath, true); + }); +} + +bool WatchfaceHelper::mkpath(const QString &dirPath) +{ + return QDir().mkpath(dirPath); +} + +bool WatchfaceHelper::removeWatchface(const QString &name) +{ + bool removedQml = false; + + const QString qmlPath = userWatchfacePath() + name + QStringLiteral(".qml"); + if (QFile::exists(qmlPath)) + removedQml = QFile::remove(qmlPath); + + for (const QString &size : PREVIEW_SIZES) { + const QString p = userDataPath() + + QStringLiteral("watchfaces-preview/") + + size + QStringLiteral("/") + name + QStringLiteral(".png"); + if (QFile::exists(p)) QFile::remove(p); + } + + QDir imgDir(userDataPath() + QStringLiteral("watchface-img/")); + if (imgDir.exists()) { + const QStringList filters = { + name + QStringLiteral("-*"), + name + QStringLiteral(".*") + }; + for (const QString &f : imgDir.entryList(filters, QDir::Files)) + imgDir.remove(f); + } + + return removedQml; +} + +void WatchfaceHelper::restartSession() +{ + QProcess::execute(QStringLiteral("fc-cache"), {QStringLiteral("-f")}); + QProcess::startDetached(QStringLiteral("systemctl"), + {QStringLiteral("--user"), QStringLiteral("restart"), QStringLiteral("asteroid-launcher")}); +} + +QString WatchfaceHelper::readFile(const QString &path) const +{ + if (!path.startsWith(cachePath())) { + qWarning() << "WatchfaceHelper: blocked read attempt from" << path; + return QString(); + } + QFile f(path); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) + return QString(); + return QString::fromUtf8(f.readAll()); +} + +bool WatchfaceHelper::writeFile(const QString &path, const QString &content) +{ + if (!isPathAllowed(path)) { + qWarning() << "WatchfaceHelper: blocked write attempt to" << path; + return false; + } + const QFileInfo fi(path); + QDir().mkpath(fi.absolutePath()); + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) + return false; + f.write(content.toUtf8()); + return true; +} diff --git a/src/WatchfaceHelper.h b/src/WatchfaceHelper.h new file mode 100644 index 00000000..e32552b2 --- /dev/null +++ b/src/WatchfaceHelper.h @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2026 - Timo Könnecke + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef WATCHFACEHELPER_H +#define WATCHFACEHELPER_H + +#include +#include +#include +#include + +class WatchfaceHelper : public QObject +{ + Q_OBJECT + +public: + explicit WatchfaceHelper(QObject *parent = nullptr); + static WatchfaceHelper *instance(); + static QObject *qmlInstance(QQmlEngine *engine, QJSEngine *scriptEngine); + + /*! + * \brief Download a remote URL and write to destPath. + * destPath must be within an allowed user-writable path — blocked otherwise. + */ + Q_INVOKABLE void downloadFile(const QString &url, const QString &destPath); + + /*! + * \brief Remove all user-folder files belonging to a community watchface. + */ + Q_INVOKABLE bool removeWatchface(const QString &name); + + /*! + * \brief Create a directory path recursively (mkdir -p equivalent). + */ + Q_INVOKABLE bool mkpath(const QString &dirPath); + + /*! + * \brief Rebuild the fontconfig user cache after font install. + */ + Q_INVOKABLE void restartSession(); + + /*! + * \brief Base path for cached watchface store thumbnails. + * Returns QStandardPaths::CacheLocation + "/watchface-store/" + */ + Q_INVOKABLE QString cachePath() const; + + /*! + * \brief User-writable watchface QML directory. + * Returns ~/.local/share/asteroid-launcher/watchfaces/ + */ + Q_INVOKABLE QString userWatchfacePath() const; + + /*! + * \brief User-writable asteroid-launcher data root as file:// URL. + * Returns file://~/.local/share/asteroid-launcher/ + */ + Q_INVOKABLE QString userAssetPath() const; + + /*! + * \brief User-writable fonts directory. + * Returns ~/.fonts/ + */ + Q_INVOKABLE QString userFontsPath() const; + + /*! + * \brief Read a file from the cache location and return its contents. + * Only files within cachePath() are readable — all other paths are blocked. + */ + Q_INVOKABLE QString readFile(const QString &path) const; + + /*! + * \brief Write content to a file at destPath. + * destPath must be within an allowed user-writable path — blocked otherwise. + */ + Q_INVOKABLE bool writeFile(const QString &path, const QString &content); + +signals: + /*! + * \brief Emitted when a downloadFile() call completes. + * \param destPath the destination path originally requested + * \param success true if the file was written successfully + */ + void downloadComplete(const QString &destPath, bool success); + + /*! + * \brief Emitted periodically during a download for progress tracking. + */ + void downloadProgress(const QString &destPath, qint64 received, qint64 total); + +private: + bool isPathAllowed(const QString &path) const; + QString userDataPath() const; + + QNetworkAccessManager *m_nam; + static WatchfaceHelper *s_instance; +}; + +#endif // WATCHFACEHELPER_H diff --git a/src/main.cpp b/src/main.cpp index c73b3889..678a6817 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -27,6 +27,7 @@ #include "tilttowake.h" #include "taptowake.h" #include "sysinfo.h" +#include "WatchfaceHelper.h" int main(int argc, char *argv[]) { @@ -38,6 +39,8 @@ int main(int argc, char *argv[]) qmlRegisterType("org.asteroid.settings", 1, 0, "TiltToWake"); qmlRegisterType("org.asteroid.settings", 1, 0, "TapToWake"); qmlRegisterType("org.asteroid.settings", 1, 0, "SysInfo"); + qmlRegisterSingletonType("org.asteroid.settings", 1, 0, "WatchfaceHelper", + WatchfaceHelper::qmlInstance); view->setSource(QUrl("qrc:/qml/main.qml")); view->rootContext()->setContextProperty("qtVersion", QString(qVersion())); view->rootContext()->setContextProperty("kernelVersion", QString(buf.release)); From 128b2de0e6ff101dc304b9d2591cf32a0f4ac06a Mon Sep 17 00:00:00 2001 From: moWerk Date: Tue, 31 Mar 2026 20:16:34 +0200 Subject: [PATCH 2/4] Add WatchfaceStorePage and extend WatchfaceSelector with store access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WatchfaceSelector gains a second FolderListModel watching the XDG user watchface path alongside the system path. Both are merged into a unified ListModel via a debounced Timer to prevent double-clear during simultaneous count changes. A HEAD probe to api.github.com fires on Component.onCompleted and gates a persistent store footer button that is greyed out with an alternative label when offline. WatchfaceStorePage fetches the unofficial-watchfaces repo catalog via the GitHub Contents API and caches the response JSON to disk. Subsequent page opens load from cache and only hit the network on explicit refresh. Thumbnail previews are downloaded lazily at the device-appropriate resolution and cached permanently. Watchfaces whose preview returns a 404 are pruned from the model silently. Directory listings recurse into subdirectories to handle watchfaces with nested asset structures. Installs land in the XDG user data tree so no root access is required. The QML file, single-resolution preview PNG, watchfaces-img assets and fonts are fetched in parallel. The downloaded watchface is auto-activated on install complete. A persistent footer offers a RemorseTimer-guarded launcher restart to pick up new fonts and QML paths — the page fades to black when the remorse timer fires and holds until the process is killed, preventing a flash back to page content. Interaction model on both pages uses manual onPressed tracking with a Dims.l(2) scroll detection threshold, matching the IntSelector scrubber pattern. Press immediately starts an opacity animation giving frame-one feedback. Scroll cancels and reverses the animation. Release within threshold confirms the action. Long press triggers deletion via RemorseTimer on both pages. Install state is conveyed through color and opacity transitions on a per-tile circle with no discrete timer: green animates in from press, bumps on completion, flashes red on network failure. The selector page uses the same circle in black to indicate the active watchface at opacity 0.2 with the press animation fading toward 0.5 over the full longpress budget as a continuous gesture discriminator. --- src/qml/WatchfaceSelector.qml | 384 +++++++++++++++----- src/qml/WatchfaceStorePage.qml | 636 +++++++++++++++++++++++++++++++++ src/resources.qrc | 1 + 3 files changed, 934 insertions(+), 87 deletions(-) create mode 100644 src/qml/WatchfaceStorePage.qml diff --git a/src/qml/WatchfaceSelector.qml b/src/qml/WatchfaceSelector.qml index 1b56d5be..f5da3b1f 100644 --- a/src/qml/WatchfaceSelector.qml +++ b/src/qml/WatchfaceSelector.qml @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 - Timo Könnecke + * Copyright (C) 2026 - Timo Könnecke * 2022 - Darrel Griët * 2015 - Florent Revest * @@ -20,24 +20,26 @@ import QtQuick 2.9 import QtGraphicalEffects 1.12 import Qt.labs.folderlistmodel 2.1 +import Nemo.Configuration 1.0 import org.asteroid.controls 1.0 import org.asteroid.utils 1.0 -import Nemo.Configuration 1.0 +import org.asteroid.settings 1.0 import Nemo.Time 1.0 Item { id: watchfaceSelector + property bool storeAvailable: false + property string deletingName: "" + readonly property var previewSizes: [112, 128, 144, 160, 182] readonly property int idealPreviewSize: Math.round(Dims.w(40)) readonly property int previewSize: { let best = previewSizes[0]; let minDiff = Math.abs(best - idealPreviewSize); - for (let i = 1, n = previewSizes.length; i < n; ++i) { const size = previewSizes[i]; const diff = Math.abs(size - idealPreviewSize); - if (diff < minDiff || (diff === minDiff && size > best)) { minDiff = diff; best = size; @@ -46,154 +48,318 @@ Item { return best; } - GridView { - id: grid - cellWidth: Dims.w(50) - cellHeight: Dims.h(40) - anchors.fill: parent + ConfigurationValue { + id: activeWatchface + key: "/desktop/asteroid/watchface" + defaultValue: "file:///usr/share/asteroid-launcher/watchfaces/000-default-digital.qml" + } - model: FolderListModel { - id: folderModel - folder: assetPath + "watchfaces" - nameFilters: ["*.qml"] - onCountChanged: { - var i = 0 - while (i < folderModel.count){ - var fileName = folderModel.get(i, "fileName") - if(watchface === folderModel.folder + "/" + fileName) - grid.positionViewAtIndex(i, GridView.Center) - - i = i+1 - } - } + // ── Network probe + + Component.onCompleted: probeConnection() + + function probeConnection() { + var xhr = new XMLHttpRequest() + xhr.open("HEAD", "https://api.github.com") + xhr.timeout = 6000 + xhr.onreadystatechange = function() { + if (xhr.readyState !== XMLHttpRequest.DONE) return + watchfaceSelector.storeAvailable = (xhr.status > 0 && xhr.status < 500) } + xhr.send() + } - Item { - id: burnInProtectionManager + // ── Watchface store component - property int leftOffset - property int rightOffset - property int topOffset - property int bottomOffset - property int widthOffset - property int heightOffset - } + Component { + id: watchfaceStoreComponent + WatchfaceStorePage {} + } + + // ── Unified model from system + user watchface folders + + ListModel { id: unifiedModel } + + FolderListModel { + id: folderModel + folder: assetPath + "watchfaces" + nameFilters: ["*.qml"] + onCountChanged: rebuildTimer.restart() + } - WallClock { - id: wallClock - enabled: true - updateFrequency: WallClock.Second + FolderListModel { + id: userFolderModel + folder: "file://" + WatchfaceHelper.userWatchfacePath() + nameFilters: ["*.qml"] + onCountChanged: rebuildTimer.restart() + } + + Timer { + id: rebuildTimer + interval: 0 + repeat: false + onTriggered: _rebuildUnified() + } + + function _rebuildUnified() { + unifiedModel.clear() + var i, fn + for (i = 0; i < folderModel.count; i++) { + fn = folderModel.get(i, "fileName") + unifiedModel.append({ fileName: fn, filePath: assetPath + "watchfaces/" + fn, isUser: false }) + } + for (i = 0; i < userFolderModel.count; i++) { + fn = userFolderModel.get(i, "fileName") + unifiedModel.append({ fileName: fn, filePath: "file://" + WatchfaceHelper.userWatchfacePath() + fn, isUser: true }) } + for (i = 0; i < unifiedModel.count; i++) { + if (watchface === unifiedModel.get(i).filePath) { + grid.positionViewAtIndex(i, GridView.Center) + break + } + } + } + + // ── Removal remorse timer + +RemorseTimer { + id: deleteRemorse - QtObject { - id: localeManager - property string changesObserver: "" + property string watchfaceName: "" + + duration: 3000 + gaugeSegmentAmount: 8 + gaugeStartDegree: -130 + gaugeEndFromStartDegree: 265 + //% "Tap to cancel" + cancelText: qsTrId("id-tap-to-cancel") + + onTriggered: { + var targetPath = WatchfaceHelper.userAssetPath() + "watchfaces/" + watchfaceName + ".qml" + if (activeWatchface.value === targetPath) + activeWatchface.value = activeWatchface.defaultValue + WatchfaceHelper.removeWatchface(watchfaceName) + watchfaceSelector.deletingName = "" } - delegate: Component { + onCancelled: watchfaceSelector.deletingName = "" + } + + // ── Watchface grid + + GridView { + id: grid + cellWidth: Dims.w(50) + cellHeight: Dims.h(45) + anchors.fill: parent + model: unifiedModel + + Item { id: burnInProtectionManager; property int leftOffset; property int rightOffset; property int topOffset; property int bottomOffset; property int widthOffset; property int heightOffset } + WallClock { id: wallClock; enabled: true; updateFrequency: WallClock.Second } + QtObject { id: localeManager; property string changesObserver: "" } + + delegate: Component { Item { width: grid.cellWidth height: grid.cellHeight - + + property bool _pressActive: false + property bool _scrollCancelled: false + property bool _wasDeleting: false + readonly property bool isActive: watchface === model.filePath + + Rectangle { + id: pressCircle + width: Dims.l(40) + height: width + radius: width + anchors.centerIn: parent + color: "#000000" + opacity: isActive ? 0.2 : 0.0 + + NumberAnimation { id: pressAnim; target: pressCircle; property: "opacity"; to: 0.5; duration: 800; easing.type: Easing.Linear } + NumberAnimation { id: releaseAnim; target: pressCircle; property: "opacity"; duration: 150; easing.type: Easing.OutQuad } + } + + onIsActiveChanged: { + if (_pressActive) return + pressAnim.stop() + releaseAnim.stop() + releaseAnim.from = pressCircle.opacity + releaseAnim.to = isActive ? 0.2 : 0.0 + releaseAnim.start() + } + + Connections { + target: watchfaceSelector + function onDeletingNameChanged() { + var thisName = model.fileName.slice(0, -4) + if (watchfaceSelector.deletingName === thisName) { + _wasDeleting = true + } else if (_wasDeleting) { + _wasDeleting = false + pressAnim.stop() + releaseAnim.from = pressCircle.opacity + releaseAnim.to = isActive ? 0.2 : 0.0 + releaseAnim.start() + } + } + } + + Timer { + id: selectorHoldTimer + interval: 800 + repeat: false + onTriggered: { + if (!model.isUser) { + pressAnim.stop() + releaseAnim.from = pressCircle.opacity + releaseAnim.to = isActive ? 0.2 : 0.0 + releaseAnim.start() + return + } + watchfaceSelector.deletingName = model.fileName.slice(0, -4) + deleteRemorse.watchfaceName = model.fileName.slice(0, -4) + //% "Remove" + deleteRemorse.action = qsTrId("id-remove") + " " + model.fileName.slice(0, -4) + deleteRemorse.start() + } + } + Rectangle { id: maskArea - - width: Dims.w(40) - height: grid.cellHeight + width: Dims.l(40) + height: width anchors.centerIn: parent color: "transparent" - radius: DeviceSpecs.hasRoundScreen ? - width : - Dims.w(3) + radius: DeviceSpecs.hasRoundScreen ? width : Dims.l(3) clip: true - + Image { id: previewPng - - readonly property string previewFolder: `${assetPath}watchfaces-preview/${previewSize}/` - readonly property string previewImg: `${previewFolder}${fileName.slice(0, -4)}.png` - property bool previewExists: FileInfo.exists(previewImg) - + readonly property string sysPreviewImg: assetPath + "watchfaces-preview/" + previewSize + "/" + model.fileName.slice(0, -4) + ".png" + readonly property string userPreviewImg: WatchfaceHelper.userAssetPath() + "watchfaces-preview/" + previewSize + "/" + model.fileName.slice(0, -4) + ".png" + readonly property string cachePreviewImg: "file://" + WatchfaceHelper.cachePath() + previewSize + "/" + model.fileName.slice(0, -4) + ".png" + readonly property string previewImg: FileInfo.exists(sysPreviewImg) ? sysPreviewImg : FileInfo.exists(userPreviewImg) ? userPreviewImg : cachePreviewImg + property bool previewExists: FileInfo.exists(sysPreviewImg) || FileInfo.exists(userPreviewImg) || FileInfo.exists(cachePreviewImg) z: 1 anchors.centerIn: parent width: Math.min(parent.width, parent.height) height: width - source: !previewExists ? "" : previewImg + source: previewExists ? previewImg : "" asynchronous: true fillMode: Image.PreserveAspectFit mipmap: true } - + Loader { id: previewQml - z: 2 visible: !previewPng.previewExists active: visible anchors.centerIn: parent width: Math.min(parent.width, parent.height) height: width - source: folderModel.folder + "/" + fileName + source: model.filePath asynchronous: true } - + MouseArea { anchors.fill: parent - onClicked: watchface = folderModel.folder + "/" + fileName + property real startX: 0 + property real startY: 0 + + onPressed: { + startX = mouse.x + startY = mouse.y + _scrollCancelled = false + _pressActive = true + pressAnim.stop() + releaseAnim.stop() + pressAnim.from = pressCircle.opacity + pressAnim.start() + selectorHoldTimer.restart() + } + + onPositionChanged: { + if (_scrollCancelled) return + var dx = Math.abs(mouse.x - startX) + var dy = Math.abs(mouse.y - startY) + if (dx > Dims.l(2) || dy > Dims.l(2)) { + _scrollCancelled = true + _pressActive = false + selectorHoldTimer.stop() + pressAnim.stop() + releaseAnim.from = pressCircle.opacity + releaseAnim.to = isActive ? 0.2 : 0.0 + releaseAnim.start() + mouse.accepted = false + } + } + + onReleased: { + if (_scrollCancelled) return + selectorHoldTimer.stop() + pressAnim.stop() + if (watchfaceSelector.deletingName === "") + watchface = model.filePath + releaseAnim.from = pressCircle.opacity + releaseAnim.to = watchface === model.filePath ? 0.2 : 0.0 + releaseAnim.start() + _pressActive = false + _scrollCancelled = false + } + + onCanceled: { + selectorHoldTimer.stop() + pressAnim.stop() + _pressActive = false + _scrollCancelled = false + releaseAnim.from = pressCircle.opacity + releaseAnim.to = isActive ? 0.2 : 0.0 + releaseAnim.start() + } } - + Image { id: wallpaperBack - property string previewSizePath: "wallpapers/" + Dims.w(50) property string wallpaperPreviewImg: wallpaperSource.value.replace("\\wallpapers/full\\", previewSizePath).slice(0, -3) + "jpg" - z: 0 anchors.fill: parent fillMode: Image.PreserveAspectFit visible: opacity - opacity: watchface === folderModel.folder + "/" + fileName ? 1 : 0 - source: opacity > 0 ? FileInfo.exists(wallpaperPreviewImg) ? - wallpaperPreviewImg : - wallpaperSource.value : "" + opacity: watchface === model.filePath ? 1 : 0 + source: opacity > 0 ? FileInfo.exists(wallpaperPreviewImg) ? wallpaperPreviewImg : wallpaperSource.value : "" Behavior on opacity { NumberAnimation { duration: 100 } } } - + layer.enabled: true layer.effect: OpacityMask { - maskSource: - Rectangle { - anchors.centerIn: parent - width: Math.min(wallpaperBack.width, wallpaperBack.height) - height: width - radius: maskArea.radius - } + maskSource: Rectangle { + anchors.centerIn: parent + width: Math.min(wallpaperBack.width, wallpaperBack.height) + height: width + radius: maskArea.radius + } } } - + Icon { name: "ios-checkmark-circle" - z: 100 width: parent.width * .3 height: width - visible: watchface === folderModel.folder + "/" + fileName + visible: watchface === model.filePath anchors { bottom: parent.bottom - bottomMargin: DeviceSpecs.hasRoundScreen ? - -parent.height * .03 : - -parent.height * .08 + bottomMargin: DeviceSpecs.hasRoundScreen ? -parent.height * .03 : -parent.height * .08 horizontalCenter: parent.horizontalCenter horizontalCenterOffset: index % 2 ? - DeviceSpecs.hasRoundScreen ? - -parent.height * .45 : - -parent.height * .40 : - DeviceSpecs.hasRoundScreen ? - parent.height * .45 : - parent.height * .40 + (DeviceSpecs.hasRoundScreen ? -parent.height * .45 : -parent.height * .40) : + (DeviceSpecs.hasRoundScreen ? parent.height * .45 : parent.height * .40) } - layer.enabled: visible layer.effect: DropShadow { transparentBorder: true @@ -206,5 +372,49 @@ Item { } } } + + // ── "Get More" footer + + footer: Column { + width: grid.width + + Item { width: parent.width; height: Dims.l(4) } + + RowSeparator {} + + Item { + width: parent.width + height: Dims.h(32) + opacity: watchfaceSelector.storeAvailable ? 1.0 : 0.45 + + Column { + anchors.centerIn: parent + + Icon { + name: "ios-cloud-download-outline" + width: Dims.l(12) + height: width + anchors.horizontalCenter: parent.horizontalCenter + } + + Label { + width: Dims.l(70) + horizontalAlignment: Text.AlignHCenter + text: watchfaceSelector.storeAvailable ? + //% "Get More Watchfaces" + qsTrId("id-get-more-watchfaces") : + //% "Connect to get more watchfaces" + qsTrId("id-connect-for-watchfaces") + font { pixelSize: Dims.l(8); family: "Noto Sans"; styleName: "SemiCondensed SemiBold" } + anchors.horizontalCenter: parent.horizontalCenter + } + } + + HighlightBar { + enabled: watchfaceSelector.storeAvailable + onClicked: layerStack.push(watchfaceStoreComponent, { assetPath: assetPath, previewSize: watchfaceSelector.previewSize }) + } + } + } } } diff --git a/src/qml/WatchfaceStorePage.qml b/src/qml/WatchfaceStorePage.qml new file mode 100644 index 00000000..d3b6e43d --- /dev/null +++ b/src/qml/WatchfaceStorePage.qml @@ -0,0 +1,636 @@ +/* + * Copyright (C) 2026 - Timo Könnecke + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * 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. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import QtQuick 2.9 +import QtGraphicalEffects 1.12 +import Qt.labs.folderlistmodel 2.1 +import Nemo.Configuration 1.0 +import org.asteroid.controls 1.0 +import org.asteroid.utils 1.0 +import org.asteroid.settings 1.0 + +Item { + id: storePage + + property string assetPath: "file:///usr/share/asteroid-launcher/" + property int previewSize: 128 + + property bool loadingCatalog: true + property string downloadingName: "" + property bool restartPending: false + property int cacheVersion: 0 + property int _pendingFiles: 0 + property int _inFlightXhrs: 0 + property var _pendingPreviews: ({}) + property string deletingName: "" + property string installingName: "" + property string failedName: "" + property bool _restartFired: false + + readonly property string _cacheBase: WatchfaceHelper.cachePath() + readonly property string _catalogCache: WatchfaceHelper.cachePath() + "catalog.json" + readonly property string _apiBase: "https://api.github.com/repos/AsteroidOS/unofficial-watchfaces/contents/" + readonly property string _rawBase: "https://raw.githubusercontent.com/AsteroidOS/unofficial-watchfaces/master/" + + ConfigurationValue { + id: activeWatchface + key: "/desktop/asteroid/watchface" + defaultValue: "file:///usr/share/asteroid-launcher/watchfaces/000-default-digital.qml" + } + + ListModel { id: storeModel } + + Component.onCompleted: { + var cached = WatchfaceHelper.readFile(_catalogCache) + if (cached) { + try { + loadingCatalog = false + _addCommunityWatchfaces(JSON.parse(cached)) + } catch(e) { + _fetchCatalog() + } + } else { + _fetchCatalog() + } + } + + // ── Grid + + GridView { + id: storeGrid + anchors { + top: parent.top + // ── Margin to offset list from PageHeader + topMargin: title.height + bottom: parent.bottom + left: parent.left + right: parent.right + } + cellWidth: Dims.w(50) + cellHeight: Dims.h(45) + + model: storeModel + + delegate: Item { + width: storeGrid.cellWidth + height: storeGrid.cellHeight + + property bool _pressActive: false + property bool _scrollCancelled: false + property bool _wasDeleting: false + readonly property bool isInstalling: storePage.installingName === model.name + + onIsInstallingChanged: { + if (!isInstalling && model.isInstalled) + bumpAnim.start() + } + + Rectangle { + id: stateBg + width: Dims.l(40) + height: width + radius: width + anchors.centerIn: parent + color: model.isInstalled ? "#44ff88" : "#000000" + opacity: model.isInstalled ? 0.5 : 0.2 + + NumberAnimation { id: opacityAnim; target: stateBg; property: "opacity"; easing.type: Easing.OutQuad } + ColorAnimation { id: colorAnim; target: stateBg; property: "color"; easing.type: Easing.OutQuad } + + SequentialAnimation { + id: bumpAnim + NumberAnimation { target: stateBg; property: "opacity"; to: 0.65; duration: 120; easing.type: Easing.OutQuad } + NumberAnimation { target: stateBg; property: "opacity"; to: 0.5; duration: 200; easing.type: Easing.InQuad } + } + + SequentialAnimation { + id: failAnim + ColorAnimation { target: stateBg; property: "color"; to: "#ff4444"; duration: 200 } + PauseAnimation { duration: 1200 } + ColorAnimation { target: stateBg; property: "color"; to: "#000000"; duration: 400 } + NumberAnimation { target: stateBg; property: "opacity"; to: 0.2; duration: 300 } + onStopped: storePage.failedName = "" + } + } + + Connections { + target: storePage + function onFailedNameChanged() { + if (storePage.failedName === model.name) failAnim.start() + } + function onDeletingNameChanged() { + if (model.isInstalled) { + if (storePage.deletingName === model.name) { + _wasDeleting = true + } else if (_wasDeleting) { + _wasDeleting = false + opacityAnim.stop() + opacityAnim.from = stateBg.opacity + opacityAnim.to = 0.5 + opacityAnim.duration = 200 + opacityAnim.start() + } + } + } + } + + Timer { + id: storeHoldTimer + interval: 800 + repeat: false + onTriggered: { + if (!model.isInstalled) return + storePage.deletingName = model.name + removeRemorse.watchfaceName = model.name + //% "Remove" + removeRemorse.action = qsTrId("id-remove") + " " + model.name + removeRemorse.start() + } + } + + Rectangle { + id: storeItemMask + width: Dims.l(40) + height: width + anchors.centerIn: parent + color: "transparent" + radius: DeviceSpecs.hasRoundScreen ? width : Dims.l(3) + clip: true + + Image { + id: storePreview + anchors.centerIn: parent + width: Math.min(parent.width, parent.height) + height: width + asynchronous: true + fillMode: Image.PreserveAspectFit + mipmap: true + opacity: model.isInstalled ? 1.0 : 0.7 + + source: { + var _cv = storePage.cacheVersion + if (model.isInstalled) + return WatchfaceHelper.userAssetPath() + "watchfaces-preview/" + storePage.previewSize + "/" + model.name + ".png" + var cacheDest = storePage._cacheBase + storePage.previewSize + "/" + model.name + ".png" + return FileInfo.exists(cacheDest) ? "file://" + cacheDest : "" + } + + Component.onCompleted: { + if (!model.isInstalled) + storePage._ensurePreview(model.name) + } + } + + MouseArea { + anchors.fill: parent + property real startX: 0 + property real startY: 0 + + onPressed: { + startX = mouse.x + startY = mouse.y + _scrollCancelled = false + _pressActive = true + bumpAnim.stop() + failAnim.stop() + opacityAnim.stop() + colorAnim.stop() + if (model.isInstalled) { + opacityAnim.from = stateBg.opacity + opacityAnim.to = 0.0 + opacityAnim.duration = 800 + opacityAnim.easing.type = Easing.Linear + opacityAnim.start() + storeHoldTimer.restart() + } else if (storePage.downloadingName === "") { + colorAnim.from = stateBg.color + colorAnim.to = "#44ff88" + colorAnim.duration = 300 + colorAnim.start() + opacityAnim.from = stateBg.opacity + opacityAnim.to = 0.35 + opacityAnim.duration = 300 + opacityAnim.easing.type = Easing.OutQuad + opacityAnim.start() + } + } + + onPositionChanged: { + if (_scrollCancelled) return + var dx = Math.abs(mouse.x - startX) + var dy = Math.abs(mouse.y - startY) + if (dx > Dims.l(2) || dy > Dims.l(2)) { + _scrollCancelled = true + _pressActive = false + storeHoldTimer.stop() + opacityAnim.stop() + colorAnim.stop() + colorAnim.from = stateBg.color + colorAnim.to = model.isInstalled ? "#44ff88" : "#000000" + colorAnim.duration = 150 + colorAnim.start() + opacityAnim.from = stateBg.opacity + opacityAnim.to = model.isInstalled ? 0.5 : 0.2 + opacityAnim.duration = 150 + opacityAnim.easing.type = Easing.OutQuad + opacityAnim.start() + mouse.accepted = false + } + } + + onReleased: { + if (_scrollCancelled) return + storeHoldTimer.stop() + _pressActive = false + _scrollCancelled = false + if (!model.isInstalled && storePage.downloadingName === "") { + // keep animating toward full green — do not stop + colorAnim.stop() + opacityAnim.stop() + colorAnim.from = stateBg.color + colorAnim.to = "#44ff88" + colorAnim.duration = 300 + colorAnim.start() + opacityAnim.from = stateBg.opacity + opacityAnim.to = 0.5 + opacityAnim.duration = 300 + opacityAnim.easing.type = Easing.OutQuad + opacityAnim.start() + storePage._startDownload(model.name) + } else if (model.isInstalled && storePage.deletingName === "") { + opacityAnim.stop() + opacityAnim.from = stateBg.opacity + opacityAnim.to = 0.5 + opacityAnim.duration = 200 + opacityAnim.easing.type = Easing.OutQuad + opacityAnim.start() + } + } + + onCanceled: { + storeHoldTimer.stop() + _pressActive = false + _scrollCancelled = false + opacityAnim.stop() + colorAnim.stop() + colorAnim.from = stateBg.color + colorAnim.to = model.isInstalled ? "#44ff88" : "#000000" + colorAnim.duration = 200 + colorAnim.start() + opacityAnim.from = stateBg.opacity + opacityAnim.to = model.isInstalled ? 0.5 : 0.2 + opacityAnim.duration = 200 + opacityAnim.easing.type = Easing.OutQuad + opacityAnim.start() + } + } + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + anchors.centerIn: parent + width: Math.min(storePreview.width, storePreview.height) + height: width + radius: storeItemMask.radius + } + } + } + + Icon { + name: "ios-checkmark-circle" + z: 100 + width: parent.width * .3 + height: width + visible: activeWatchface.value === WatchfaceHelper.userAssetPath() + "watchfaces/" + model.name + ".qml" + anchors { + bottom: parent.bottom + bottomMargin: DeviceSpecs.hasRoundScreen ? -parent.height * .03 : -parent.height * .08 + horizontalCenter: parent.horizontalCenter + horizontalCenterOffset: index % 2 ? + (DeviceSpecs.hasRoundScreen ? -parent.height * .45 : -parent.height * .40) : + (DeviceSpecs.hasRoundScreen ? parent.height * .45 : parent.height * .40) + } + layer.enabled: visible + layer.effect: DropShadow { + transparentBorder: true + horizontalOffset: 2 + verticalOffset: 2 + radius: 8.0 + samples: 17 + color: "#88000000" + } + } + } + + // ── Footer: restart + refresh + + footer: Column { + width: storeGrid.width + + Item { width: parent.width; height: Dims.l(4) } + + RowSeparator {} + + Item { + width: parent.width + height: Dims.h(32) + + Column { + anchors.centerIn: parent + + Icon { + name: "ios-cloud-download-outline" + width: Dims.l(12) + height: width + anchors.horizontalCenter: parent.horizontalCenter + } + + Label { + width: Dims.l(70) + horizontalAlignment: Text.AlignHCenter + //% "Refresh store" + text: qsTrId("id-refresh-store") + font { pixelSize: Dims.l(8); family: "Noto Sans"; styleName: "SemiCondensed SemiBold" } + anchors.horizontalCenter: parent.horizontalCenter + } + } + + HighlightBar { onClicked: _fetchCatalog() } + } + + RowSeparator {} + + Item { + width: parent.width + height: Dims.h(32) + + Column { + anchors.centerIn: parent + spacing: Dims.l(1) + + Icon { + name: "ios-refresh" + width: Dims.l(12) + height: width + anchors.horizontalCenter: parent.horizontalCenter + } + + Label { + width: Dims.l(70) + horizontalAlignment: Text.AlignHCenter + //% "Restart launcher" + text: qsTrId("id-restart-launcher") + font { pixelSize: Dims.l(8); family: "Noto Sans"; styleName: "SemiCondensed SemiBold" } + anchors.horizontalCenter: parent.horizontalCenter + } + } + + HighlightBar { onClicked: restartRemorse.start() } + } + } + } + + // ── Loading indicator + + Label { + anchors.centerIn: parent + visible: loadingCatalog && storeModel.count === 0 + //% "Loading..." + text: qsTrId("id-loading") + font { pixelSize: Dims.l(6); styleName: "Light" } + color: "#80ffffff" + } + + // ── Page header + + PageHeader { + id: title + //% "Watchface Store" + text: qsTrId("id-watchface-store") + } + + // ── Removal remorse timer + + RemorseTimer { + id: removeRemorse + + property string watchfaceName: "" + + duration: 3000 + gaugeSegmentAmount: 8 + gaugeStartDegree: -130 + gaugeEndFromStartDegree: 265 + //% "Tap to cancel" + cancelText: qsTrId("id-tap-to-cancel") + + onTriggered: { + var targetPath = WatchfaceHelper.userAssetPath() + "watchfaces/" + watchfaceName + ".qml" + if (activeWatchface.value === targetPath) + activeWatchface.value = activeWatchface.defaultValue + if (WatchfaceHelper.removeWatchface(watchfaceName)) { + for (var i = 0; i < storeModel.count; i++) { + if (storeModel.get(i).name === watchfaceName) { + storeModel.setProperty(i, "isInstalled", false) + break + } + } + } + storePage.deletingName = "" + } + + onCancelled: storePage.deletingName = "" + } + + // ── Launcher restart remorse timer with full black cut off before restart + + Rectangle { + anchors.fill: parent + color: "#000000" + opacity: _restartFired ? 0.92 : restartRemorse.opacity * 0.92 + visible: opacity > 0 + } + + RemorseTimer { + id: restartRemorse + duration: 4000 + gaugeSegmentAmount: 6 + gaugeStartDegree: -130 + gaugeEndFromStartDegree: 265 + //% "Restart launcher" + action: qsTrId("id-restart-launcher") + //% "Tap to cancel" + cancelText: qsTrId("id-tap-to-cancel") + onTriggered: { + storePage._restartFired = true + WatchfaceHelper.restartSession() + } + } + + // ── WatchfaceHelper connections + + Connections { + target: WatchfaceHelper + + function onDownloadComplete(destPath, success) { + if (destPath.startsWith(storePage._cacheBase)) { + if (!success) { + var cacheName = destPath.split("/").pop().replace(".png", "") + for (var i = 0; i < storeModel.count; i++) { + if (storeModel.get(i).name === cacheName) { storeModel.remove(i); break } + } + } else { + storePage.cacheVersion++ + } + return + } + if (storePage.downloadingName !== "") { + if (!success) { + console.warn("WatchfaceStorePage: download failed:", destPath) + if (destPath.endsWith(".qml")) + storePage.failedName = storePage.downloadingName + } + storePage._pendingFiles-- + storePage._checkInstallComplete() + } + } + } + + // ── Private functions + + function _fetchCatalog() { + loadingCatalog = true + storeModel.clear() + var xhr = new XMLHttpRequest() + xhr.open("GET", _apiBase) + xhr.timeout = 10000 + xhr.onreadystatechange = function() { + if (xhr.readyState !== XMLHttpRequest.DONE) return + loadingCatalog = false + if (xhr.status !== 200) { + console.warn("WatchfaceStorePage: catalog fetch failed, status", xhr.status) + return + } + try { + var text = xhr.responseText + WatchfaceHelper.writeFile(_catalogCache, text) + _addCommunityWatchfaces(JSON.parse(text)) + } catch(e) { + console.warn("WatchfaceStorePage: catalog parse error:", e) + } + } + xhr.send() + } + + function _addCommunityWatchfaces(catalog) { + var skipList = { "tests": true, "fake-components": true } + for (var j = 0; j < catalog.length; j++) { + var entry = catalog[j] + if (entry.type !== "dir") continue + if (entry.name[0] === ".") continue + if (skipList[entry.name]) continue + var isInstalled = FileInfo.exists(WatchfaceHelper.userWatchfacePath() + entry.name + ".qml") + storeModel.append({ name: entry.name, isInstalled: isInstalled }) + } + } + + function _ensurePreview(name) { + if (_pendingPreviews[name]) return + var cacheDest = _cacheBase + previewSize + "/" + name + ".png" + if (FileInfo.exists(cacheDest)) return + _pendingPreviews[name] = true + WatchfaceHelper.mkpath(_cacheBase + previewSize) + WatchfaceHelper.downloadFile( + _rawBase + name + "/usr/share/asteroid-launcher/watchfaces-preview/" + previewSize + "/" + name + ".png", + cacheDest) + } + + function _startDownload(name) { + if (downloadingName !== "") return + downloadingName = name + installingName = name + _pendingFiles = 0 + _inFlightXhrs = 0 + + var userBase = WatchfaceHelper.userWatchfacePath() + var userRoot = userBase.substring(0, userBase.lastIndexOf("watchfaces/")) + + _queueDownload( + _rawBase + name + "/usr/share/asteroid-launcher/watchfaces/" + name + ".qml", + userBase + name + ".qml") + _queueDownload( + _rawBase + name + "/usr/share/asteroid-launcher/watchfaces-preview/" + previewSize + "/" + name + ".png", + userRoot + "watchfaces-preview/" + previewSize + "/" + name + ".png") + _fetchDirectory( + _apiBase + name + "/usr/share/asteroid-launcher/watchfaces-img/", + userRoot + "watchfaces-img/", + false) + _fetchDirectory( + _apiBase + name + "/usr/share/fonts/", + WatchfaceHelper.userFontsPath(), + true) + } + + function _queueDownload(url, dest) { + _pendingFiles++ + WatchfaceHelper.mkpath(dest.substring(0, dest.lastIndexOf("/"))) + WatchfaceHelper.downloadFile(url, dest) + } + + function _fetchDirectory(apiUrl, destPrefix, isFont) { + _inFlightXhrs++ + var xhr = new XMLHttpRequest() + xhr.open("GET", apiUrl) + xhr.timeout = 10000 + xhr.onreadystatechange = function() { + if (xhr.readyState !== XMLHttpRequest.DONE) return + _inFlightXhrs-- + if (xhr.status === 200) { + try { + var files = JSON.parse(xhr.responseText) + for (var i = 0; i < files.length; i++) { + if (files[i].type === "file") + _queueDownload(files[i].download_url, destPrefix + files[i].name) + } + } catch(e) { + console.warn("WatchfaceStorePage: directory parse error:", e) + } + } + _checkInstallComplete() + } + xhr.send() + } + + function _checkInstallComplete() { + if (_pendingFiles !== 0 || _inFlightXhrs !== 0) return + if (downloadingName === "") return + + var name = downloadingName + downloadingName = "" + + if (storePage.failedName !== name) { + for (var i = 0; i < storeModel.count; i++) { + if (storeModel.get(i).name === name) { + storeModel.setProperty(i, "isInstalled", true) + break + } + } + activeWatchface.value = WatchfaceHelper.userAssetPath() + "watchfaces/" + name + ".qml" + restartPending = true + } + installingName = "" + } +} diff --git a/src/resources.qrc b/src/resources.qrc index b4bafbb7..734d2b55 100644 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -15,6 +15,7 @@ qml/WallpaperPage.qml qml/WatchfacePage.qml qml/WatchfaceSelector.qml + qml/WatchfaceStorePage.qml qml/LauncherPage.qml qml/USBPage.qml qml/PowerPage.qml From e89b61a76ebc712879a1e4c0283bcf1263a9779d Mon Sep 17 00:00:00 2001 From: moWerk Date: Tue, 31 Mar 2026 21:54:17 +0200 Subject: [PATCH 3/4] Add wallpaper download support to WatchfaceStorePage Community watchfaces that ship a custom wallpaper now have it fetched into the XDG user wallpapers folder during install. The existing _fetchDirectory call pattern handles all file types in the wallpapers/full/ folder including jpg, png and svg. --- src/qml/WatchfaceStorePage.qml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/qml/WatchfaceStorePage.qml b/src/qml/WatchfaceStorePage.qml index d3b6e43d..8102ec09 100644 --- a/src/qml/WatchfaceStorePage.qml +++ b/src/qml/WatchfaceStorePage.qml @@ -578,6 +578,10 @@ Item { _apiBase + name + "/usr/share/asteroid-launcher/watchfaces-img/", userRoot + "watchfaces-img/", false) + _fetchDirectory( + _apiBase + name + "/usr/share/asteroid-launcher/wallpapers/full/", + userRoot + "wallpapers/full/", + false) _fetchDirectory( _apiBase + name + "/usr/share/fonts/", WatchfaceHelper.userFontsPath(), From b71da8f45913efb1edd195e63ddf72a3ff849f8c Mon Sep 17 00:00:00 2001 From: moWerk Date: Tue, 31 Mar 2026 23:05:14 +0200 Subject: [PATCH 4/4] Extend WallpaperPage to show user-installed wallpapers Replaces the single system FolderListModel with a unified ListModel populated from both the system and XDG user wallpapers paths via a debounced Timer, matching the WatchfaceSelector pattern. Each user wallpaper entry is verified via FileInfo.exists() before being added to the model, guarding against phantom entries from FolderListModel reporting a non-zero count on non-existent or deleted paths. Thumbnail lookup checks the system thumbnail subfolder first and falls back to full resolution for user wallpapers which have no thumbnail. QML wallpapers remain system-only as community wallpapers are image-only by convention. WatchfaceStorePage already downloads wallpapers shipped by community watchfaces into the XDG user wallpapers path, so they appear here automatically after install without requiring a launcher restart. --- src/qml/WallpaperPage.qml | 101 +++++++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 34 deletions(-) diff --git a/src/qml/WallpaperPage.qml b/src/qml/WallpaperPage.qml index f4c1462f..6b391c36 100644 --- a/src/qml/WallpaperPage.qml +++ b/src/qml/WallpaperPage.qml @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 - Timo Könnecke + * Copyright (C) 2026 - Timo Könnecke * 2022 - Darrel Griët * 2015 - Florent Revest * @@ -23,26 +23,73 @@ import Qt.labs.folderlistmodel 2.1 import Nemo.Configuration 1.0 import org.asteroid.controls 1.0 import org.asteroid.utils 1.0 - +import org.asteroid.settings 1.0 Item { property string assetPath: "file:///usr/share/asteroid-launcher/wallpapers/" + readonly property string userAssetPath: WatchfaceHelper.userAssetPath() + "wallpapers/" ConfigurationValue { id: wallpaperSource - key: "/desktop/asteroid/background-filename" defaultValue: assetPath + "full/000-flatmesh.qml" } FolderListModel { id: qmlWallpapersModel - folder: assetPath + "full" nameFilters: ["*.qml"] } + ListModel { id: unifiedModel } + + FolderListModel { + id: sysWallpaperModel + folder: assetPath + "full" + nameFilters: ["*.jpg", "*.png", "*.svg"] + onCountChanged: rebuildTimer.restart() + } + + FolderListModel { + id: userWallpaperModel + folder: userAssetPath + "full" + nameFilters: ["*.jpg", "*.png", "*.svg"] + onCountChanged: rebuildTimer.restart() + } + + Timer { + id: rebuildTimer + interval: 100 + repeat: false + onTriggered: { + unifiedModel.clear() + var i, fn, fb + for (i = 0; i < sysWallpaperModel.count; i++) { + fn = sysWallpaperModel.get(i, "fileName") + fb = sysWallpaperModel.get(i, "fileBaseName") + unifiedModel.append({ fileName: fn, fileBaseName: fb, + filePath: assetPath + "full/" + fn, isUser: false }) + } + for (i = 0; i < userWallpaperModel.count; i++) { + fn = userWallpaperModel.get(i, "fileName") + fb = userWallpaperModel.get(i, "fileBaseName") + var fullPath = (userAssetPath + "full/" + fn).slice(7) + if (!FileInfo.exists(fullPath)) continue + unifiedModel.append({ fileName: fn, fileBaseName: fb, + filePath: userAssetPath + "full/" + fn, isUser: true }) + } + for (i = 0; i < unifiedModel.count; i++) { + var entry = unifiedModel.get(i) + if (wallpaperSource.value === entry.filePath || + wallpaperSource.value === entry.filePath.replace(/\.[^.]+$/, ".qml")) { + grid.positionViewAtIndex(i, GridView.Center) + break + } + } + } + } + GridView { id: grid @@ -50,24 +97,7 @@ Item { cellHeight: Dims.h(40) anchors.fill: parent - model: FolderListModel { - id: folderModel - - folder: assetPath + "full" - nameFilters: ["*.jpg", "*.png", "*.svg"] - onCountChanged: { - var i = 0 - while (i < folderModel.count){ - var fileName = folderModel.get(i, "fileName") - var fileBaseName = folderModel.get(i, "fileBaseName") - if(wallpaperSource.value === folderModel.folder + "/" + fileName | - wallpaperSource.value === folderModel.folder + "/" + fileBaseName + ".qml") { - grid.positionViewAtIndex(i, GridView.Center) - } - i = i + 1 - } - } - } + model: unifiedModel delegate: Component { id: fileDelegate @@ -75,34 +105,37 @@ Item { Item { width: grid.cellWidth height: grid.cellHeight + Image { id: img anchors.fill: parent fillMode: Image.PreserveAspectCrop - // If a pre-scaled thumbnail file exists, use that. - source: FileInfo.exists((assetPath + Dims.w(50) + "/" + fileName).slice(7)) ? - assetPath + Dims.w(50) + "/" + fileName : - // Else use the full resolution wallpaper with negative impact on performance, as failsafe. - folderModel.folder + "/" + fileName + source: { + var sysThumb = (assetPath + Dims.w(50) + "/" + model.fileName).slice(7) + if (!model.isUser && FileInfo.exists(sysThumb)) + return assetPath + Dims.w(50) + "/" + model.fileName + return model.filePath + } asynchronous: true } MouseArea { anchors.fill: parent onClicked: { - if(qmlWallpapersModel.indexOf(folderModel.folder + "/" + fileBaseName + ".qml") !== -1) - wallpaperSource.value = folderModel.folder + "/" + fileBaseName + ".qml" + var qmlPath = model.filePath.replace(/\.[^.]+$/, ".qml") + if (!model.isUser && qmlWallpapersModel.indexOf(qmlPath) !== -1) + wallpaperSource.value = qmlPath else - wallpaperSource.value = folderModel.folder + "/" + fileName + wallpaperSource.value = model.filePath } } Rectangle { id: highlightSelection - property bool notSelected: wallpaperSource.value !== folderModel.folder + "/" + fileName & - wallpaperSource.value !== folderModel.folder + "/" + fileBaseName + ".qml" + property bool notSelected: wallpaperSource.value !== model.filePath && + wallpaperSource.value !== model.filePath.replace(/\.[^.]+$/, ".qml") anchors.fill: img color: "#30000000" @@ -123,8 +156,8 @@ Item { } height: width width: parent.width * 0.3 - visible: wallpaperSource.value === folderModel.folder + "/" + fileName | - wallpaperSource.value === folderModel.folder + "/" + fileBaseName + ".qml" + visible: wallpaperSource.value === model.filePath || + wallpaperSource.value === model.filePath.replace(/\.[^.]+$/, ".qml") layer.enabled: visible layer.effect: DropShadow {