diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4ccaf5e..572ec89 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 0000000..f92b649 --- /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 0000000..e32552b --- /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 c73b388..678a681 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)); diff --git a/src/qml/WallpaperPage.qml b/src/qml/WallpaperPage.qml index f4c1462..6b391c3 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 { diff --git a/src/qml/WatchfaceSelector.qml b/src/qml/WatchfaceSelector.qml index 1b56d5b..f5da3b1 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 0000000..8102ec0 --- /dev/null +++ b/src/qml/WatchfaceStorePage.qml @@ -0,0 +1,640 @@ +/* + * 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/asteroid-launcher/wallpapers/full/", + userRoot + "wallpapers/full/", + 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 b4bafbb..734d2b5 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