diff --git a/framework/global/modularity/imodulesetup.h b/framework/global/modularity/imodulesetup.h index 88930ee935..1b807c7cea 100644 --- a/framework/global/modularity/imodulesetup.h +++ b/framework/global/modularity/imodulesetup.h @@ -46,6 +46,7 @@ class IContextSetup virtual void onPreInit(const IApplication::RunMode& mode) { (void)mode; } virtual void onInit(const IApplication::RunMode& mode) { (void)mode; } virtual void onAllInited(const IApplication::RunMode& mode) { (void)mode; } + virtual void onDelayedInit() {} virtual void onDeinit() {} virtual void onDestroy() {} diff --git a/framework/stubs/update/CMakeLists.txt b/framework/stubs/update/CMakeLists.txt index 8b9c346179..0f2245150b 100644 --- a/framework/stubs/update/CMakeLists.txt +++ b/framework/stubs/update/CMakeLists.txt @@ -30,7 +30,3 @@ target_sources(muse_update PRIVATE appupdateservicestub.cpp appupdateservicestub.h ) - -if (MUSE_MODULE_UPDATE_QML) - add_subdirectory(qml/Muse/Update) -endif() diff --git a/framework/stubs/update/appupdatescenariostub.cpp b/framework/stubs/update/appupdatescenariostub.cpp index 5d9ec9c688..3f679b4c02 100644 --- a/framework/stubs/update/appupdatescenariostub.cpp +++ b/framework/stubs/update/appupdatescenariostub.cpp @@ -31,49 +31,3 @@ bool AppUpdateScenarioStub::needCheckForUpdate() const void AppUpdateScenarioStub::checkForUpdate(bool) { } - -bool AppUpdateScenarioStub::hasUpdate() const -{ - return false; -} - -bool AppUpdateScenarioStub::hasReadyUpdate() const -{ - return false; -} - -muse::async::Notification AppUpdateScenarioStub::hasReadyUpdateChanged() const -{ - return {}; -} - -std::string AppUpdateScenarioStub::readyUpdateVersion() const -{ - return {}; -} - -void AppUpdateScenarioStub::installReadyUpdate() -{ -} - -bool AppUpdateScenarioStub::hasCompletedUpdate() const -{ - return false; -} - -muse::async::Notification AppUpdateScenarioStub::hasCompletedUpdateChanged() const -{ - return {}; -} - -void AppUpdateScenarioStub::dismissCompletedUpdate() -{ -} - -void AppUpdateScenarioStub::showReadyUpdateInfo() -{ -} - -void AppUpdateScenarioStub::dismissReadyUpdate() -{ -} diff --git a/framework/stubs/update/appupdatescenariostub.h b/framework/stubs/update/appupdatescenariostub.h index 7ae9b62a14..3b02b67e14 100644 --- a/framework/stubs/update/appupdatescenariostub.h +++ b/framework/stubs/update/appupdatescenariostub.h @@ -30,19 +30,5 @@ class AppUpdateScenarioStub : public IAppUpdateScenario public: bool needCheckForUpdate() const override; void checkForUpdate(bool manual) override; - - bool hasUpdate() const override; - - bool hasReadyUpdate() const override; - async::Notification hasReadyUpdateChanged() const override; - std::string readyUpdateVersion() const override; - - void installReadyUpdate() override; - void showReadyUpdateInfo() override; - void dismissReadyUpdate() override; - - bool hasCompletedUpdate() const override; - async::Notification hasCompletedUpdateChanged() const override; - void dismissCompletedUpdate() override; }; } diff --git a/framework/stubs/update/appupdateservicestub.cpp b/framework/stubs/update/appupdateservicestub.cpp index a7e05df714..acc6b7e8fe 100644 --- a/framework/stubs/update/appupdateservicestub.cpp +++ b/framework/stubs/update/appupdateservicestub.cpp @@ -48,17 +48,12 @@ bool AppUpdateServiceStub::canAutoInstall() const return false; } -RetVal AppUpdateServiceStub::prepareUpdate(const muse::io::path_t&) -{ - return RetVal(make_ret(Ret::Code::NotSupported)); -} - -Ret AppUpdateServiceStub::finalizeUpdate(const muse::io::path_t&) +Ret AppUpdateServiceStub::installUpdate() { return make_ret(Ret::Code::NotSupported); } -bool AppUpdateServiceStub::isReleaseDownloaded() const +bool AppUpdateServiceStub::isReleaseReadyToInstall() const { return false; } diff --git a/framework/stubs/update/appupdateservicestub.h b/framework/stubs/update/appupdateservicestub.h index 0f05d90475..12ca94da82 100644 --- a/framework/stubs/update/appupdateservicestub.h +++ b/framework/stubs/update/appupdateservicestub.h @@ -33,10 +33,9 @@ class AppUpdateServiceStub : public IAppUpdateService RetVal downloadRelease() override; bool canAutoInstall() const override; - RetVal prepareUpdate(const muse::io::path_t& packagePath) override; - Ret finalizeUpdate(const muse::io::path_t& preparedPath) override; + Ret installUpdate() override; - bool isReleaseDownloaded() const override; + bool isReleaseReadyToInstall() const override; muse::io::path_t downloadedReleasePath() const override; void removeDownloadedRelease() override; }; diff --git a/framework/stubs/update/qml/Muse/Update/CMakeLists.txt b/framework/stubs/update/qml/Muse/Update/CMakeLists.txt deleted file mode 100644 index 18ae009aad..0000000000 --- a/framework/stubs/update/qml/Muse/Update/CMakeLists.txt +++ /dev/null @@ -1,30 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-only -# MuseScore-Studio-CLA-applies -# -# MuseScore Studio -# Music Composition & Notation -# -# Copyright (C) 2026 MuseScore Limited and others -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3 as -# published by the Free Software Foundation. -# -# 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 . - -muse_create_qml_module(muse_update_qml ALIAS muse::update_qml FOR muse_update STUB) - -qt_add_qml_module(muse_update_qml - URI Muse.Update - VERSION 1.0 - QML_FILES - UpdateBanner.qml -) - -fixup_qml_module_dependencies(muse_update_qml) diff --git a/framework/stubs/update/qml/Muse/Update/UpdateBanner.qml b/framework/stubs/update/qml/Muse/Update/UpdateBanner.qml deleted file mode 100644 index d20035ab57..0000000000 --- a/framework/stubs/update/qml/Muse/Update/UpdateBanner.qml +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-only - * MuseScore-CLA-applies - * - * MuseScore Studio - * Music Composition & Notation - * - * Copyright (C) 2026 MuseScore Limited and others - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 3 as - * published by the Free Software Foundation. - * - * 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 - -Item { - readonly property bool hasReadyUpdate: false - readonly property string updateVersion: "" - - visible: false -} diff --git a/framework/stubs/update/updateconfigurationstub.cpp b/framework/stubs/update/updateconfigurationstub.cpp index bac83dfb5a..6d5108b0dc 100644 --- a/framework/stubs/update/updateconfigurationstub.cpp +++ b/framework/stubs/update/updateconfigurationstub.cpp @@ -52,12 +52,12 @@ muse::async::Notification UpdateConfigurationStub::needCheckForUpdateChanged() c return n; } -bool UpdateConfigurationStub::autoDownloadEnabled() const +bool UpdateConfigurationStub::autoUpdateEnabled() const { return false; } -void UpdateConfigurationStub::setAutoDownloadEnabled(bool) +void UpdateConfigurationStub::setAutoUpdateEnabled(bool) { } diff --git a/framework/stubs/update/updateconfigurationstub.h b/framework/stubs/update/updateconfigurationstub.h index 14d2a6a573..cf55d1d4e4 100644 --- a/framework/stubs/update/updateconfigurationstub.h +++ b/framework/stubs/update/updateconfigurationstub.h @@ -37,8 +37,8 @@ class UpdateConfigurationStub : public IUpdateConfiguration void setNeedCheckForUpdate(bool needCheck) override; muse::async::Notification needCheckForUpdateChanged() const override; - bool autoDownloadEnabled() const override; - void setAutoDownloadEnabled(bool enabled) override; + bool autoUpdateEnabled() const override; + void setAutoUpdateEnabled(bool enabled) override; std::string skippedReleaseVersion() const override; void setSkippedReleaseVersion(const std::string& version) override; diff --git a/framework/ui/internal/guiapplication.cpp b/framework/ui/internal/guiapplication.cpp index c5cbb79451..5efb4844a6 100644 --- a/framework/ui/internal/guiapplication.cpp +++ b/framework/ui/internal/guiapplication.cpp @@ -22,6 +22,8 @@ #include "guiapplication.h" +#include + #include #include #include @@ -38,6 +40,8 @@ using namespace muse; using namespace muse::ui; +static constexpr int DELAYED_INIT_INTERVAL_MS = 5000; + GuiApplication::GuiApplication(const std::shared_ptr& options) : BaseApplication(options) { @@ -51,7 +55,7 @@ void GuiApplication::doSetup(const std::shared_ptr& options) // Setup modules: onDelayedInit // ==================================================== m_delayedInitTimer.setSingleShot(true); - m_delayedInitTimer.setInterval(5000); + m_delayedInitTimer.setInterval(DELAYED_INIT_INTERVAL_MS); QObject::connect(&m_delayedInitTimer, &QTimer::timeout, [this]() { m_globalModule->onDelayedInit(); for (modularity::IModuleSetup* m : m_modules) { @@ -123,11 +127,31 @@ void GuiApplication::startupScenario(const muse::modularity::ContextPtr& ctxId) if (ok) { QMetaObject::invokeMethod(qApp, [this, ctxId]() { doStartupScenario(ctxId); + + QTimer::singleShot(DELAYED_INIT_INTERVAL_MS, qApp, [this, ctxId]() { + contextDelayedInit(ctxId); + }); }, Qt::QueuedConnection); } }, Qt::QueuedConnection); } +void GuiApplication::contextDelayedInit(const muse::modularity::ContextPtr& ctxId) +{ + //! NOTE: The context may have been destroyed (its window closed) in the meantime + auto it = std::find_if(m_contexts.begin(), m_contexts.end(), [&ctxId](const ContextData& c) { + return c.ctxId->id == ctxId->id; + }); + + if (it == m_contexts.end()) { + return; + } + + for (modularity::IContextSetup* s : it->setups) { + s->onDelayedInit(); + } +} + bool GuiApplication::loadMainWindow(const muse::modularity::ContextPtr& ctxId) { TRACEFUNC; diff --git a/framework/ui/internal/guiapplication.h b/framework/ui/internal/guiapplication.h index c754991f69..787c4d4257 100644 --- a/framework/ui/internal/guiapplication.h +++ b/framework/ui/internal/guiapplication.h @@ -44,6 +44,7 @@ class GuiApplication : public BaseApplication void setupGraphicsApi(); void startupScenario(const muse::modularity::ContextPtr& ctxId) override; + void contextDelayedInit(const muse::modularity::ContextPtr& ctxId); virtual QString mainWindowQmlPath(const QString& platform) const = 0; virtual bool loadMainWindow(const muse::modularity::ContextPtr& ctxId); diff --git a/framework/update/iappupdatescenario.h b/framework/update/iappupdatescenario.h index 704cee998e..95456b30f2 100644 --- a/framework/update/iappupdatescenario.h +++ b/framework/update/iappupdatescenario.h @@ -24,7 +24,6 @@ #include "types/ret.h" #include "async/promise.h" -#include "async/notification.h" #include "modularity/imoduleinterface.h" @@ -38,24 +37,5 @@ class IAppUpdateScenario : MODULE_CONTEXT_INTERFACE virtual bool needCheckForUpdate() const = 0; virtual void checkForUpdate(bool manual) = 0; - - virtual bool hasUpdate() const = 0; - - //! A downloaded update is ready to be installed in-place. - virtual bool hasReadyUpdate() const = 0; - virtual async::Notification hasReadyUpdateChanged() const = 0; - virtual std::string readyUpdateVersion() const = 0; - - //! Install the already-downloaded update (asks to restart, then applies it). - virtual void installReadyUpdate() = 0; - //! Show the release notes of the ready update, with install/skip actions. - virtual void showReadyUpdateInfo() = 0; - //! Hide the ready update banner for this session (the package is kept). - virtual void dismissReadyUpdate() = 0; - - //! This launch is the first one after an update was installed. - virtual bool hasCompletedUpdate() const = 0; - virtual async::Notification hasCompletedUpdateChanged() const = 0; - virtual void dismissCompletedUpdate() = 0; }; } diff --git a/framework/update/iappupdateservice.h b/framework/update/iappupdateservice.h index 8a4e822489..adebe6cdf7 100644 --- a/framework/update/iappupdateservice.h +++ b/framework/update/iappupdateservice.h @@ -42,13 +42,12 @@ class IAppUpdateService : MODULE_CONTEXT_INTERFACE virtual const RetVal& lastCheckResult() const = 0; virtual RetVal downloadRelease() = 0; - virtual bool isReleaseDownloaded() const = 0; + virtual bool isReleaseReadyToInstall() const = 0; virtual muse::io::path_t downloadedReleasePath() const = 0; virtual void removeDownloadedRelease() = 0; virtual bool canAutoInstall() const = 0; - virtual RetVal prepareUpdate(const muse::io::path_t& packagePath) = 0; - virtual Ret finalizeUpdate(const muse::io::path_t& preparedPath) = 0; + virtual Ret installUpdate() = 0; }; } diff --git a/framework/update/internal/appupdatescenario.cpp b/framework/update/internal/appupdatescenario.cpp index 795b0255b9..5bacec864d 100644 --- a/framework/update/internal/appupdatescenario.cpp +++ b/framework/update/internal/appupdatescenario.cpp @@ -26,9 +26,6 @@ #include "updateerrors.h" -#include "async/async.h" -#include "global/concurrency/concurrent.h" -#include "runtime.h" #include "types/val.h" #include "translation.h" #include "log.h" @@ -38,7 +35,7 @@ using namespace muse::update; using namespace muse::actions; using namespace muse::async; -void AppUpdateScenario::init() +void AppUpdateScenario::delayedInit() { const std::string installing = configuration()->installingReleaseVersion(); if (installing.empty()) { @@ -48,7 +45,11 @@ void AppUpdateScenario::init() configuration()->setInstallingReleaseVersion(std::string()); //! NOTE: The version differs if the user canceled the installer or it failed. - m_hasCompletedUpdate = Version(installing) == application()->fullVersion(); + bool hasCompletedUpdate = Version(installing) == application()->fullVersion(); + + if (hasCompletedUpdate) { + showUpdateCompletedToast(); + } } bool AppUpdateScenario::needCheckForUpdate() const @@ -73,7 +74,7 @@ void AppUpdateScenario::checkForUpdate(bool manual) } else if (!res.ret) { showServerErrorMsg(); } else { - showReleaseInfo(res.val); + showReleaseInfo(res.val, service()->isReleaseReadyToInstall()); } } else if (!res.ret && !noUpdate) { LOGE() << res.ret.toString(); @@ -81,8 +82,12 @@ void AppUpdateScenario::checkForUpdate(bool manual) m_checkInProgress = false; - if (!manual && res.ret) { - downloadUpdateInBackground(); + if (!manual && res.ret && hasUpdate()) { + if (configuration()->autoUpdateEnabled()) { + downloadUpdateInBackground(); + } else { + showUpdateAvailableToast(res.val, /*downloaded*/ false); + } } }); } @@ -107,11 +112,17 @@ bool AppUpdateScenario::hasUpdate() const Promise AppUpdateScenario::processUpdateError(const Ret& error) { + const int errorCode = error.code(); + if (errorCode == static_cast(Ret::Code::Cancel)) { + return async::make_promise([](auto resolve, auto) { + return resolve(muse::make_ret(Ret::Code::Cancel)); + }); + } + const auto unknownError = async::make_promise([](auto resolve, auto) { return resolve(muse::make_ret(Ret::Code::UnknownError)); }); - const int errorCode = error.code(); IF_ASSERT_FAILED(errorCode >= static_cast(Ret::Code::UpdateFirst) && errorCode <= static_cast(Ret::Code::UpdateLast)) { return unknownError; @@ -144,16 +155,19 @@ Promise AppUpdateScenario::showNoUpdateMsg() IInteractive::Option::WithIcon); } -Promise AppUpdateScenario::showReleaseInfo(const ReleaseInfo& info) +Promise AppUpdateScenario::showReleaseInfo(const ReleaseInfo& info, bool readyToInstall) { UriQuery query("muse://update/appreleaseinfo"); query.addParam("appName", Val(application()->title().toStdString())); + query.addParam("version", Val(info.version)); query.addParam("notes", Val(info.notes)); query.addParam("previousReleasesNotes", Val(releasesNotesToValList(info.previousReleasesNotes))); + query.addParam("readyToInstall", Val(readyToInstall)); + query.addParam("autoUpdateEnabled", Val(configuration()->autoUpdateEnabled())); - return interactive()->open(query).then(this, [this, info](const Val& val, auto resolve) { - const QString actionCode = val.toQString(); - if (actionCode == "remindLater") { + return interactive()->open(query).then(this, [this, info, readyToInstall](const Val& val, auto resolve) { + const std::string actionCode = applyReleaseInfoResult(val); + if (actionCode == "close") { return resolve(muse::make_ret(Ret::Code::Cancel)); } @@ -162,7 +176,15 @@ Promise AppUpdateScenario::showReleaseInfo(const ReleaseInfo& info) return resolve(muse::make_ret(Ret::Code::Cancel)); } - downloadRelease().onResolve(this, [resolve](const Ret& ret) { + //! NOTE: The user wants this release after all (e.g. chose it in a manual check after skipping it) + unskipRelease(info.version); + + if (actionCode == "remindLater") { + return resolve(muse::make_ret(Ret::Code::Cancel)); + } + + //! NOTE: "Restart & update" already tells that the app restarts, no need to ask again + downloadRelease(/*confirmRestart*/ !readyToInstall).onResolve(this, [resolve](const Ret& ret) { (void)resolve(ret); }); @@ -170,6 +192,51 @@ Promise AppUpdateScenario::showReleaseInfo(const ReleaseInfo& info) }); } +std::string AppUpdateScenario::applyReleaseInfoResult(const Val& result) +{ + const ValMap map = result.toMap(); + + const auto autoUpdate = map.find("autoUpdateEnabled"); + if (autoUpdate != map.end()) { + configuration()->setAutoUpdateEnabled(autoUpdate->second.toBool()); + } + + const auto action = map.find("action"); + return action != map.end() ? action->second.toString() : std::string(); +} + +void AppUpdateScenario::showUpdateCompletedToast() +{ + const std::string title = muse::qtrc("update", "Updated to %1 %2") + .arg(application()->title().toQString(), application()->fullVersion().toString().toQString()) + .toStdString(); + + toastService()->showWithTimeout(title, std::string(), std::chrono::seconds(10), muse::ui::IconCode::Code::TICK_FILLED); +} + +void AppUpdateScenario::showUpdateAvailableToast(const ReleaseInfo& info, bool downloaded) +{ + constexpr int seeDetailsBtn = int(toast::ToastActionCode::Custom) + 1; + constexpr int installBtn = int(toast::ToastActionCode::Custom) + 2; + + const std::string msg = muse::qtrc("update", "%1 %2 is now ready to install.") + .arg(application()->title().toQString(), QString::fromStdString(info.version)).toStdString(); + + toastService()->show(muse::trc("update", "New update available"), msg, + muse::ui::IconCode::Code::INFO_FILLED, true, + { + { muse::trc("update", "See details"), seeDetailsBtn }, + { downloaded ? muse::trc("update", "Restart & update") : muse::trc("update", "Install update"), installBtn, /*accent*/ true }, + }).onResolve(this, [this, info, downloaded](const toast::ToastResult& result) { + if (result.isCode(seeDetailsBtn)) { + showReleaseInfo(info, downloaded).onResolve(this, [](const Ret&) {}); + } else if (result.isCode(installBtn)) { + //! NOTE: "Restart & update" already tells that the app restarts, no need to ask again + downloadRelease(/*confirmRestart*/ !downloaded).onResolve(this, [](const Ret&) {}); + } + }); +} + Promise AppUpdateScenario::showServerErrorMsg() { return interactive()->error(muse::trc("update", "Cannot connect to server"), @@ -201,128 +268,64 @@ Promise AppUpdateScenario::askToRetryOnNotEnoughDiskSpace(const Ret& error, }); } -Promise AppUpdateScenario::downloadRelease() +Promise AppUpdateScenario::downloadRelease(bool confirmRestart) { - io::path_t packagePath = service()->downloadedReleasePath(); - - if (packagePath.empty()) { + if (!service()->isReleaseReadyToInstall()) { RetVal rv = interactive()->openSync("muse://update/app?mode=download"); if (rv.ret.code() == static_cast(Err::NotEnoughDiskSpace)) { - return askToRetryOnNotEnoughDiskSpace(rv.ret, [this]() { return downloadRelease(); }); + return askToRetryOnNotEnoughDiskSpace(rv.ret, [this, confirmRestart]() { return downloadRelease(confirmRestart); }); } if (!rv.ret) { return processUpdateError(rv.ret); } - packagePath = rv.val.toString(); } - //! NOTE: In-place auto-install currently supports a single window only; - //! otherwise fall back to handing the installer to the user. - if (service()->canAutoInstall() && multiwindowsProvider()->windowCount() == 1) { - return prepareAndInstall(packagePath); + if (confirmRestart) { + return askToCloseAppAndCompleteInstall(); } - return askToCloseAppAndCompleteInstall(packagePath); -} - -Promise AppUpdateScenario::prepareAndInstall(const io::path_t& packagePath) -{ - //! NOTE: The heavy phase (unpacking and verification) runs in the - //! background while the app keeps running, so failures can still fall - //! back to the manual flow; the confirmation dialog is shown once - //! everything is staged, making the restart itself instant. - return make_promise([this, packagePath](auto resolve, auto) { - auto service = this->service(); - - Concurrent::run([this, service, packagePath, resolve]() { - const RetVal prepared = service->prepareUpdate(packagePath); - async::Async::call(this, [this, packagePath, prepared, resolve]() { - auto complete = [resolve](const Ret& ret) { (void)resolve(ret); }; - if (!prepared.ret) { - if (prepared.ret.code() == static_cast(Err::NotEnoughDiskSpace)) { - askToRetryOnNotEnoughDiskSpace(prepared.ret, [this, packagePath]() { - return prepareAndInstall(packagePath); - }).onResolve(this, complete); - return; - } - - LOGE() << "failed to prepare update, falling back to manual install: " << prepared.ret.toString(); - askToCloseAppAndCompleteInstall(packagePath).onResolve(this, complete); - return; - } - - askToRestartAndInstall(packagePath, prepared.val).onResolve(this, complete); - }, runtime::mainThreadId()); - }); + closeAppAndCompleteInstall(); - return Promise::dummy_result(); + return async::make_promise([](auto resolve, auto) { + return resolve(muse::make_ok()); }); } -Promise AppUpdateScenario::askToRestartAndInstall(const io::path_t& packagePath, const io::path_t& preparedPath) +Promise AppUpdateScenario::askToCloseAppAndCompleteInstall() { - const std::string info = muse::qtrc("update", "%1 has downloaded an update and is ready to install it. " - "%1 will restart to complete the installation. " - "If you have any unsaved changes, you will be prompted to save them first.") + const std::string title = muse::trc("update", "Restart to finish updating"); + const std::string info = muse::qtrc("update", "%1 needs to close to complete the installation. " + "If you have any unsaved changes, you will be prompted to save them before %1 closes.") .arg(application()->title().toQString()).toStdString(); - const int restartBtn = int(IInteractive::Button::CustomButton) + 1; + const int restartBtn = int(IInteractive::Button::Apply); const IInteractive::ButtonDatas buttons = { interactive()->buttonData(IInteractive::Button::Cancel), IInteractive::ButtonData(restartBtn, muse::trc("update", "Restart"), true) }; - return interactive()->info("", info, buttons, restartBtn) - .then(this, [this, packagePath, preparedPath](const IInteractive::Result& res, auto resolve) { + return interactive()->info(title, info, buttons, restartBtn) + .then(this, [this](const IInteractive::Result& res, auto resolve) { if (res.isButton(IInteractive::Button::Cancel)) { return resolve(muse::make_ret(Ret::Code::Cancel)); } - const Ret ret = service()->finalizeUpdate(preparedPath); - if (!ret) { - LOGE() << "failed to finalize update, falling back to manual install: " << ret.toString(); - askToCloseAppAndCompleteInstall(packagePath).onResolve(this, [resolve](const Ret& r) { - (void)resolve(r); - }); - return Promise::dummy_result(); - } - - configuration()->setInstallingReleaseVersion(service()->lastCheckResult().val.version); - - //! NOTE: The helper has been spawned and will replace the app and - //! relaunch once we quit. Quit without an installer path so the - //! legacy "open installer" path is not taken. - dispatcher()->dispatch("quit", ActionData::make_arg2(false, std::string())); + closeAppAndCompleteInstall(); return resolve(muse::make_ok()); }); } -Promise AppUpdateScenario::askToCloseAppAndCompleteInstall(const io::path_t& packagePath) +void AppUpdateScenario::closeAppAndCompleteInstall() { - const std::string info = muse::qtrc("update", "%1 needs to close to complete the installation. " - "If you have any unsaved changes, you will be prompted to save them before %1 closes.") - .arg(application()->title().toQString()).toStdString(); - const int closeBtn = int(IInteractive::Button::CustomButton) + 1; - const IInteractive::ButtonDatas buttons = { - interactive()->buttonData(IInteractive::Button::Cancel), - IInteractive::ButtonData(closeBtn, muse::trc("update", "Close"), true) - }; - - return interactive()->info("", info, buttons, closeBtn) - .then(this, [this, packagePath](const IInteractive::Result& res, auto resolve) { - if (res.isButton(IInteractive::Button::Cancel)) { - return resolve(muse::make_ret(Ret::Code::Cancel)); - } + const io::path_t packagePath = service()->downloadedReleasePath(); - configuration()->setInstallingReleaseVersion(service()->lastCheckResult().val.version); + configuration()->setInstallingReleaseVersion(service()->lastCheckResult().val.version); - if (multiwindowsProvider()->windowCount() != 1) { - multiwindowsProvider()->quitAllAndRunInstallation(packagePath); - } + if (multiwindowsProvider()->windowCount() != 1) { + multiwindowsProvider()->quitAllAndRunInstallation(packagePath); + } - dispatcher()->dispatch("quit", ActionData::make_arg2(false, packagePath.toStdString())); - return resolve(muse::make_ok()); - }); + dispatcher()->dispatch("quit", ActionData::make_arg2(false, packagePath.toStdString())); } bool AppUpdateScenario::shouldIgnoreUpdate(const ReleaseInfo& info) const @@ -332,30 +335,11 @@ bool AppUpdateScenario::shouldIgnoreUpdate(const ReleaseInfo& info) const void AppUpdateScenario::downloadUpdateInBackground() { - if (m_bgDownloadInProgress || !m_readyPackagePath.empty()) { + if (m_bgDownloadInProgress) { return; } - if (!hasUpdate()) { - return; - } - - if (!configuration()->autoDownloadEnabled()) { - LOGI() << "background update download skipped: user has disabled"; - return; - } - - //! NOTE: This release was already downloaded in a previous session and is - //! waiting to be installed - surface it without downloading again. - if (service()->isReleaseDownloaded()) { - m_readyPackagePath = service()->downloadedReleasePath(); - m_readyUpdateVersion = service()->lastCheckResult().val.version; - m_readyUpdateDismissed = false; - m_hasReadyUpdateChanged.notify(); - return; - } - - if (networkInformation()->isMetered()) { + if (!service()->isReleaseReadyToInstall() && networkInformation()->isMetered()) { LOGI() << "background update download skipped: metered network connection"; return; } @@ -371,20 +355,14 @@ void AppUpdateScenario::downloadUpdateInBackground() progress.val.finished().onReceive(this, [this](const ProgressResult& res) { m_bgDownloadInProgress = false; - if (!res.ret) { - LOGE() << res.ret.toString(); - return; - } - //! NOTE: The release may have been skipped while the download was running. if (!hasUpdate()) { return; } - m_readyPackagePath = res.val.toString(); - m_readyUpdateVersion = service()->lastCheckResult().val.version; - m_readyUpdateDismissed = false; - m_hasReadyUpdateChanged.notify(); + if (res.ret) { + showUpdateAvailableToast(service()->lastCheckResult().val, /*downloaded*/ true); + } }, Asyncable::Mode::SetReplace); } @@ -392,95 +370,11 @@ void AppUpdateScenario::skipRelease(const std::string& version) { configuration()->setSkippedReleaseVersion(version); service()->removeDownloadedRelease(); - - m_readyPackagePath = io::path_t(); - m_hasReadyUpdateChanged.notify(); -} - -bool AppUpdateScenario::hasCompletedUpdate() const -{ - return m_hasCompletedUpdate; -} - -async::Notification AppUpdateScenario::hasCompletedUpdateChanged() const -{ - return m_hasCompletedUpdateChanged; } -void AppUpdateScenario::dismissCompletedUpdate() +void AppUpdateScenario::unskipRelease(const std::string& version) { - if (!m_hasCompletedUpdate) { - return; + if (configuration()->skippedReleaseVersion() == version) { + configuration()->setSkippedReleaseVersion(std::string()); } - - m_hasCompletedUpdate = false; - m_hasCompletedUpdateChanged.notify(); -} - -bool AppUpdateScenario::hasReadyUpdate() const -{ - return !m_readyPackagePath.empty() && !m_readyUpdateDismissed; -} - -async::Notification AppUpdateScenario::hasReadyUpdateChanged() const -{ - return m_hasReadyUpdateChanged; -} - -std::string AppUpdateScenario::readyUpdateVersion() const -{ - return m_readyUpdateVersion; -} - -void AppUpdateScenario::installReadyUpdate() -{ - if (m_readyPackagePath.empty()) { - return; - } - - if (!service()->canAutoInstall() || multiwindowsProvider()->windowCount() != 1) { - askToCloseAppAndCompleteInstall(m_readyPackagePath).onResolve(this, [](const Ret&) {}); - return; - } - - prepareAndInstall(m_readyPackagePath).onResolve(this, [](const Ret&) {}); -} - -void AppUpdateScenario::showReadyUpdateInfo() -{ - if (m_readyPackagePath.empty()) { - return; - } - - const ReleaseInfo& info = service()->lastCheckResult().val; - - UriQuery query("muse://update/appreleaseinfo"); - query.addParam("appName", Val(application()->title().toStdString())); - query.addParam("notes", Val(info.notes)); - query.addParam("previousReleasesNotes", Val(releasesNotesToValList(info.previousReleasesNotes))); - query.addParam("version", Val(m_readyUpdateVersion)); - query.addParam("readyToInstall", Val(true)); - - interactive()->open(query).onResolve(this, [this](const Val& val) { - const QString actionCode = val.toQString(); - - if (actionCode == "skip") { - skipRelease(m_readyUpdateVersion); - return; - } - - if (actionCode == "install") { - installReadyUpdate(); - } - }); -} - -void AppUpdateScenario::dismissReadyUpdate() -{ - if (m_readyPackagePath.empty() || m_readyUpdateDismissed) { - return; - } - - m_readyUpdateDismissed = true; - m_hasReadyUpdateChanged.notify(); } diff --git a/framework/update/internal/appupdatescenario.h b/framework/update/internal/appupdatescenario.h index 4a2274434f..a5f1851338 100644 --- a/framework/update/internal/appupdatescenario.h +++ b/framework/update/internal/appupdatescenario.h @@ -30,6 +30,7 @@ #include "actions/iactionsdispatcher.h" #include "multiwindows/imultiwindowsprovider.h" #include "network/inetworkinformation.h" +#include "toast/itoastservice.h" #include "update/iupdateconfiguration.h" #include "update/iappupdateservice.h" #include "global/iapplication.h" @@ -41,6 +42,7 @@ class AppUpdateScenario : public IAppUpdateScenario, public Contextable, public GlobalInject multiwindowsProvider; GlobalInject configuration; GlobalInject networkInformation; + GlobalInject toastService; ContextInject interactive = { this }; ContextInject dispatcher = { this }; ContextInject service = { this }; @@ -49,54 +51,37 @@ class AppUpdateScenario : public IAppUpdateScenario, public Contextable, public AppUpdateScenario(const modularity::ContextPtr& iocCtx) : Contextable(iocCtx) {} - void init(); + void delayedInit(); bool needCheckForUpdate() const override; void checkForUpdate(bool manual) override; - bool hasUpdate() const override; - - bool hasReadyUpdate() const override; - async::Notification hasReadyUpdateChanged() const override; - std::string readyUpdateVersion() const override; - - void installReadyUpdate() override; - void showReadyUpdateInfo() override; - void dismissReadyUpdate() override; - - bool hasCompletedUpdate() const override; - async::Notification hasCompletedUpdateChanged() const override; - void dismissCompletedUpdate() override; - private: friend class AppUpdateScenarioTests; + bool hasUpdate() const; + muse::async::Promise processUpdateError(const Ret& error); async::Promise showNoUpdateMsg(); - muse::async::Promise showReleaseInfo(const ReleaseInfo& info); + muse::async::Promise showReleaseInfo(const ReleaseInfo& info, bool readyToInstall); + void showUpdateAvailableToast(const ReleaseInfo& info, bool downloaded); + void showUpdateCompletedToast(); + std::string applyReleaseInfoResult(const Val& result); async::Promise showServerErrorMsg(); async::Promise askToRetryOnNotEnoughDiskSpace(const Ret& error, const std::function()>& retry); void downloadUpdateInBackground(); - muse::async::Promise downloadRelease(); - muse::async::Promise askToCloseAppAndCompleteInstall(const io::path_t& installerPath); - muse::async::Promise prepareAndInstall(const io::path_t& packagePath); - muse::async::Promise askToRestartAndInstall(const io::path_t& packagePath, const io::path_t& preparedPath); + muse::async::Promise downloadRelease(bool confirmRestart = true); + muse::async::Promise askToCloseAppAndCompleteInstall(); + void closeAppAndCompleteInstall(); bool shouldIgnoreUpdate(const ReleaseInfo& info) const; void skipRelease(const std::string& version); + void unskipRelease(const std::string& version); bool m_checkInProgress = false; - bool m_bgDownloadInProgress = false; - io::path_t m_readyPackagePath; - bool m_readyUpdateDismissed = false; - std::string m_readyUpdateVersion; - async::Notification m_hasReadyUpdateChanged; - - bool m_hasCompletedUpdate = false; - async::Notification m_hasCompletedUpdateChanged; }; } diff --git a/framework/update/internal/appupdateservice.cpp b/framework/update/internal/appupdateservice.cpp index d89252f89f..82c382efad 100644 --- a/framework/update/internal/appupdateservice.cpp +++ b/framework/update/internal/appupdateservice.cpp @@ -35,6 +35,9 @@ #include "downloadfiledevice.h" +#include "async/async.h" +#include "global/concurrency/concurrent.h" +#include "runtime.h" #include "defer.h" #include "translation.h" #include "log.h" @@ -57,6 +60,11 @@ static constexpr uint64_t DISK_SPACE_RESERVE = 100ull * 1024 * 1024; static constexpr uint64_t MAX_PACKAGE_SIZE = 500ull * 1024 * 1024; +//! NOTE: The download takes the first 90% of the progress, the preparation +//! for in-place install takes the rest. +static constexpr int64_t PROGRESS_TOTAL = 1000; +static constexpr int64_t PROGRESS_DOWNLOAD_PART = 900; + static QDate calculateWeekBeginForDate(const QDate& date) { // 1 (Monday) + 6 mod 7 = 0 @@ -246,9 +254,19 @@ RetVal AppUpdateService::downloadRelease() return RetVal::make_ret(make_ret(Err::NoUpdate)); } - const QUrl fileUrl = QUrl::fromUserInput(QString::fromStdString(info.fileUrl)); - const path_t finalPath = packagesDir() + "/" + info.fileName; + + m_updateProgress.canceled().disconnect(this); + + //! NOTE: Already downloaded (e.g. in a previous session), only the preparation is left + if (fileSystem()->exists(finalPath)) { + m_downloadInProgress = true; + m_updateProgress.start(); + prepareUpdate(finalPath); + return RetVal::make_ok(m_updateProgress); + } + + const QUrl fileUrl = QUrl::fromUserInput(QString::fromStdString(info.fileUrl)); const path_t partialPath = finalPath + PARTIAL_SUFFIX; fileSystem()->makePath(muse::io::absoluteDirpath(partialPath)); @@ -293,14 +311,19 @@ RetVal AppUpdateService::downloadRelease() }, Asyncable::Mode::SetReplace); downloadProgress.val.progressChanged().onReceive(this, [this, offset](int64_t current, int64_t total, const std::string& msg) { - m_updateProgress.progress(static_cast(offset) + current, static_cast(offset) + total, msg); + const int64_t downloaded = static_cast(offset) + current; + const int64_t size = static_cast(offset) + total; + if (size <= 0) { + return; + } + + m_updateProgress.progress(downloaded * PROGRESS_DOWNLOAD_PART / size, PROGRESS_TOTAL, msg); }, Asyncable::Mode::SetReplace); downloadProgress.val.finished().onReceive(this, [this, finalPath, partialPath, offset](const ProgressResult& res) { - m_downloadInProgress = false; - if (!res.ret) { //! NOTE: Keep the partial file so the next attempt can resume from it. + m_downloadInProgress = false; m_updateProgress.finish(ProgressResult::make_ret(res.ret)); return; } @@ -312,6 +335,7 @@ RetVal AppUpdateService::downloadRelease() //! it so the next attempt starts clean. if (offset > 0 && (status == 200 || status == 416)) { fileSystem()->remove(partialPath); + m_downloadInProgress = false; m_updateProgress.finish(ProgressResult::make_ret(make_ret(Err::NetworkError, "range request not honoured"))); return; } @@ -320,16 +344,98 @@ RetVal AppUpdateService::downloadRelease() //! to the final package name. const Ret ret = fileSystem()->move(partialPath, finalPath, /*replace*/ true); if (!ret) { + m_downloadInProgress = false; m_updateProgress.finish(ProgressResult::make_ret(ret)); return; } - m_updateProgress.finish(ProgressResult::make_ok(Val(finalPath))); + m_updateProgress.canceled().disconnect(this); + prepareUpdate(finalPath); }, Asyncable::Mode::SetReplace); return RetVal::make_ok(m_updateProgress); } +void AppUpdateService::prepareUpdate(const muse::io::path_t& packagePath) +{ + resetPreparedUpdate(); + + async::Async::call(this, [this, packagePath]() { + if (m_updateProgress.isCanceled()) { + m_downloadInProgress = false; + return; + } + + if (!canAutoInstall()) { + m_downloadInProgress = false; + m_updateProgress.progress(PROGRESS_TOTAL, PROGRESS_TOTAL); + m_updateProgress.finish(ProgressResult::make_ok(Val(packagePath))); + return; + } + + m_updateProgress.progress(PROGRESS_DOWNLOAD_PART, PROGRESS_TOTAL, + muse::qtrc("update", "Preparing %1 %2") + .arg(application()->title().toQString(), QString::fromStdString(m_lastCheckResult.val.version)) + .toStdString()); + + RetVal packageSize = fileSystem()->fileSize(packagePath); + const Ret spaceRet = checkDiskSpace(DiskSpaceFor::Unpack, packageSize.ret ? packageSize.val : 0); + if (!spaceRet) { + m_downloadInProgress = false; + m_updateProgress.finish(ProgressResult::make_ret(spaceRet)); + return; + } + + auto installer = updateInstaller(); + + Concurrent::run([this, installer, packagePath]() { + const RetVal prepared = installer->prepareUpdate(packagePath); + + async::Async::call(this, [this, packagePath, prepared]() { + m_downloadInProgress = false; + + if (m_updateProgress.isCanceled()) { + if (prepared.ret && prepared.val != packagePath) { + fileSystem()->remove(prepared.val); + } + return; + } + + if (prepared.ret) { + m_preparedPackagePath = packagePath; + m_preparedPath = prepared.val; + } else { + //! NOTE: The package is downloaded anyway, so it can still be installed manually + LOGE() << "failed to prepare update: " << prepared.ret.toString(); + } + + m_updateProgress.progress(PROGRESS_TOTAL, PROGRESS_TOTAL); + m_updateProgress.finish(ProgressResult::make_ok(Val(packagePath))); + }, runtime::mainThreadId()); + }); + }); +} + +muse::io::path_t AppUpdateService::preparedUpdatePath() const +{ + if (m_preparedPath.empty() || m_preparedPackagePath != downloadedReleasePath()) { + return {}; + } + + //! NOTE: The prepared files may have been removed since + if (!fileSystem()->exists(m_preparedPath)) { + return {}; + } + + return m_preparedPath; +} + +void AppUpdateService::resetPreparedUpdate() +{ + m_preparedPackagePath = io::path_t(); + m_preparedPath = io::path_t(); +} + Ret AppUpdateService::checkDiskSpace(DiskSpaceFor purpose, uint64_t packageSize, uint64_t downloadedBytes) const { if (packageSize == 0) { @@ -518,19 +624,14 @@ bool AppUpdateService::canAutoInstall() const return updateInstaller()->isInPlaceUpdateSupported(); } -RetVal AppUpdateService::prepareUpdate(const muse::io::path_t& packagePath) +Ret AppUpdateService::installUpdate() { - RetVal packageSize = fileSystem()->fileSize(packagePath); - Ret ret = checkDiskSpace(DiskSpaceFor::Unpack, packageSize.ret ? packageSize.val : 0); - if (!ret) { - return RetVal(ret); + const io::path_t preparedPath = preparedUpdatePath(); + if (preparedPath.empty()) { + LOGE() << "the update is not ready to install"; + return make_ret(Err::UnknownError); } - return updateInstaller()->prepareUpdate(packagePath); -} - -Ret AppUpdateService::finalizeUpdate(const muse::io::path_t& preparedPath) -{ return updateInstaller()->finalizeUpdate(preparedPath, makeInstallProgressUi()); } @@ -634,6 +735,10 @@ void AppUpdateService::cleanupStalePackages(const std::string& keepFileName) configuration()->setLastDownloadedPackagePath(io::path_t()); } + if (io::filename(m_preparedPackagePath).toStdString() != keepFileName) { + resetPreparedUpdate(); + } + const io::path_t dir = configuration()->updateDataPath(); if (!fileSystem()->exists(dir)) { return; @@ -648,17 +753,29 @@ void AppUpdateService::cleanupStalePackages(const std::string& keepFileName) //! interrupted download of the current release can still be resumed. const std::string keepPartial = keepFileName + PARTIAL_SUFFIX; + //! NOTE: Also keep the update prepared for the kept package + const std::string prepared = m_preparedPath.toStdString(); + auto isPrepared = [&prepared](const io::path_t& entry) { + const std::string path = entry.toStdString(); + return !prepared.empty() && (prepared == path || prepared.rfind(path + "/", 0) == 0); + }; + for (const io::path_t& entry : entries.val) { const std::string name = io::filename(entry).toStdString(); - if (name != keepFileName && name != keepPartial) { + if (name != keepFileName && name != keepPartial && !isPrepared(entry)) { fileSystem()->remove(entry); } } } -bool AppUpdateService::isReleaseDownloaded() const +bool AppUpdateService::isReleaseReadyToInstall() const { - return !downloadedReleasePath().empty(); + if (downloadedReleasePath().empty()) { + return false; + } + + //! NOTE: In-place install also needs the downloaded release to be prepared + return !canAutoInstall() || !preparedUpdatePath().empty(); } io::path_t AppUpdateService::downloadedReleasePath() const diff --git a/framework/update/internal/appupdateservice.h b/framework/update/internal/appupdateservice.h index 49d22a0736..d810ef24bf 100644 --- a/framework/update/internal/appupdateservice.h +++ b/framework/update/internal/appupdateservice.h @@ -59,10 +59,9 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as RetVal downloadRelease() override; bool canAutoInstall() const override; - RetVal prepareUpdate(const muse::io::path_t& packagePath) override; - Ret finalizeUpdate(const muse::io::path_t& preparedPath) override; + Ret installUpdate() override; - bool isReleaseDownloaded() const override; + bool isReleaseReadyToInstall() const override; muse::io::path_t downloadedReleasePath() const override; void removeDownloadedRelease() override; @@ -87,6 +86,10 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as }; Ret checkDiskSpace(DiskSpaceFor purpose, uint64_t packageSize, uint64_t downloadedBytes = 0) const; + void prepareUpdate(const muse::io::path_t& packagePath); + muse::io::path_t preparedUpdatePath() const; + void resetPreparedUpdate(); + InstallProgressUi makeInstallProgressUi() const; //! Ordered list of acceptable asset suffixes for this platform, most @@ -105,6 +108,9 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as RetVal m_lastCheckResult; network::INetworkManagerPtr m_networkManager; Progress m_updateProgress; + bool m_downloadInProgress = false; + muse::io::path_t m_preparedPackagePath; + muse::io::path_t m_preparedPath; }; } diff --git a/framework/update/internal/updateconfiguration.cpp b/framework/update/internal/updateconfiguration.cpp index 303c7b8c42..6b0c5d875a 100644 --- a/framework/update/internal/updateconfiguration.cpp +++ b/framework/update/internal/updateconfiguration.cpp @@ -35,7 +35,7 @@ static const Settings::Key ALLOW_UPDATE_ON_PRERELEASE(module_name, "application/ static const Settings::Key SKIPPED_VERSION_KEY(module_name, "application/skippedVersion"); static const Settings::Key INSTALLING_VERSION_KEY(module_name, "application/installingVersion"); static const Settings::Key LAST_DOWNLOADED_PACKAGE_KEY(module_name, "application/lastDownloadedPackage"); -static const Settings::Key AUTO_DOWNLOAD_KEY(module_name, "application/autoDownload"); +static const Settings::Key AUTO_UPDATE_KEY(module_name, "application/autoUpdate"); void UpdateConfiguration::init() { @@ -54,7 +54,7 @@ void UpdateConfiguration::init() #endif settings()->setDefaultValue(ALLOW_UPDATE_ON_PRERELEASE, Val(allowUpdateOnPreRelease)); - settings()->setDefaultValue(AUTO_DOWNLOAD_KEY, Val(true)); + settings()->setDefaultValue(AUTO_UPDATE_KEY, Val(true)); } bool UpdateConfiguration::isAppUpdatable() const @@ -87,14 +87,14 @@ async::Notification UpdateConfiguration::needCheckForUpdateChanged() const return m_needCheckForUpdateChanged; } -bool UpdateConfiguration::autoDownloadEnabled() const +bool UpdateConfiguration::autoUpdateEnabled() const { - return settings()->value(AUTO_DOWNLOAD_KEY).toBool(); + return settings()->value(AUTO_UPDATE_KEY).toBool(); } -void UpdateConfiguration::setAutoDownloadEnabled(bool enabled) +void UpdateConfiguration::setAutoUpdateEnabled(bool enabled) { - settings()->setSharedValue(AUTO_DOWNLOAD_KEY, Val(enabled)); + settings()->setSharedValue(AUTO_UPDATE_KEY, Val(enabled)); } std::string UpdateConfiguration::skippedReleaseVersion() const diff --git a/framework/update/internal/updateconfiguration.h b/framework/update/internal/updateconfiguration.h index d2c8b3814c..b91c590d27 100644 --- a/framework/update/internal/updateconfiguration.h +++ b/framework/update/internal/updateconfiguration.h @@ -52,8 +52,8 @@ class UpdateConfiguration : public IUpdateConfiguration, public Contextable, pub void setNeedCheckForUpdate(bool needCheck) override; muse::async::Notification needCheckForUpdateChanged() const override; - bool autoDownloadEnabled() const override; - void setAutoDownloadEnabled(bool enabled) override; + bool autoUpdateEnabled() const override; + void setAutoUpdateEnabled(bool enabled) override; std::string skippedReleaseVersion() const override; void setSkippedReleaseVersion(const std::string& version) override; diff --git a/framework/update/iupdateconfiguration.h b/framework/update/iupdateconfiguration.h index 84fbfd53b6..b6c4146f38 100644 --- a/framework/update/iupdateconfiguration.h +++ b/framework/update/iupdateconfiguration.h @@ -45,8 +45,8 @@ class IUpdateConfiguration : MODULE_GLOBAL_INTERFACE virtual void setNeedCheckForUpdate(bool needCheck) = 0; virtual muse::async::Notification needCheckForUpdateChanged() const = 0; - virtual bool autoDownloadEnabled() const = 0; - virtual void setAutoDownloadEnabled(bool enabled) = 0; + virtual bool autoUpdateEnabled() const = 0; + virtual void setAutoUpdateEnabled(bool enabled) = 0; virtual std::string skippedReleaseVersion() const = 0; virtual void setSkippedReleaseVersion(const std::string& version) = 0; diff --git a/framework/update/qml/Muse/Update/AppReleaseInfoDialog.qml b/framework/update/qml/Muse/Update/AppReleaseInfoDialog.qml index cba6f028a9..d7a9c45cf6 100644 --- a/framework/update/qml/Muse/Update/AppReleaseInfoDialog.qml +++ b/framework/update/qml/Muse/Update/AppReleaseInfoDialog.qml @@ -32,15 +32,40 @@ StyledDialogView { property string appName: "" property string version: "" - property bool readyToInstall: false + property alias readyToInstall: buttons.readyToInstall property alias notes: view.notes property alias previousReleasesNotes: view.previousReleasesNotes + property alias autoUpdateEnabled: autoUpdateSetting.checked contentWidth: 644 contentHeight: 474 margins: 22 + property bool isFinished: false + + function setResult(action) { + root.isFinished = true + root.ret = { + errcode: 0, + value: { + action: action, + autoUpdateEnabled: root.autoUpdateEnabled + } + } + } + + function finish(action) { + root.setResult(action) + root.hide() + } + + onAboutToClose: { + if (!root.isFinished) { + root.setResult("close") + } + } + onNavigationActivateRequested: { buttons.focusOnFirst() } @@ -56,61 +81,28 @@ StyledDialogView { spacing: 24 AccessibleItem { - id: accessibleInfo - - visualItem: content - role: MUAccessible.Button - name: releaseTitleLabel.text + " " + view.notes + " " + buttons.defaultButtonName + id: accessibleInfo - function readInfo() { - accessibleInfo.ignored = false - accessibleInfo.focused = true - } + visualItem: content + role: MUAccessible.Button + name: releaseTitleLabel.text + " " + view.notes + " " + buttons.defaultButtonName - function resetFocus() { - accessibleInfo.ignored = true - accessibleInfo.focused = false - } - } - - Column { - Layout.alignment: Qt.AlignTop - - spacing: 8 - - StyledTextLabel { - id: releaseTitleLabel - - text: root.readyToInstall - ? qsTrc("update", "A new update is ready to install") - : qsTrc("update", "A new version of %1 is available!").arg(root.appName) - font: ui.theme.headerBoldFont + function readInfo() { + accessibleInfo.ignored = false + accessibleInfo.focused = true } - StyledTextLabel { - id: releaseDescriptionLabel - - width: content.width - - visible: root.readyToInstall - - text: qsTrc("update", "%1 has downloaded an update and is ready to install. " - + "%1 will restart to complete the installation. " - + "If you have any unsaved changes, you will be prompted to save them first.") - .arg(root.appName) - horizontalAlignment: Qt.AlignLeft - wrapMode: Text.WordWrap + function resetFocus() { + accessibleInfo.ignored = true + accessibleInfo.focused = false } + } - StyledTextLabel { - id: releaseNotesLabel - - visible: !root.readyToInstall + StyledTextLabel { + id: releaseTitleLabel - text: qsTrc("update", "Release notes") - font: ui.theme.largeBodyBoldFont - horizontalAlignment: Qt.AlignLeft - } + text: qsTrc("update", "%1 %2 is available!").arg(root.appName).arg(root.version) + font: ui.theme.headerBoldFont } SeparatorLine { @@ -125,11 +117,7 @@ StyledDialogView { spacing: 12 StyledTextLabel { - visible: root.readyToInstall - - text: root.version.length > 0 - ? qsTrc("update", "%1 Release notes").arg(root.version) - : qsTrc("update", "Release notes") + text: qsTrc("update", "Release notes") font: ui.theme.largeBodyBoldFont horizontalAlignment: Qt.AlignLeft } @@ -147,6 +135,22 @@ StyledDialogView { Layout.rightMargin: -root.margins } + AutoUpdateSetting { + id: autoUpdateSetting + + Layout.fillWidth: true + + appName: root.appName + + navigationPanel.section: root.navigationSection + navigationPanel.order: 2 + } + + SeparatorLine { + Layout.leftMargin: -root.margins + Layout.rightMargin: -root.margins + } + AppReleaseInfoBottomPanel { id: buttons @@ -154,22 +158,21 @@ StyledDialogView { Layout.preferredHeight: childrenRect.height Layout.alignment: Qt.AlignBottom + isRemindMeLaterButtonEnabled: !root.autoUpdateEnabled + navigationPanel.section: root.navigationSection navigationPanel.order: 1 onRemindLaterRequested: { - root.ret = { errcode: 0, value: "remindLater" } - root.hide() + root.finish("remindLater") } onInstallRequested: { - root.ret = { errcode: 0, value: "install" } - root.hide() + root.finish("install") } onSkipRequested: { - root.ret = { errcode: 0, value: "skip" } - root.hide() + root.finish("skip") } } } diff --git a/framework/update/qml/Muse/Update/CMakeLists.txt b/framework/update/qml/Muse/Update/CMakeLists.txt index e2780d173a..c97831aa22 100644 --- a/framework/update/qml/Muse/Update/CMakeLists.txt +++ b/framework/update/qml/Muse/Update/CMakeLists.txt @@ -26,16 +26,12 @@ qt_add_qml_module(muse_update_qml SOURCES appupdatemodel.cpp appupdatemodel.h - updatebannermodel.cpp - updatebannermodel.h QML_FILES AppReleaseInfoDialog.qml AppUpdateProgressDialog.qml - UpdateBanner.qml internal/AppReleaseInfoBottomPanel.qml + internal/AutoUpdateSetting.qml internal/ReleaseNotesView.qml - internal/UpdateCompletedContent.qml - internal/UpdateReadyContent.qml IMPORTS TARGET muse_ui_qml TARGET muse_uicomponents_qml diff --git a/framework/update/qml/Muse/Update/UpdateBanner.qml b/framework/update/qml/Muse/Update/UpdateBanner.qml deleted file mode 100644 index eb4d917afd..0000000000 --- a/framework/update/qml/Muse/Update/UpdateBanner.qml +++ /dev/null @@ -1,96 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-only - * MuseScore-Studio-CLA-applies - * - * MuseScore Studio - * Music Composition & Notation - * - * Copyright (C) 2026 MuseScore Limited and others - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 3 as - * published by the Free Software Foundation. - * - * 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 - -import Muse.Ui -import Muse.UiComponents -import Muse.Update - -import "internal" - -Rectangle { - id: root - - readonly property bool hasReadyUpdate: updateBannerModel.updateReady - readonly property bool hasCompletedUpdate: updateBannerModel.updateCompleted - - implicitHeight: loader.implicitHeight + 24 - - visible: loader.sourceComponent !== null - - radius: 4 - color: ui.theme.backgroundPrimaryColor - border.width: 1 - border.color: ui.theme.accentColor - - UpdateBannerModel { - id: updateBannerModel - } - - Component.onCompleted: { - updateBannerModel.load() - } - - Loader { - id: loader - - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 12 - - sourceComponent: root.hasReadyUpdate ? readyContent - : root.hasCompletedUpdate ? completedContent - : null - } - - Component { - id: readyContent - - UpdateReadyContent { - appName: updateBannerModel.appName - updateVersion: updateBannerModel.updateVersion - - onDetailsRequested: { - updateBannerModel.showDetails() - } - - onInstallRequested: { - updateBannerModel.install() - } - - onDismissRequested: { - updateBannerModel.dismissReady() - } - } - } - - Component { - id: completedContent - - UpdateCompletedContent { - onDismissRequested: { - updateBannerModel.dismissCompleted() - } - } - } -} diff --git a/framework/update/qml/Muse/Update/appupdatemodel.cpp b/framework/update/qml/Muse/Update/appupdatemodel.cpp index 34395b45f5..6c67638817 100644 --- a/framework/update/qml/Muse/Update/appupdatemodel.cpp +++ b/framework/update/qml/Muse/Update/appupdatemodel.cpp @@ -63,7 +63,11 @@ void AppUpdateModel::load(const QString& mode) setProgressTitle(muse::qtrc("update", "Downloading %1 %2") .arg(application()->title().toQString(), QString::fromStdString(info.val.version))); - m_progress.progressChanged().onReceive(this, [this](int64_t current, int64_t total, const std::string&) { + m_progress.progressChanged().onReceive(this, [this](int64_t current, int64_t total, const std::string& msg) { + if (!msg.empty()) { + setProgressTitle(QString::fromStdString(msg)); + } + setCurrentProgress(current); setTotalProgress(total); }); diff --git a/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml b/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml index f6cb5c0799..ab8e516664 100644 --- a/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml +++ b/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml @@ -29,6 +29,8 @@ RowLayout { id: root property string defaultButtonName: installButton.text + property bool readyToInstall: false + property alias isRemindMeLaterButtonEnabled: remindMeLaterButton.enabled property NavigationPanel navigationPanel: NavigationPanel { name: "UpdateBottomPanel" @@ -37,9 +39,9 @@ RowLayout { spacing: 12 - signal skipRequested() - signal remindLaterRequested() - signal installRequested() + signal skipRequested + signal remindLaterRequested + signal installRequested function focusOnFirst() { installButton.navigation.requestActive() @@ -65,12 +67,11 @@ RowLayout { } FlatButton { + id: remindMeLaterButton + Layout.alignment: Qt.AlignVCenter text: qsTrc("update", "Remind me later") - icon: IconCode.CLOCK - - orientation: Qt.Horizontal navigation.name: "RemindMeLaterButton" navigation.panel: root.navigationPanel @@ -86,8 +87,7 @@ RowLayout { Layout.alignment: Qt.AlignVCenter - text: qsTrc("update", "Update now") - icon: IconCode.IMPORT + text: root.readyToInstall ? qsTrc("update", "Restart & update") : qsTrc("update", "Install update") accentButton: true orientation: Qt.Horizontal diff --git a/framework/update/qml/Muse/Update/internal/UpdateCompletedContent.qml b/framework/update/qml/Muse/Update/internal/AutoUpdateSetting.qml similarity index 53% rename from framework/update/qml/Muse/Update/internal/UpdateCompletedContent.qml rename to framework/update/qml/Muse/Update/internal/AutoUpdateSetting.qml index ef3b8cbabf..399247062e 100644 --- a/framework/update/qml/Muse/Update/internal/UpdateCompletedContent.qml +++ b/framework/update/qml/Muse/Update/internal/AutoUpdateSetting.qml @@ -1,6 +1,6 @@ /* * SPDX-License-Identifier: GPL-3.0-only - * MuseScore-Studio-CLA-applies + * MuseScore-CLA-applies * * MuseScore Studio * Music Composition & Notation @@ -25,55 +25,42 @@ import QtQuick.Layouts import Muse.Ui import Muse.UiComponents -RowLayout { +ColumnLayout { id: root - signal dismissRequested() + property string appName: "" + property alias checked: toggle.checked - spacing: 12 + property NavigationPanel navigationPanel: NavigationPanel { + name: "AutoUpdatePanel" + direction: NavigationPanel.Vertical + } - Timer { - interval: 10000 - running: true + spacing: 8 - onTriggered: { - root.dismissRequested() - } - } + ToggleButton { + id: toggle - Rectangle { - Layout.preferredWidth: 24 - Layout.preferredHeight: 24 + Layout.fillWidth: true - radius: width / 2 - color: "#46A955" + text: qsTrc("update", "Download and install future %1 updates automatically").arg(root.appName) - StyledIconLabel { - anchors.centerIn: parent + navigation.name: "AutoUpdateToggle" + navigation.panel: root.navigationPanel + navigation.row: 0 - iconCode: IconCode.TICK_RIGHT_ANGLE_THICK - color: "white" + onToggled: { + checked = !checked } } StyledTextLabel { Layout.fillWidth: true - horizontalAlignment: Text.AlignLeft - wrapMode: Text.WordWrap + text: qsTrc("update", "You can change this anytime in Preferences > General.") - text: qsTrc("update", "Updated successfully") - font: ui.theme.bodyBoldFont - } - - FlatButton { - icon: IconCode.CLOSE_X_ROUNDED - transparent: true - - navigation.accessible.name: qsTrc("global", "Dismiss") - - onClicked: { - root.dismissRequested() - } + horizontalAlignment: Qt.AlignLeft + wrapMode: Text.WordWrap + opacity: 0.7 } } diff --git a/framework/update/qml/Muse/Update/internal/UpdateReadyContent.qml b/framework/update/qml/Muse/Update/internal/UpdateReadyContent.qml deleted file mode 100644 index db23e8831a..0000000000 --- a/framework/update/qml/Muse/Update/internal/UpdateReadyContent.qml +++ /dev/null @@ -1,105 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-only - * MuseScore-Studio-CLA-applies - * - * MuseScore Studio - * Music Composition & Notation - * - * Copyright (C) 2026 MuseScore Limited and others - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 3 as - * published by the Free Software Foundation. - * - * 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 -import QtQuick.Layouts - -import Muse.Ui -import Muse.UiComponents - -ColumnLayout { - id: root - - property string appName: "" - property string updateVersion: "" - - signal detailsRequested() - signal installRequested() - signal dismissRequested() - - spacing: 8 - - ColumnLayout { - spacing: 4 - - RowLayout { - Layout.fillWidth: true - - spacing: 8 - - StyledTextLabel { - Layout.fillWidth: true - - horizontalAlignment: Text.AlignLeft - - text: qsTrc("update", "Update available") - font: ui.theme.largeBodyBoldFont - } - - FlatButton { - icon: IconCode.CLOSE_X_ROUNDED - transparent: true - - navigation.accessible.name: qsTrc("global", "Dismiss") - - onClicked: { - root.dismissRequested() - } - } - } - - StyledTextLabel { - Layout.fillWidth: true - - horizontalAlignment: Text.AlignLeft - wrapMode: Text.WordWrap - - text: root.updateVersion.length > 0 ? root.appName + " " + root.updateVersion : root.appName - } - } - - RowLayout { - Layout.fillWidth: true - - spacing: 4 - - FlatButton { - Layout.fillWidth: true - - text: qsTrc("update", "See details") - - onClicked: { - root.detailsRequested() - } - } - - FlatButton { - Layout.fillWidth: true - - text: qsTrc("update", "Update") - accentButton: true - - onClicked: { - root.installRequested() - } - } - } -} diff --git a/framework/update/qml/Muse/Update/updatebannermodel.cpp b/framework/update/qml/Muse/Update/updatebannermodel.cpp deleted file mode 100644 index f0d588cda9..0000000000 --- a/framework/update/qml/Muse/Update/updatebannermodel.cpp +++ /dev/null @@ -1,80 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-only - * MuseScore-CLA-applies - * - * MuseScore Studio - * Music Composition & Notation - * - * Copyright (C) 2026 MuseScore Limited and others - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 3 as - * published by the Free Software Foundation. - * - * 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 "updatebannermodel.h" - -using namespace muse::update; - -UpdateBannerModel::UpdateBannerModel(QObject* parent) - : QObject(parent), Contextable(muse::iocCtxForQmlObject(this)) -{ -} - -void UpdateBannerModel::load() -{ - scenario()->hasReadyUpdateChanged().onNotify(this, [this]() { - emit updateReadyChanged(); - }); - - scenario()->hasCompletedUpdateChanged().onNotify(this, [this]() { - emit updateCompletedChanged(); - }); -} - -bool UpdateBannerModel::updateReady() const -{ - return scenario()->hasReadyUpdate(); -} - -QString UpdateBannerModel::updateVersion() const -{ - return QString::fromStdString(scenario()->readyUpdateVersion()); -} - -QString UpdateBannerModel::appName() const -{ - return application()->title().toQString(); -} - -void UpdateBannerModel::install() -{ - scenario()->installReadyUpdate(); -} - -void UpdateBannerModel::showDetails() -{ - scenario()->showReadyUpdateInfo(); -} - -void UpdateBannerModel::dismissReady() -{ - scenario()->dismissReadyUpdate(); -} - -bool UpdateBannerModel::updateCompleted() const -{ - return scenario()->hasCompletedUpdate(); -} - -void UpdateBannerModel::dismissCompleted() -{ - scenario()->dismissCompletedUpdate(); -} diff --git a/framework/update/qml/Muse/Update/updatebannermodel.h b/framework/update/qml/Muse/Update/updatebannermodel.h deleted file mode 100644 index e3bed5e10c..0000000000 --- a/framework/update/qml/Muse/Update/updatebannermodel.h +++ /dev/null @@ -1,67 +0,0 @@ -/* - * SPDX-License-Identifier: GPL-3.0-only - * MuseScore-CLA-applies - * - * MuseScore Studio - * Music Composition & Notation - * - * Copyright (C) 2026 MuseScore Limited and others - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License version 3 as - * published by the Free Software Foundation. - * - * 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 . - */ -#pragma once - -#include -#include - -#include "async/asyncable.h" - -#include "modularity/ioc.h" - -#include "global/iapplication.h" -#include "iappupdatescenario.h" - -namespace muse::update { -class UpdateBannerModel : public QObject, public Contextable, public async::Asyncable -{ - Q_OBJECT - - Q_PROPERTY(bool updateReady READ updateReady NOTIFY updateReadyChanged) - Q_PROPERTY(QString updateVersion READ updateVersion NOTIFY updateReadyChanged) - Q_PROPERTY(QString appName READ appName CONSTANT) - Q_PROPERTY(bool updateCompleted READ updateCompleted NOTIFY updateCompletedChanged) - - QML_ELEMENT - - GlobalInject application; - ContextInject scenario = { this }; - -public: - explicit UpdateBannerModel(QObject* parent = nullptr); - - Q_INVOKABLE void load(); - Q_INVOKABLE void install(); - Q_INVOKABLE void showDetails(); - Q_INVOKABLE void dismissReady(); - Q_INVOKABLE void dismissCompleted(); - - bool updateReady() const; - QString updateVersion() const; - QString appName() const; - bool updateCompleted() const; - -signals: - void updateReadyChanged(); - void updateCompletedChanged(); -}; -} diff --git a/framework/update/tests/appupdatescenario_tests.cpp b/framework/update/tests/appupdatescenario_tests.cpp index 049d8db103..23bebf1db2 100644 --- a/framework/update/tests/appupdatescenario_tests.cpp +++ b/framework/update/tests/appupdatescenario_tests.cpp @@ -21,12 +21,16 @@ */ #include +#include + #include #include "global/tests/mocks/applicationmock.h" +#include "actions/tests/mocks/actionsdispatchermock.h" #include "network/tests/mocks/networkinformationmock.h" #include "interactive/tests/mocks/interactivemock.h" #include "multiwindows/tests/mocks/multiwindowsprovidermock.h" +#include "toast/tests/mocks/toastservicemock.h" #include "mocks/updateconfigurationmock.h" #include "mocks/appupdateservicemock.h" @@ -38,6 +42,7 @@ using ::testing::_; using ::testing::Invoke; using ::testing::InvokeWithoutArgs; +using ::testing::IsEmpty; using ::testing::NiceMock; using ::testing::Return; using ::testing::ReturnRef; @@ -73,9 +78,15 @@ class AppUpdateScenarioTests : public ::testing::Test m_multiwindowsProvider = std::make_shared >(); m_scenario->multiwindowsProvider.set(m_multiwindowsProvider); + m_toastService = std::make_shared >(); + m_scenario->toastService.set(m_toastService); + m_application = std::make_shared >(); m_scenario->application.set(m_application); + m_dispatcher = std::make_shared >(); + m_scenario->dispatcher.set(m_dispatcher); + ON_CALL(*m_application, fullVersion()) .WillByDefault(Return(Version(CURRENT_VERSION))); ON_CALL(*m_application, title()) @@ -89,10 +100,10 @@ class AppUpdateScenarioTests : public ::testing::Test ON_CALL(*m_service, lastCheckResult()) .WillByDefault(ReturnRef(m_lastCheckResult)); - ON_CALL(*m_service, isReleaseDownloaded()) + ON_CALL(*m_service, isReleaseReadyToInstall()) .WillByDefault(Return(false)); - ON_CALL(*m_configuration, autoDownloadEnabled()) + ON_CALL(*m_configuration, autoUpdateEnabled()) .WillByDefault(Return(true)); } @@ -106,9 +117,9 @@ class AppUpdateScenarioTests : public ::testing::Test m_scenario->downloadUpdateInBackground(); } - void init() + void delayedInit() { - m_scenario->init(); + m_scenario->delayedInit(); } void skipRelease(const std::string& version) @@ -121,11 +132,6 @@ class AppUpdateScenarioTests : public ::testing::Test return m_scenario->downloadRelease(); } - async::Promise prepareAndInstall(const io::path_t& packagePath) - { - return m_scenario->prepareAndInstall(packagePath); - } - //! A dialog action: the promise is created at call time (so the caller can //! subscribe to it) and resolves with the given button once messages are processed static auto dialog(IInteractive::Button btn) @@ -137,6 +143,110 @@ class AppUpdateScenarioTests : public ::testing::Test }); } + //! An "update available" toast: checks its content and buttons, then resolves + //! (once messages are processed) as if the button at the given index was clicked, + //! or as dismissed when no button is clicked + static auto toast(const std::vector& expectedButtons, int clickedButton = NO_CLICK) + { + return Invoke([expectedButtons, clickedButton](const std::string& title, const std::string& message, + ui::IconCode::Code iconCode, bool dismissible, + const std::vector& actions) { + EXPECT_EQ(title, "New update available"); + EXPECT_EQ(message, "App 1000.0 is now ready to install."); + EXPECT_EQ(iconCode, ui::IconCode::Code::INFO_FILLED); + EXPECT_TRUE(dismissible); + + std::vector buttons; + for (const toast::ToastAction& action : actions) { + buttons.push_back(action.text); + } + EXPECT_EQ(buttons, expectedButtons); + EXPECT_TRUE(!actions.empty() && actions.back().accent); + + int code = static_cast(toast::ToastActionCode::Dismiss); + if (clickedButton != NO_CLICK && clickedButton < static_cast(actions.size())) { + code = actions.at(clickedButton).code; + } + + return async::make_promise([code](auto resolve) { + return resolve(toast::ToastResult(code)); + }); + }); + } + + static auto checkForUpdateResolves(const RetVal& result) + { + return InvokeWithoutArgs([result]() { + return async::make_promise >([result](auto resolve) { + return resolve(result); + }); + }); + } + + //! The release info dialog: checks the query, then resolves with the given action + //! ("install", "skip", "remindLater", "close") and the auto-update toggle state once messages are processed + static auto releaseInfoDialog(bool expectedReadyToInstall, const std::string& action, + std::optional autoUpdateEnabled = std::nullopt) + { + return Invoke([expectedReadyToInstall, action, autoUpdateEnabled](const UriQuery& query) { + EXPECT_EQ(query.uri(), Uri("muse://update/appreleaseinfo")); + EXPECT_EQ(query.param("appName").toString(), "App"); + EXPECT_EQ(query.param("version").toString(), "1000.0"); + EXPECT_EQ(query.param("readyToInstall").toBool(), expectedReadyToInstall); + + //! NOTE: The toggle keeps its initial value unless the test says otherwise + const bool toggle = autoUpdateEnabled.value_or(query.param("autoUpdateEnabled").toBool()); + const Val result(ValMap { + { "action", Val(action) }, + { "autoUpdateEnabled", Val(toggle) }, + }); + + return async::make_promise([result](auto resolve) { + return resolve(result); + }); + }); + } + + //! The install prompt: checks its title, then resolves with the given button + static auto installPrompt(IInteractive::Button btn) + { + return Invoke([btn](const std::string& title, const IInteractive::Text&, const IInteractive::ButtonDatas&, + int, const IInteractive::Options&, const std::string&) { + EXPECT_EQ(title, "Restart to finish updating"); + return async::make_promise([btn](auto resolve) { + return resolve(IInteractive::Result(static_cast(btn))); + }); + }); + } + + //! [GIVEN] The update was downloaded in the background and is ready to install; + //! the "ready to install" toast is resolved with the given button + void givenDownloadedUpdate(int clickedButton = NO_CLICK) + { + ON_CALL(*m_networkInformation, isMetered()) + .WillByDefault(Return(false)); + ON_CALL(*m_multiwindowsProvider, windowCount()) + .WillByDefault(Return(1)); + EXPECT_CALL(*m_service, downloadRelease()) + .WillOnce(Return(RetVal::make_ok(m_downloadProgress))); + EXPECT_CALL(*m_toastService, show(_, _, _, _, _)) + .WillOnce(toast(DOWNLOADED_TOAST, clickedButton)); + + downloadUpdateInBackground(); + + ON_CALL(*m_service, isReleaseReadyToInstall()) + .WillByDefault(Return(true)); + ON_CALL(*m_service, isReleaseReadyToInstall()) + .WillByDefault(Return(true)); + ON_CALL(*m_service, downloadedReleasePath()) + .WillByDefault(Return(PACKAGE)); + } + + void finishDownload() + { + m_downloadProgress.finish(ProgressResult::make_ok(Val(PACKAGE.toStdString()))); + } + static RetVal notEnoughDiskSpace() { return RetVal(make_ret(Err::NotEnoughDiskSpace, "Free up 250 MB")); @@ -153,13 +263,24 @@ class AppUpdateScenarioTests : public ::testing::Test static constexpr const char* CURRENT_VERSION = "4.0.0"; + static constexpr int NO_CLICK = -1; + static constexpr int SEE_DETAILS = 0; + static constexpr int INSTALL = 1; + + inline static const io::path_t PACKAGE = io::path_t("upd/MuseScore.dmg"); + + inline static const std::vector AVAILABLE_TOAST = { "See details", "Install update" }; + inline static const std::vector DOWNLOADED_TOAST = { "See details", "Restart & update" }; + AppUpdateScenario* m_scenario = nullptr; std::shared_ptr m_application; + std::shared_ptr m_dispatcher; std::shared_ptr m_configuration; std::shared_ptr m_service; std::shared_ptr m_networkInformation; std::shared_ptr m_interactive; std::shared_ptr m_multiwindowsProvider; + std::shared_ptr m_toastService; RetVal m_lastCheckResult; Progress m_downloadProgress; }; @@ -178,30 +299,12 @@ TEST_F(AppUpdateScenarioTests, BgDownload_UnmeteredNetwork_StartsDownload) //! [WHEN] A background download is requested downloadUpdateInBackground(); + //! [THEN] The "ready to install" toast is shown once the download finishes + EXPECT_CALL(*m_toastService, show(_, _, _, _, _)) + .WillOnce(toast(DOWNLOADED_TOAST)); + //! [WHEN] The download finishes successfully m_downloadProgress.finish(ProgressResult::make_ok(Val(std::string("upd/MuseScore.dmg")))); - - //! [THEN] The update is surfaced as ready to install - EXPECT_TRUE(m_scenario->hasReadyUpdate()); - EXPECT_EQ(m_scenario->readyUpdateVersion(), "1000.0"); -} - -TEST_F(AppUpdateScenarioTests, BgDownload_AutoDownloadDisabled_SkipsDownload) -{ - //! [GIVEN] The user turned automatic download off - ON_CALL(*m_configuration, autoDownloadEnabled()) - .WillByDefault(Return(false)); - ON_CALL(*m_networkInformation, isMetered()) - .WillByDefault(Return(false)); - - //! [THEN] No download is started - EXPECT_CALL(*m_service, downloadRelease()) - .Times(0); - - //! [WHEN] A background download is requested - downloadUpdateInBackground(); - - EXPECT_FALSE(m_scenario->hasReadyUpdate()); } TEST_F(AppUpdateScenarioTests, BgDownload_MeteredNetwork_SkipsDownload) @@ -216,9 +319,6 @@ TEST_F(AppUpdateScenarioTests, BgDownload_MeteredNetwork_SkipsDownload) //! [WHEN] A background download is requested downloadUpdateInBackground(); - - //! [THEN] No update is surfaced as ready - EXPECT_FALSE(m_scenario->hasReadyUpdate()); } TEST_F(AppUpdateScenarioTests, BgDownload_MeteredThenUnmetered_DownloadsOnRetry) @@ -238,30 +338,49 @@ TEST_F(AppUpdateScenarioTests, BgDownload_MeteredThenUnmetered_DownloadsOnRetry) downloadUpdateInBackground(); } -TEST_F(AppUpdateScenarioTests, BgDownload_AlreadyDownloaded_SurfacedEvenOnMetered) +TEST_F(AppUpdateScenarioTests, BgDownload_AlreadyDownloaded_GetsReadyEvenOnMetered) { //! [GIVEN] The release was already downloaded in a previous session - ON_CALL(*m_service, isReleaseDownloaded()) + ON_CALL(*m_service, isReleaseReadyToInstall()) .WillByDefault(Return(true)); - ON_CALL(*m_service, downloadedReleasePath()) - .WillByDefault(Return(io::path_t("upd/MuseScore.dmg"))); //! [GIVEN] The network connection is metered ON_CALL(*m_networkInformation, isMetered()) .WillByDefault(Return(true)); - //! [THEN] No download is started + //! [THEN] The service is asked for the release, which only gets it ready again EXPECT_CALL(*m_service, downloadRelease()) - .Times(0); + .WillOnce(Return(RetVal::make_ok(m_downloadProgress))); //! [WHEN] A background download is requested downloadUpdateInBackground(); - //! [THEN] The downloaded update is still surfaced as ready to install - EXPECT_TRUE(m_scenario->hasReadyUpdate()); - EXPECT_EQ(m_scenario->readyUpdateVersion(), "1000.0"); + //! [THEN] The "ready to install" toast is shown once it is ready + EXPECT_CALL(*m_toastService, show(_, _, _, _, _)) + .WillOnce(toast(DOWNLOADED_TOAST)); + + m_downloadProgress.finish(ProgressResult::make_ok(Val(PACKAGE.toStdString()))); } +TEST_F(AppUpdateScenarioTests, BgDownload_Failed_NoToast) +{ + //! [GIVEN] A background download is running + ON_CALL(*m_networkInformation, isMetered()) + .WillByDefault(Return(false)); + EXPECT_CALL(*m_service, downloadRelease()) + .WillOnce(Return(RetVal::make_ok(m_downloadProgress))); + + downloadUpdateInBackground(); + + //! [THEN] Nothing is shown in the background + EXPECT_CALL(*m_interactive, error(_, _, _, _, _, _)) + .Times(0); + EXPECT_CALL(*m_toastService, show(_, _, _, _, _)) + .Times(0); + + //! [WHEN] The download finishes with an error + m_downloadProgress.finish(ProgressResult::make_ret(make_ret(Err::NotEnoughDiskSpace))); +} TEST_F(AppUpdateScenarioTests, BgDownload_NotEnoughDiskSpace_SkipsSilently) { //! [GIVEN] The network connection is not metered @@ -275,9 +394,7 @@ TEST_F(AppUpdateScenarioTests, BgDownload_NotEnoughDiskSpace_SkipsSilently) //! [WHEN] A background download is requested downloadUpdateInBackground(); - //! [THEN] No update is surfaced as ready and a later retry is allowed - EXPECT_FALSE(m_scenario->hasReadyUpdate()); - + //! [THEN] A later retry is allowed EXPECT_CALL(*m_service, downloadRelease()) .WillOnce(Return(RetVal::make_ok(m_downloadProgress))); downloadUpdateInBackground(); @@ -354,29 +471,178 @@ TEST_F(AppUpdateScenarioTests, ManualDownload_NotEnoughDiskSpace_RetrySucceeds_P EXPECT_EQ(result.code(), static_cast(Ret::Code::Cancel)); } -TEST_F(AppUpdateScenarioTests, PrepareAndInstall_NotEnoughDiskSpace_Retry_PreparesAgain) +TEST_F(AppUpdateScenarioTests, ManualDownload_ReadyToInstall_GoesStraightToInstallPrompt) { - //! [GIVEN] Staging fails for lack of space the first time and succeeds after a retry - const io::path_t package("upd/MuseScore.dmg"); - EXPECT_CALL(*m_service, prepareUpdate(package)) - .WillOnce(Return(RetVal(make_ret(Err::NotEnoughDiskSpace, "Free up 250 MB")))) - .WillOnce(Return(RetVal::make_ok(io::path_t("upd/staging/MuseScore.app")))); + //! [GIVEN] The release is already downloaded and ready to install + ON_CALL(*m_service, isReleaseReadyToInstall()) + .WillByDefault(Return(true)); - EXPECT_CALL(*m_interactive, error(_, _, _, _, _, _)) - .WillOnce(dialog(IInteractive::Button::Retry)); + //! [THEN] The download dialog is not opened; the install prompt is shown + EXPECT_CALL(*m_interactive, openSync(_)) + .Times(0); + EXPECT_CALL(*m_interactive, info(_, _, _, _, _, _)) + .WillOnce(installPrompt(IInteractive::Button::Cancel)); - //! [THEN] The restart prompt is shown; no fallback to the manual install prompt + //! [WHEN] A manual download is requested + Ret result; + downloadRelease().onResolve(m_scenario, [&result](const Ret& ret) { result = ret; }); + pump(); + + EXPECT_EQ(result.code(), static_cast(Ret::Code::Cancel)); +} +TEST_F(AppUpdateScenarioTests, ManualDownload_NotReady_OpensDownloadDialog_ThenInstallPrompt) +{ + //! [GIVEN] The release is not ready to install (not downloaded, or downloaded but not ready) + ON_CALL(*m_service, isReleaseReadyToInstall()) + .WillByDefault(Return(false)); + + //! [THEN] The download dialog gets it ready + EXPECT_CALL(*m_interactive, openSync(_)) + .WillOnce(Invoke([](const UriQuery& query) { + EXPECT_EQ(query.uri(), Uri("muse://update/app")); + EXPECT_EQ(query.param("mode").toString(), "download"); + return RetVal::make_ok(Val(PACKAGE.toStdString())); + })); + + //! [THEN] The install prompt follows EXPECT_CALL(*m_interactive, info(_, _, _, _, _, _)) - .WillOnce(dialog(IInteractive::Button::Cancel)); + .WillOnce(installPrompt(IInteractive::Button::Cancel)); - //! [WHEN] The downloaded package is installed + //! [WHEN] A manual download is requested + downloadRelease().onResolve(m_scenario, [](const Ret&) {}); + pump(); +} +TEST_F(AppUpdateScenarioTests, ManualDownload_Canceled_EndsWithCancel) +{ + //! [GIVEN] The user closes the download dialog + EXPECT_CALL(*m_interactive, openSync(_)) + .WillOnce(Return(RetVal(make_ret(Ret::Code::Cancel)))); + + //! [THEN] No error is shown + EXPECT_CALL(*m_interactive, error(_, _, _, _, _, _)) + .Times(0); + + //! [WHEN] A manual download is requested Ret result; - prepareAndInstall(package).onResolve(m_scenario, [&result](const Ret& ret) { result = ret; }); + downloadRelease().onResolve(m_scenario, [&result](const Ret& ret) { result = ret; }); pump(); EXPECT_EQ(result.code(), static_cast(Ret::Code::Cancel)); } +TEST_F(AppUpdateScenarioTests, BgDownload_Finished_RestartAndUpdate_QuitsWithoutPrompt) +{ + //! [GIVEN] The update was downloaded in the background, and the user clicks "Restart & update" + givenDownloadedUpdate(INSTALL); + + //! [THEN] Nothing is downloaded again, and no prompt is shown: the button already says that the app restarts + EXPECT_CALL(*m_interactive, openSync(_)) + .Times(0); + EXPECT_CALL(*m_interactive, open(_)) + .Times(0); + EXPECT_CALL(*m_interactive, info(_, _, _, _, _, _)) + .Times(0); + + //! [THEN] The installing version is remembered and the app quits with the downloaded package + EXPECT_CALL(*m_configuration, setInstallingReleaseVersion("1000.0")); + EXPECT_CALL(*m_multiwindowsProvider, quitAllAndRunInstallation(_)) + .Times(0); + EXPECT_CALL(*m_dispatcher, dispatch(actions::ActionCode("quit"), _)) + .WillOnce(Invoke([](const actions::ActionCode&, const actions::ActionData& data) { + EXPECT_FALSE(data.arg(0)); + EXPECT_EQ(data.arg(1), PACKAGE.toStdString()); + })); + + //! [WHEN] The download finishes + finishDownload(); + pump(); +} +TEST_F(AppUpdateScenarioTests, BgDownload_NotReadyAnymore_RestartAndUpdate_GetsReadyAndQuitsWithoutPrompt) +{ + //! [GIVEN] The update was downloaded in the background, and the user clicks "Restart & update" + givenDownloadedUpdate(INSTALL); + + //! [GIVEN] It is not ready to install anymore (e.g. getting it ready failed) + ON_CALL(*m_service, isReleaseReadyToInstall()) + .WillByDefault(Return(false)); + + //! [THEN] The download dialog gets it ready again + EXPECT_CALL(*m_interactive, openSync(_)) + .WillOnce(Return(RetVal::make_ok(Val(PACKAGE.toStdString())))); + + //! [THEN] Then the app quits to install it, without a prompt + EXPECT_CALL(*m_interactive, info(_, _, _, _, _, _)) + .Times(0); + EXPECT_CALL(*m_dispatcher, dispatch(actions::ActionCode("quit"), _)); + + //! [WHEN] The download finishes + finishDownload(); + pump(); +} +TEST_F(AppUpdateScenarioTests, BgDownload_Finished_RestartAndUpdate_SeveralWindows_QuitsAllWindows) +{ + //! [GIVEN] The update was downloaded in the background, and the user clicks "Restart & update" + givenDownloadedUpdate(INSTALL); + + //! [GIVEN] Several windows are open + ON_CALL(*m_multiwindowsProvider, windowCount()) + .WillByDefault(Return(2)); + + //! [THEN] All windows quit to install the downloaded package + EXPECT_CALL(*m_multiwindowsProvider, quitAllAndRunInstallation(PACKAGE)); + EXPECT_CALL(*m_dispatcher, dispatch(actions::ActionCode("quit"), _)); + + //! [WHEN] The download finishes + finishDownload(); + pump(); +} + +TEST_F(AppUpdateScenarioTests, ReleaseInfo_InstallUpdate_Downloads_ThenAsksToRestart) +{ + //! [GIVEN] The available release is not downloaded yet + EXPECT_CALL(*m_service, checkForUpdate()) + .WillOnce(checkForUpdateResolves(m_lastCheckResult)); + + //! [GIVEN] The user clicks "Install update" in the release info + EXPECT_CALL(*m_interactive, open(_)) + .WillOnce(releaseInfoDialog(/*readyToInstall*/ false, "install")); + + //! [THEN] The release is downloaded + EXPECT_CALL(*m_interactive, openSync(_)) + .WillOnce(Return(RetVal::make_ok(Val(PACKAGE.toStdString())))); + + //! [THEN] The user is asked to restart, and confirms it + EXPECT_CALL(*m_interactive, info(_, _, _, _, _, _)) + .WillOnce(installPrompt(IInteractive::Button::Apply)); + + //! [THEN] The app quits to install it + EXPECT_CALL(*m_dispatcher, dispatch(actions::ActionCode("quit"), _)); + + //! [WHEN] The user checks for updates manually + m_scenario->checkForUpdate(/*manual*/ true); + pump(); +} + +TEST_F(AppUpdateScenarioTests, InstallPrompt_Cancel_DoesNotQuit) +{ + //! [GIVEN] The release is not ready to install, and the download dialog gets it ready + EXPECT_CALL(*m_interactive, openSync(_)) + .WillOnce(Return(RetVal::make_ok(Val(PACKAGE.toStdString())))); + + //! [GIVEN] The user cancels the restart prompt + EXPECT_CALL(*m_interactive, info(_, _, _, _, _, _)) + .WillOnce(installPrompt(IInteractive::Button::Cancel)); + + //! [THEN] The app does not quit + EXPECT_CALL(*m_configuration, setInstallingReleaseVersion(_)) + .Times(0); + EXPECT_CALL(*m_dispatcher, dispatch(actions::ActionCode("quit"), _)) + .Times(0); + + //! [WHEN] A manual download is requested + downloadRelease().onResolve(m_scenario, [](const Ret&) {}); + pump(); +} TEST_F(AppUpdateScenarioTests, SkipRelease_RemovesPackage_AndClearsReadyUpdate) { //! [GIVEN] The release was downloaded in the background and is ready to install @@ -385,9 +651,11 @@ TEST_F(AppUpdateScenarioTests, SkipRelease_RemovesPackage_AndClearsReadyUpdate) EXPECT_CALL(*m_service, downloadRelease()) .WillOnce(Return(RetVal::make_ok(m_downloadProgress))); + EXPECT_CALL(*m_toastService, show(_, _, _, _, _)) + .WillOnce(toast(DOWNLOADED_TOAST)); + downloadUpdateInBackground(); m_downloadProgress.finish(ProgressResult::make_ok(Val(std::string("upd/MuseScore.dmg")))); - ASSERT_TRUE(m_scenario->hasReadyUpdate()); //! [THEN] The version is remembered as skipped and the package is deleted EXPECT_CALL(*m_configuration, setSkippedReleaseVersion("1000.0")); @@ -395,9 +663,6 @@ TEST_F(AppUpdateScenarioTests, SkipRelease_RemovesPackage_AndClearsReadyUpdate) //! [WHEN] The user skips the release skipRelease("1000.0"); - - //! [THEN] Nothing is left to install - EXPECT_FALSE(m_scenario->hasReadyUpdate()); } TEST_F(AppUpdateScenarioTests, SkipRelease_WhileDownloading_DoesNotSurfaceUpdate) @@ -415,14 +680,15 @@ TEST_F(AppUpdateScenarioTests, SkipRelease_WhileDownloading_DoesNotSurfaceUpdate .WillByDefault(Return("1000.0")); skipRelease("1000.0"); + //! [THEN] No toast is shown for the skipped release + EXPECT_CALL(*m_toastService, show(_, _, _, _, _)) + .Times(0); + //! [WHEN] The (not yet canceled) download still reports success m_downloadProgress.finish(ProgressResult::make_ok(Val(std::string("upd/MuseScore.dmg")))); - - //! [THEN] The skipped release is not surfaced as ready to install - EXPECT_FALSE(m_scenario->hasReadyUpdate()); } -TEST_F(AppUpdateScenarioTests, Init_LaunchedWithInstalledVersion_ReportsCompletedUpdate) +TEST_F(AppUpdateScenarioTests, DelayedInit_LaunchedWithInstalledVersion_ReportsCompletedUpdate) { //! [GIVEN] The app quit to install this very version and is now running it ON_CALL(*m_configuration, installingReleaseVersion()) @@ -431,17 +697,20 @@ TEST_F(AppUpdateScenarioTests, Init_LaunchedWithInstalledVersion_ReportsComplete //! [THEN] The record is cleared so the banner shows only once EXPECT_CALL(*m_configuration, setInstallingReleaseVersion("")); - //! [WHEN] The scenario starts - init(); - - //! [THEN] The update is reported as completed until dismissed - EXPECT_TRUE(m_scenario->hasCompletedUpdate()); + //! [THEN] The "updated" toast is shown, with no buttons + EXPECT_CALL(*m_toastService, + showWithTimeout("Updated to App 4.0.0", "", std::chrono::seconds(10), ui::IconCode::Code::TICK_FILLED, true, IsEmpty())) + .WillOnce(InvokeWithoutArgs([]() { + return async::make_promise([](auto resolve) { + return resolve(toast::ToastResult(static_cast(toast::ToastActionCode::Dismiss))); + }); + })); - m_scenario->dismissCompletedUpdate(); - EXPECT_FALSE(m_scenario->hasCompletedUpdate()); + //! [WHEN] The app finished starting + delayedInit(); } -TEST_F(AppUpdateScenarioTests, Init_InstallDidNotHappen_NoCompletedUpdate) +TEST_F(AppUpdateScenarioTests, DelayedInit_InstallDidNotHappen_NoCompletedUpdate) { //! [GIVEN] The app quit to install a version, but still runs the old one ON_CALL(*m_configuration, installingReleaseVersion()) @@ -450,14 +719,15 @@ TEST_F(AppUpdateScenarioTests, Init_InstallDidNotHappen_NoCompletedUpdate) //! [THEN] The record is still cleared EXPECT_CALL(*m_configuration, setInstallingReleaseVersion("")); - //! [WHEN] The scenario starts - init(); + //! [THEN] No toast is shown + EXPECT_CALL(*m_toastService, show(_, _, _, _, _)) + .Times(0); - //! [THEN] Nothing is reported - EXPECT_FALSE(m_scenario->hasCompletedUpdate()); + //! [WHEN] The app finished starting + delayedInit(); } -TEST_F(AppUpdateScenarioTests, Init_NothingWasInstalling_NoCompletedUpdate) +TEST_F(AppUpdateScenarioTests, DelayedInit_NothingWasInstalling_NoCompletedUpdate) { //! [GIVEN] A regular launch ON_CALL(*m_configuration, installingReleaseVersion()) @@ -466,64 +736,350 @@ TEST_F(AppUpdateScenarioTests, Init_NothingWasInstalling_NoCompletedUpdate) EXPECT_CALL(*m_configuration, setInstallingReleaseVersion(_)) .Times(0); - //! [WHEN] The scenario starts - init(); + //! [THEN] No toast is shown + EXPECT_CALL(*m_toastService, show(_, _, _, _, _)) + .Times(0); - EXPECT_FALSE(m_scenario->hasCompletedUpdate()); + //! [WHEN] The app finished starting + delayedInit(); } -TEST_F(AppUpdateScenarioTests, DismissReadyUpdate_HidesBanner_KeepsPackage) +TEST_F(AppUpdateScenarioTests, BgDownload_Finished_SeeDetails_InstallUsesDownloadedPackage) { - //! [GIVEN] A ready update + //! [GIVEN] A background download is running and in-place install is not available ON_CALL(*m_networkInformation, isMetered()) .WillByDefault(Return(false)); EXPECT_CALL(*m_service, downloadRelease()) .WillOnce(Return(RetVal::make_ok(m_downloadProgress))); + ON_CALL(*m_service, canAutoInstall()) + .WillByDefault(Return(false)); + ON_CALL(*m_service, isReleaseReadyToInstall()) + .WillByDefault(Return(true)); downloadUpdateInBackground(); - m_downloadProgress.finish(ProgressResult::make_ok(Val(std::string("upd/MuseScore.dmg")))); - ASSERT_TRUE(m_scenario->hasReadyUpdate()); - //! [THEN] The package is not removed - EXPECT_CALL(*m_service, removeDownloadedRelease()) + //! [GIVEN] The user clicks "See details" on the toast + EXPECT_CALL(*m_toastService, show(_, _, _, _, _)) + .WillOnce(toast(DOWNLOADED_TOAST, SEE_DETAILS)); + + //! [THEN] The release info is opened in the "ready to install" mode, and the user clicks "Restart & update" + EXPECT_CALL(*m_interactive, open(_)) + .WillOnce(releaseInfoDialog(/*readyToInstall*/ true, "install")); + + //! [THEN] The downloaded package is installed without downloading it again and without a prompt + ON_CALL(*m_service, downloadedReleasePath()) + .WillByDefault(Return(PACKAGE)); + EXPECT_CALL(*m_interactive, openSync(_)) .Times(0); + EXPECT_CALL(*m_interactive, info(_, _, _, _, _, _)) + .Times(0); + EXPECT_CALL(*m_dispatcher, dispatch(actions::ActionCode("quit"), _)) + .WillOnce(Invoke([](const actions::ActionCode&, const actions::ActionData& data) { + EXPECT_EQ(data.arg(1), PACKAGE.toStdString()); + })); - //! [WHEN] The user closes the banner - m_scenario->dismissReadyUpdate(); + //! [WHEN] The download finishes + m_downloadProgress.finish(ProgressResult::make_ok(Val(std::string("upd/MuseScore.dmg")))); + pump(); +} - //! [THEN] The banner is hidden, but the update can still be installed - EXPECT_FALSE(m_scenario->hasReadyUpdate()); +TEST_F(AppUpdateScenarioTests, AutoCheck_AutoUpdateDisabled_InstallUpdate_Downloads) +{ + //! [GIVEN] The user turned automatic update off + ON_CALL(*m_configuration, autoUpdateEnabled()) + .WillByDefault(Return(false)); + EXPECT_CALL(*m_service, checkForUpdate()) + .WillOnce(checkForUpdateResolves(m_lastCheckResult)); + + //! [THEN] No background download is started + EXPECT_CALL(*m_service, downloadRelease()) + .Times(0); + //! [THEN] The "update available" toast is shown, and the user clicks "Install update" + EXPECT_CALL(*m_toastService, show(_, _, _, _, _)) + .WillOnce(toast(AVAILABLE_TOAST, INSTALL)); + + //! [THEN] The download dialog is opened, followed by the install prompt + EXPECT_CALL(*m_interactive, openSync(_)) + .WillOnce(Invoke([](const UriQuery& query) { + EXPECT_EQ(query.uri(), Uri("muse://update/app")); + EXPECT_EQ(query.param("mode").toString(), "download"); + return RetVal::make_ok(Val(std::string("upd/MuseScore.dmg"))); + })); ON_CALL(*m_service, canAutoInstall()) .WillByDefault(Return(false)); EXPECT_CALL(*m_interactive, info(_, _, _, _, _, _)) .WillOnce(dialog(IInteractive::Button::Cancel)); - m_scenario->installReadyUpdate(); + //! [WHEN] An automatic check finds an update + m_scenario->checkForUpdate(/*manual*/ false); pump(); } -TEST_F(AppUpdateScenarioTests, InstallReadyUpdate_GoesStraightToInstall) +TEST_F(AppUpdateScenarioTests, AutoCheck_AutoUpdateDisabled_SeeDetails_OpensReleaseInfo) { - //! [GIVEN] A ready update and no in-place install support + //! [GIVEN] The user turned automatic update off + ON_CALL(*m_configuration, autoUpdateEnabled()) + .WillByDefault(Return(false)); + EXPECT_CALL(*m_service, checkForUpdate()) + .WillOnce(checkForUpdateResolves(m_lastCheckResult)); + + //! [THEN] The "update available" toast is shown, and the user clicks "See details" + EXPECT_CALL(*m_toastService, show(_, _, _, _, _)) + .WillOnce(toast(AVAILABLE_TOAST, SEE_DETAILS)); + + //! [THEN] The release info is opened (not in the "ready to install" mode); the user postpones + EXPECT_CALL(*m_interactive, open(_)) + .WillOnce(releaseInfoDialog(/*readyToInstall*/ false, "remindLater")); + + //! [THEN] Nothing is downloaded + EXPECT_CALL(*m_interactive, openSync(_)) + .Times(0); + + //! [WHEN] An automatic check finds an update + m_scenario->checkForUpdate(/*manual*/ false); + pump(); +} + +TEST_F(AppUpdateScenarioTests, AutoCheck_AutoUpdateDisabled_SkippedRelease_NoToast) +{ + //! [GIVEN] The user turned automatic update off and skipped the available release + ON_CALL(*m_configuration, autoUpdateEnabled()) + .WillByDefault(Return(false)); + ON_CALL(*m_configuration, skippedReleaseVersion()) + .WillByDefault(Return("1000.0")); + EXPECT_CALL(*m_service, checkForUpdate()) + .WillOnce(checkForUpdateResolves(m_lastCheckResult)); + + //! [THEN] No toast is shown + EXPECT_CALL(*m_toastService, show(_, _, _, _, _)) + .Times(0); + + //! [WHEN] An automatic check finds the skipped release + m_scenario->checkForUpdate(/*manual*/ false); + pump(); +} + +TEST_F(AppUpdateScenarioTests, ManualCheck_ReleaseAlreadyDownloaded_OpensReadyToInstallInfo) +{ + //! [GIVEN] The available release was already downloaded + ON_CALL(*m_service, isReleaseReadyToInstall()) + .WillByDefault(Return(true)); + EXPECT_CALL(*m_service, checkForUpdate()) + .WillOnce(checkForUpdateResolves(m_lastCheckResult)); + + //! [THEN] No toast is shown; the release info is opened in the "ready to install" mode + EXPECT_CALL(*m_toastService, show(_, _, _, _, _)) + .Times(0); + EXPECT_CALL(*m_interactive, open(_)) + .WillOnce(releaseInfoDialog(/*readyToInstall*/ true, "remindLater")); + + //! [WHEN] The user checks for updates manually + m_scenario->checkForUpdate(/*manual*/ true); + pump(); +} + +TEST_F(AppUpdateScenarioTests, ManualCheck_ReleaseNotDownloaded_OpensReleaseInfo) +{ + //! [GIVEN] The available release is not downloaded yet + EXPECT_CALL(*m_service, checkForUpdate()) + .WillOnce(checkForUpdateResolves(m_lastCheckResult)); + + //! [THEN] No toast is shown; the release info is opened in the regular mode + EXPECT_CALL(*m_toastService, show(_, _, _, _, _)) + .Times(0); + EXPECT_CALL(*m_interactive, open(_)) + .WillOnce(releaseInfoDialog(/*readyToInstall*/ false, "remindLater")); + + //! [WHEN] The user checks for updates manually + m_scenario->checkForUpdate(/*manual*/ true); + pump(); +} + +TEST_F(AppUpdateScenarioTests, ReleaseInfo_PassesCurrentAutoUpdateSetting) +{ + //! [GIVEN] Automatic update is off + ON_CALL(*m_configuration, autoUpdateEnabled()) + .WillByDefault(Return(false)); + EXPECT_CALL(*m_service, checkForUpdate()) + .WillOnce(checkForUpdateResolves(m_lastCheckResult)); + + //! [THEN] The dialog gets the current setting for its toggle + EXPECT_CALL(*m_interactive, open(_)) + .WillOnce(Invoke([](const UriQuery& query) { + EXPECT_FALSE(query.param("autoUpdateEnabled").toBool()); + return async::make_promise([](auto resolve) { + return resolve(Val(ValMap { { "action", Val(std::string("remindLater")) } })); + }); + })); + + //! [THEN] A result without the toggle state does not touch the setting + EXPECT_CALL(*m_configuration, setAutoUpdateEnabled(_)) + .Times(0); + + //! [WHEN] The user checks for updates manually + m_scenario->checkForUpdate(/*manual*/ true); + pump(); +} + +TEST_F(AppUpdateScenarioTests, ReleaseInfo_RemindLater_AppliesAutoUpdateToggle) +{ + //! [GIVEN] Automatic update is off + ON_CALL(*m_configuration, autoUpdateEnabled()) + .WillByDefault(Return(false)); + EXPECT_CALL(*m_service, checkForUpdate()) + .WillOnce(checkForUpdateResolves(m_lastCheckResult)); + + //! [GIVEN] The user turns the toggle on and clicks "Remind me later" + EXPECT_CALL(*m_interactive, open(_)) + .WillOnce(releaseInfoDialog(/*readyToInstall*/ false, "remindLater", /*autoUpdateEnabled*/ true)); + + //! [THEN] The setting is saved, and nothing is downloaded + EXPECT_CALL(*m_configuration, setAutoUpdateEnabled(true)); + EXPECT_CALL(*m_interactive, openSync(_)) + .Times(0); + + //! [WHEN] The user checks for updates manually + m_scenario->checkForUpdate(/*manual*/ true); + pump(); +} + +TEST_F(AppUpdateScenarioTests, ReleaseInfo_Closed_AppliesAutoUpdateToggle) +{ + //! [GIVEN] Automatic update is off + ON_CALL(*m_configuration, autoUpdateEnabled()) + .WillByDefault(Return(false)); + EXPECT_CALL(*m_service, checkForUpdate()) + .WillOnce(checkForUpdateResolves(m_lastCheckResult)); + + //! [GIVEN] The user turns the toggle on and closes the dialog (close button or Escape) + EXPECT_CALL(*m_interactive, open(_)) + .WillOnce(releaseInfoDialog(/*readyToInstall*/ false, "close", /*autoUpdateEnabled*/ true)); + + //! [THEN] The setting is saved, and nothing is downloaded or skipped + EXPECT_CALL(*m_configuration, setAutoUpdateEnabled(true)); + EXPECT_CALL(*m_interactive, openSync(_)) + .Times(0); + EXPECT_CALL(*m_configuration, setSkippedReleaseVersion(_)) + .Times(0); + + //! [WHEN] The user checks for updates manually + m_scenario->checkForUpdate(/*manual*/ true); + pump(); +} + +TEST_F(AppUpdateScenarioTests, ManualCheck_SkippedRelease_RemindLater_UnskipsRelease) +{ + //! [GIVEN] The user skipped the available release earlier + ON_CALL(*m_configuration, skippedReleaseVersion()) + .WillByDefault(Return("1000.0")); + EXPECT_CALL(*m_service, checkForUpdate()) + .WillOnce(checkForUpdateResolves(m_lastCheckResult)); + + //! [GIVEN] The user checks for updates manually and chooses "Remind me later" + EXPECT_CALL(*m_interactive, open(_)) + .WillOnce(releaseInfoDialog(/*readyToInstall*/ false, "remindLater")); + + //! [THEN] The release is no longer skipped, so it is offered again later + EXPECT_CALL(*m_configuration, setSkippedReleaseVersion("")); + + //! [WHEN] The user checks for updates manually + m_scenario->checkForUpdate(/*manual*/ true); + pump(); +} + +TEST_F(AppUpdateScenarioTests, ManualCheck_SkippedRelease_Install_UnskipsRelease) +{ + //! [GIVEN] The user skipped the available release earlier + ON_CALL(*m_configuration, skippedReleaseVersion()) + .WillByDefault(Return("1000.0")); + EXPECT_CALL(*m_service, checkForUpdate()) + .WillOnce(checkForUpdateResolves(m_lastCheckResult)); + + //! [GIVEN] The user checks for updates manually and chooses to install + EXPECT_CALL(*m_interactive, open(_)) + .WillOnce(releaseInfoDialog(/*readyToInstall*/ false, "install")); + + //! [THEN] The release is no longer skipped + EXPECT_CALL(*m_configuration, setSkippedReleaseVersion("")); + + //! [GIVEN] The download dialog is closed + EXPECT_CALL(*m_interactive, openSync(_)) + .WillOnce(Return(RetVal(make_ret(Ret::Code::Cancel)))); + + //! [WHEN] The user checks for updates manually + m_scenario->checkForUpdate(/*manual*/ true); + pump(); +} + +TEST_F(AppUpdateScenarioTests, ManualCheck_SkippedRelease_Closed_KeepsReleaseSkipped) +{ + //! [GIVEN] The user skipped the available release earlier + ON_CALL(*m_configuration, skippedReleaseVersion()) + .WillByDefault(Return("1000.0")); + EXPECT_CALL(*m_service, checkForUpdate()) + .WillOnce(checkForUpdateResolves(m_lastCheckResult)); + + //! [GIVEN] The user checks for updates manually and just closes the dialog + EXPECT_CALL(*m_interactive, open(_)) + .WillOnce(releaseInfoDialog(/*readyToInstall*/ false, "close")); + + //! [THEN] The release stays skipped + EXPECT_CALL(*m_configuration, setSkippedReleaseVersion(_)) + .Times(0); + + //! [WHEN] The user checks for updates manually + m_scenario->checkForUpdate(/*manual*/ true); + pump(); +} + +TEST_F(AppUpdateScenarioTests, ReleaseInfo_Skip_AppliesAutoUpdateToggle) +{ + //! [GIVEN] Automatic update is on + EXPECT_CALL(*m_service, checkForUpdate()) + .WillOnce(checkForUpdateResolves(m_lastCheckResult)); + + //! [GIVEN] The user turns the toggle off and skips the release + EXPECT_CALL(*m_interactive, open(_)) + .WillOnce(releaseInfoDialog(/*readyToInstall*/ false, "skip", /*autoUpdateEnabled*/ false)); + + //! [THEN] The setting is saved and the release is skipped + EXPECT_CALL(*m_configuration, setAutoUpdateEnabled(false)); + EXPECT_CALL(*m_configuration, setSkippedReleaseVersion("1000.0")); + + //! [WHEN] The user checks for updates manually + m_scenario->checkForUpdate(/*manual*/ true); + pump(); +} + +TEST_F(AppUpdateScenarioTests, BgDownload_Finished_SeeDetails_AppliesAutoUpdateToggle) +{ + //! [GIVEN] A background download is running and in-place install is not available ON_CALL(*m_networkInformation, isMetered()) .WillByDefault(Return(false)); EXPECT_CALL(*m_service, downloadRelease()) .WillOnce(Return(RetVal::make_ok(m_downloadProgress))); - - downloadUpdateInBackground(); - m_downloadProgress.finish(ProgressResult::make_ok(Val(std::string("upd/MuseScore.dmg")))); - + ON_CALL(*m_service, downloadedReleasePath()) + .WillByDefault(Return(io::path_t("upd/MuseScore.dmg"))); ON_CALL(*m_service, canAutoInstall()) .WillByDefault(Return(false)); + ON_CALL(*m_service, isReleaseReadyToInstall()) + .WillByDefault(Return(true)); + + downloadUpdateInBackground(); - //! [THEN] No release info dialog is opened; the install prompt follows directly + //! [GIVEN] The user clicks "See details" on the toast, + //! turns the toggle off and clicks "Restart & update" + EXPECT_CALL(*m_toastService, show(_, _, _, _, _)) + .WillOnce(toast(DOWNLOADED_TOAST, SEE_DETAILS)); EXPECT_CALL(*m_interactive, open(_)) - .Times(0); - EXPECT_CALL(*m_interactive, info(_, _, _, _, _, _)) - .WillOnce(dialog(IInteractive::Button::Cancel)); + .WillOnce(releaseInfoDialog(/*readyToInstall*/ true, "install", /*autoUpdateEnabled*/ false)); - //! [WHEN] The user chooses "Restart and update" - m_scenario->installReadyUpdate(); + //! [THEN] The setting is saved and the app quits to install the update + EXPECT_CALL(*m_configuration, setAutoUpdateEnabled(false)); + EXPECT_CALL(*m_dispatcher, dispatch(actions::ActionCode("quit"), _)); + + //! [WHEN] The download finishes + m_downloadProgress.finish(ProgressResult::make_ok(Val(std::string("upd/MuseScore.dmg")))); pump(); } diff --git a/framework/update/tests/appupdateservice_tests.cpp b/framework/update/tests/appupdateservice_tests.cpp index bfbb1ad106..c5f621c2f1 100644 --- a/framework/update/tests/appupdateservice_tests.cpp +++ b/framework/update/tests/appupdateservice_tests.cpp @@ -21,7 +21,10 @@ */ #include +#include + #include +#include using ::testing::_; using ::testing::NiceMock; @@ -34,6 +37,7 @@ using ::testing::Return; #include "global/tests/mocks/applicationmock.h" #include "network/tests/mocks/networkmanagercreatormock.h" #include "network/tests/mocks/networkmanagermock.h" +#include "ui/tests/mocks/uiconfigurationmock.h" #include "mocks/updateconfigurationmock.h" #include "mocks/updateinstallermock.h" @@ -80,6 +84,11 @@ class AppUpdateServiceTests : public ::testing::Test, public ::async::Asyncable m_application = std::make_shared >(); m_service->application.set(m_application); + m_uiConfiguration = std::make_shared >(); + m_service->uiConfiguration.set(m_uiConfiguration); + ON_CALL(*m_uiConfiguration, currentTheme()) + .WillByDefault(testing::ReturnRef(m_theme)); + ON_CALL(*m_application, fullVersion()) .WillByDefault(Return(Version(CURRENT_VERSION))); ON_CALL(*m_application, title()) @@ -89,6 +98,10 @@ class AppUpdateServiceTests : public ::testing::Test, public ::async::Asyncable void TearDown() override { delete m_service; + + //! NOTE: Drop the calls queued for the deleted service, so that they do + //! not reach the next test's service if it is allocated at the same address + pump(); } void makeReleaseInfo() @@ -171,8 +184,53 @@ class AppUpdateServiceTests : public ::testing::Test, public ::async::Asyncable .WillByDefault(Return(muse::make_ok())); } + //! [GIVEN] The release can be installed in-place and nothing is downloaded yet + void givenInPlaceInstall(uint64_t fileSize = 0) + { + givenAvailableRelease("MuseScore.dmg", "upd", fileSize); + + ON_CALL(*m_updateInstaller, isInPlaceUpdateSupported()) + .WillByDefault(Return(true)); + ON_CALL(*m_fileSystem, exists(_)) + .WillByDefault(Return(Ret(false))); + ON_CALL(*m_fileSystem, move(_, _, _)) + .WillByDefault(Return(muse::make_ok())); + } + + void expectDownloadRequest() + { + EXPECT_CALL(*m_networkManager, get(_, _, _)) + .WillOnce(testing::Invoke([this](const QUrl&, IncomingDevicePtr, const RequestHeaders&) { + return RetVal::make_ok(m_downloadProgress); + })); + } + + void finishDownload() + { + ProgressResult res = ProgressResult::make_ok(Val()); + res.ret.setData("status", 200); + m_downloadProgress.finish(res); + } + + void cleanupStalePackages(const std::string& keepFileName) + { + m_service->cleanupStalePackages(keepFileName); + } + + //! Drain background work and queued async calls + static void pump() + { + for (int i = 0; i < 10; ++i) { + QThreadPool::globalInstance()->waitForDone(); + async::processMessages(); + } + } + static constexpr const char* CURRENT_VERSION = "4.0.0"; + inline static const io::path_t PACKAGE = io::path_t("upd/MuseScore.dmg"); + inline static const io::path_t STAGED = io::path_t("upd/staging/MuseScore.app"); + AppUpdateService* m_service = nullptr; std::shared_ptr m_configuration; std::shared_ptr m_networkManagerCreator; @@ -181,6 +239,8 @@ class AppUpdateServiceTests : public ::testing::Test, public ::async::Asyncable std::shared_ptr m_fileSystem; std::shared_ptr m_updateInstaller; std::shared_ptr m_application; + std::shared_ptr m_uiConfiguration; + ui::ThemeInfo m_theme; Progress m_getReleaseInfoProgress; Progress m_getPrevReleasesInfoProgress; Progress m_downloadProgress; @@ -445,6 +505,8 @@ TEST_F(AppUpdateServiceTests, DownloadRelease_ResumesFromPartial_SendsRangeHeade givenAvailableRelease(); ON_CALL(*m_fileSystem, exists(_)) .WillByDefault(Return(Ret(true))); + ON_CALL(*m_fileSystem, exists(io::path_t("upd/MuseScore.dmg"))) + .WillByDefault(Return(Ret(false))); ON_CALL(*m_fileSystem, fileSize(_)) .WillByDefault(Return(RetVal::make_ok(static_cast(1000)))); @@ -526,6 +588,8 @@ TEST_F(AppUpdateServiceTests, DownloadRelease_RangeNotHonoured_DiscardsPartial) givenAvailableRelease(); ON_CALL(*m_fileSystem, exists(_)) .WillByDefault(Return(Ret(true))); + ON_CALL(*m_fileSystem, exists(io::path_t("upd/MuseScore.dmg"))) + .WillByDefault(Return(Ret(false))); ON_CALL(*m_fileSystem, fileSize(_)) .WillByDefault(Return(RetVal::make_ok(static_cast(1000)))); EXPECT_CALL(*m_networkManager, get(_, _, _)) @@ -638,6 +702,8 @@ TEST_F(AppUpdateServiceTests, DownloadRelease_Resume_OnlyRemainingBytesRequired) givenAvailableRelease("MuseScore.dmg", "upd", 100 * mb); ON_CALL(*m_fileSystem, exists(_)) .WillByDefault(Return(Ret(true))); + ON_CALL(*m_fileSystem, exists(io::path_t("upd/MuseScore.dmg"))) + .WillByDefault(Return(Ret(false))); ON_CALL(*m_fileSystem, fileSize(_)) .WillByDefault(Return(RetVal::make_ok(90 * mb))); ON_CALL(*m_fileSystem, availableSpace(io::path_t("upd"))) @@ -654,48 +720,273 @@ TEST_F(AppUpdateServiceTests, DownloadRelease_Resume_OnlyRemainingBytesRequired) EXPECT_TRUE(rv.ret); } -TEST_F(AppUpdateServiceTests, PrepareUpdate_NotEnoughDiskSpace_DoesNotStage) +TEST_F(AppUpdateServiceTests, DownloadRelease_InPlace_PreparesBeforeFinishing) +{ + //! [GIVEN] A download of a release that can be installed in-place + givenInPlaceInstall(); + expectDownloadRequest(); + + RetVal rv = m_service->downloadRelease(); + ASSERT_TRUE(rv.ret); + + std::vector > progress; + rv.val.progressChanged().onReceive(this, [&progress](int64_t current, int64_t total, const std::string&) { + progress.emplace_back(current, total); + }); + + std::optional result; + rv.val.finished().onReceive(this, [&result](const ProgressResult& res) { + result = res; + }); + + //! [THEN] The download takes the first 90% of the progress + m_downloadProgress.progress(50, 100); + ASSERT_EQ(progress.size(), 1u); + EXPECT_EQ(progress.back(), std::make_pair(int64_t(450), int64_t(1000))); + + //! [THEN] The downloaded package is prepared + EXPECT_CALL(*m_updateInstaller, prepareUpdate(PACKAGE)) + .WillOnce(Return(RetVal::make_ok(STAGED))); + + //! [WHEN] The download finishes + finishDownload(); + + //! [THEN] Nothing is reported as finished until the update is prepared + EXPECT_FALSE(result.has_value()); + + pump(); + + //! [THEN] The preparation takes the rest of the progress, then the result is reported + ASSERT_EQ(progress.size(), 3u); + EXPECT_EQ(progress.at(1), std::make_pair(int64_t(900), int64_t(1000))); + EXPECT_EQ(progress.at(2), std::make_pair(int64_t(1000), int64_t(1000))); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE(result->ret); + EXPECT_EQ(result->val.toString(), PACKAGE.toStdString()); + + //! [THEN] The release is ready to install + ON_CALL(*m_fileSystem, exists(PACKAGE)) + .WillByDefault(Return(Ret(true))); + ON_CALL(*m_fileSystem, exists(STAGED)) + .WillByDefault(Return(Ret(true))); + EXPECT_TRUE(m_service->isReleaseReadyToInstall()); + + //! [THEN] Installing it hands the prepared update to the installer + EXPECT_CALL(*m_updateInstaller, finalizeUpdate(STAGED, _)) + .WillOnce(Return(muse::make_ok())); + EXPECT_TRUE(m_service->installUpdate()); +} + +TEST_F(AppUpdateServiceTests, DownloadRelease_AlreadyDownloaded_OnlyPrepares) +{ + //! [GIVEN] The package was downloaded in a previous session + givenInPlaceInstall(); + ON_CALL(*m_fileSystem, exists(PACKAGE)) + .WillByDefault(Return(Ret(true))); + + //! [THEN] Nothing is downloaded, the package is only prepared + EXPECT_CALL(*m_networkManager, get(_, _, _)) + .Times(0); + EXPECT_CALL(*m_updateInstaller, prepareUpdate(PACKAGE)) + .WillOnce(Return(RetVal::make_ok(STAGED))); + + //! [WHEN] Download the release + RetVal rv = m_service->downloadRelease(); + ASSERT_TRUE(rv.ret); + + //! [THEN] Subscribers attached right after the call get the result + std::optional result; + rv.val.finished().onReceive(this, [&result](const ProgressResult& res) { + result = res; + }); + + pump(); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE(result->ret); + EXPECT_EQ(result->val.toString(), PACKAGE.toStdString()); +} + +TEST_F(AppUpdateServiceTests, DownloadRelease_NotInPlace_FinishesWithoutPreparing) +{ + //! [GIVEN] A download of a release that can not be installed in-place + givenAvailableRelease(); + ON_CALL(*m_fileSystem, exists(_)) + .WillByDefault(Return(Ret(false))); + ON_CALL(*m_fileSystem, move(_, _, _)) + .WillByDefault(Return(muse::make_ok())); + expectDownloadRequest(); + + //! [THEN] Nothing is prepared + EXPECT_CALL(*m_updateInstaller, prepareUpdate(_)) + .Times(0); + + RetVal rv = m_service->downloadRelease(); + ASSERT_TRUE(rv.ret); + + std::optional result; + rv.val.finished().onReceive(this, [&result](const ProgressResult& res) { + result = res; + }); + + //! [WHEN] The download finishes + finishDownload(); + pump(); + + //! [THEN] The downloaded package is reported + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE(result->ret); + EXPECT_EQ(result->val.toString(), PACKAGE.toStdString()); + + //! [THEN] The downloaded release is ready to install as is + ON_CALL(*m_fileSystem, exists(PACKAGE)) + .WillByDefault(Return(Ret(true))); + EXPECT_TRUE(m_service->isReleaseReadyToInstall()); +} + +TEST_F(AppUpdateServiceTests, DownloadRelease_NoRoomToPrepare_FinishesWithError) { //! [GIVEN] A downloaded 100 MB package and only 150 MB left in the update data dir const uint64_t mb = 1024 * 1024; - const io::path_t package("upd/MuseScore.dmg"); - ON_CALL(*m_configuration, updateDataPath()) - .WillByDefault(Return(io::path_t("upd"))); - ON_CALL(*m_fileSystem, fileSize(package)) + givenInPlaceInstall(); + ON_CALL(*m_fileSystem, exists(PACKAGE)) + .WillByDefault(Return(Ret(true))); + ON_CALL(*m_fileSystem, fileSize(PACKAGE)) .WillByDefault(Return(RetVal::make_ok(100 * mb))); ON_CALL(*m_fileSystem, availableSpace(io::path_t("upd"))) .WillByDefault(Return(RetVal::make_ok(150 * mb))); - //! [THEN] The installer is not asked to stage anything + //! [THEN] The installer is not asked to prepare anything EXPECT_CALL(*m_updateInstaller, prepareUpdate(_)) .Times(0); - //! [WHEN] Prepare the update - RetVal rv = m_service->prepareUpdate(package); + RetVal rv = m_service->downloadRelease(); + ASSERT_TRUE(rv.ret); + + std::optional result; + rv.val.finished().onReceive(this, [&result](const ProgressResult& res) { + result = res; + }); + + pump(); //! [THEN] It fails with a disk space error - EXPECT_EQ(rv.ret.code(), static_cast(Err::NotEnoughDiskSpace)); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->ret.code(), static_cast(Err::NotEnoughDiskSpace)); } -TEST_F(AppUpdateServiceTests, PrepareUpdate_EnoughDiskSpace_Stages) +TEST_F(AppUpdateServiceTests, DownloadRelease_PrepareFailed_FinishesWithPackage) { - //! [GIVEN] A downloaded 100 MB package and plenty of space in the update data dir - const uint64_t mb = 1024 * 1024; - const io::path_t package("upd/MuseScore.dmg"); - ON_CALL(*m_configuration, updateDataPath()) - .WillByDefault(Return(io::path_t("upd"))); - ON_CALL(*m_fileSystem, fileSize(package)) - .WillByDefault(Return(RetVal::make_ok(100 * mb))); - ON_CALL(*m_fileSystem, availableSpace(io::path_t("upd"))) - .WillByDefault(Return(RetVal::make_ok(10000 * mb))); + //! [GIVEN] A download of a release that can be installed in-place + givenInPlaceInstall(); + expectDownloadRequest(); - //! [THEN] The installer stages the package - EXPECT_CALL(*m_updateInstaller, prepareUpdate(package)) - .WillOnce(Return(RetVal::make_ok(io::path_t("upd/staging/MuseScore.app")))); + //! [GIVEN] The package can not be prepared + EXPECT_CALL(*m_updateInstaller, prepareUpdate(PACKAGE)) + .WillOnce(Return(RetVal(make_ret(Err::UnknownError)))); - //! [WHEN] Prepare the update - RetVal rv = m_service->prepareUpdate(package); - EXPECT_TRUE(rv.ret); + RetVal rv = m_service->downloadRelease(); + ASSERT_TRUE(rv.ret); + + std::optional result; + rv.val.finished().onReceive(this, [&result](const ProgressResult& res) { + result = res; + }); + + //! [WHEN] The download finishes + finishDownload(); + pump(); + + //! [THEN] The package is still reported, so it can be installed manually + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE(result->ret); + EXPECT_EQ(result->val.toString(), PACKAGE.toStdString()); + + //! [THEN] It is not ready to install in-place + ON_CALL(*m_fileSystem, exists(PACKAGE)) + .WillByDefault(Return(Ret(true))); + EXPECT_FALSE(m_service->isReleaseReadyToInstall()); + + EXPECT_CALL(*m_updateInstaller, finalizeUpdate(_, _)) + .Times(0); + EXPECT_FALSE(m_service->installUpdate()); +} + +TEST_F(AppUpdateServiceTests, DownloadRelease_CanceledWhilePreparing_DropsPreparedUpdate) +{ + //! [GIVEN] The package is being prepared + givenInPlaceInstall(); + ON_CALL(*m_fileSystem, exists(PACKAGE)) + .WillByDefault(Return(Ret(true))); + EXPECT_CALL(*m_updateInstaller, prepareUpdate(PACKAGE)) + .WillOnce(Return(RetVal::make_ok(STAGED))); + + RetVal rv = m_service->downloadRelease(); + ASSERT_TRUE(rv.ret); + + std::vector results; + rv.val.finished().onReceive(this, [&results](const ProgressResult& res) { + results.push_back(res); + }); + + //! [THEN] The prepared files are removed + EXPECT_CALL(*m_fileSystem, remove(STAGED, false)) + .WillOnce(Return(muse::make_ok())); + + //! [WHEN] The release is removed while it is being prepared + async::processMessages(); + m_service->removeDownloadedRelease(); + pump(); + + //! [THEN] Only the cancellation is reported, and nothing is prepared + ASSERT_EQ(results.size(), 1u); + EXPECT_EQ(results.front().ret.code(), static_cast(Ret::Code::Cancel)); + + ON_CALL(*m_fileSystem, exists(STAGED)) + .WillByDefault(Return(Ret(true))); + EXPECT_FALSE(m_service->isReleaseReadyToInstall()); +} + +TEST_F(AppUpdateServiceTests, CleanupStalePackages_KeepsPreparedUpdateOfKeptPackage) +{ + //! [GIVEN] A prepared update + givenInPlaceInstall(); + ON_CALL(*m_fileSystem, exists(_)) + .WillByDefault(Return(Ret(true))); + ON_CALL(*m_updateInstaller, prepareUpdate(PACKAGE)) + .WillByDefault(Return(RetVal::make_ok(STAGED))); + + m_service->downloadRelease(); + pump(); + ASSERT_TRUE(m_service->isReleaseReadyToInstall()); + + ON_CALL(*m_fileSystem, scanFiles(io::path_t("upd"), _, _)) + .WillByDefault(Return(RetVal::make_ok(io::paths_t { + PACKAGE, io::path_t("upd/staging"), io::path_t("upd/other") + }))); + + //! [THEN] Only unrelated files are removed + EXPECT_CALL(*m_fileSystem, remove(PACKAGE, false)) + .Times(1) + .WillRepeatedly(Return(muse::make_ok())); + EXPECT_CALL(*m_fileSystem, remove(io::path_t("upd/other"), false)) + .Times(2) + .WillRepeatedly(Return(muse::make_ok())); + EXPECT_CALL(*m_fileSystem, remove(io::path_t("upd/staging"), false)) + .Times(0); + + //! [WHEN] The same release is found again + cleanupStalePackages("MuseScore.dmg"); + EXPECT_TRUE(m_service->isReleaseReadyToInstall()); + + //! [THEN] Everything is removed for another release + EXPECT_CALL(*m_fileSystem, remove(io::path_t("upd/staging"), false)) + .WillOnce(Return(muse::make_ok())); + + //! [WHEN] Another release is found + cleanupStalePackages("MuseScore-new.dmg"); + EXPECT_FALSE(m_service->isReleaseReadyToInstall()); } TEST_F(AppUpdateServiceTests, RemoveDownloadedRelease_RemovesPackageAndPartial) diff --git a/framework/update/tests/mocks/appupdateservicemock.h b/framework/update/tests/mocks/appupdateservicemock.h index 23f8840ad8..7b1ed4796f 100644 --- a/framework/update/tests/mocks/appupdateservicemock.h +++ b/framework/update/tests/mocks/appupdateservicemock.h @@ -34,13 +34,12 @@ class AppUpdateServiceMock : public IAppUpdateService MOCK_METHOD(const RetVal&, lastCheckResult, (), (const, override)); MOCK_METHOD(RetVal, downloadRelease, (), (override)); - MOCK_METHOD(bool, isReleaseDownloaded, (), (const, override)); + MOCK_METHOD(bool, isReleaseReadyToInstall, (), (const, override)); MOCK_METHOD(muse::io::path_t, downloadedReleasePath, (), (const, override)); MOCK_METHOD(void, removeDownloadedRelease, (), (override)); MOCK_METHOD(bool, canAutoInstall, (), (const, override)); - MOCK_METHOD(RetVal, prepareUpdate, (const muse::io::path_t&), (override)); - MOCK_METHOD(Ret, finalizeUpdate, (const muse::io::path_t&), (override)); + MOCK_METHOD(Ret, installUpdate, (), (override)); }; } diff --git a/framework/update/tests/mocks/updateconfigurationmock.h b/framework/update/tests/mocks/updateconfigurationmock.h index 1faf5516fa..f277baaa9b 100644 --- a/framework/update/tests/mocks/updateconfigurationmock.h +++ b/framework/update/tests/mocks/updateconfigurationmock.h @@ -39,8 +39,8 @@ class UpdateConfigurationMock : public IUpdateConfiguration MOCK_METHOD(void, setNeedCheckForUpdate, (bool), (override)); MOCK_METHOD(muse::async::Notification, needCheckForUpdateChanged, (), (const, override)); - MOCK_METHOD(bool, autoDownloadEnabled, (), (const, override)); - MOCK_METHOD(void, setAutoDownloadEnabled, (bool), (override)); + MOCK_METHOD(bool, autoUpdateEnabled, (), (const, override)); + MOCK_METHOD(void, setAutoUpdateEnabled, (bool), (override)); MOCK_METHOD(std::string, skippedReleaseVersion, (), (const, override)); MOCK_METHOD(void, setSkippedReleaseVersion, (const std::string&), (override)); diff --git a/framework/update/updatemodule.cpp b/framework/update/updatemodule.cpp index f78ebe755c..74f11d771d 100644 --- a/framework/update/updatemodule.cpp +++ b/framework/update/updatemodule.cpp @@ -134,6 +134,10 @@ void UpdateContext::resolveImports() void UpdateContext::onInit(const IApplication::RunMode&) { m_appUpdateService->init(); - m_appUpdateScenario->init(); m_actionController->init(); } + +void UpdateContext::onDelayedInit() +{ + m_appUpdateScenario->delayedInit(); +} diff --git a/framework/update/updatemodule.h b/framework/update/updatemodule.h index 8fc3d5be8d..41a3407ba7 100644 --- a/framework/update/updatemodule.h +++ b/framework/update/updatemodule.h @@ -56,6 +56,7 @@ class UpdateContext : public modularity::IContextSetup void registerExports() override; void resolveImports() override; void onInit(const IApplication::RunMode& mode) override; + void onDelayedInit() override; private: std::shared_ptr m_appUpdateScenario;