From 56dc92e482e31b3872859e772c8fd6a53a643e1f Mon Sep 17 00:00:00 2001 From: Eism Date: Thu, 11 Jun 2026 19:38:47 +0200 Subject: [PATCH 01/13] added update helper --- framework/update/CMakeLists.txt | 17 ++ framework/update/helper/CMakeLists.txt | 48 +++++ framework/update/helper/main.cpp | 186 ++++++++++++++++++ framework/update/helper/platform.h | 38 ++++ framework/update/helper/platform_mac.cpp | 86 ++++++++ framework/update/helper/platform_unix.cpp | 78 ++++++++ framework/update/helper/platform_win.cpp | 62 ++++++ framework/update/iappupdateservice.h | 9 + .../update/internal/appupdatescenario.cpp | 46 ++++- framework/update/internal/appupdatescenario.h | 1 + .../update/internal/appupdateservice.cpp | 58 ++++-- framework/update/internal/appupdateservice.h | 10 +- .../platform/mac/macupdateinstaller.cpp | 150 ++++++++++++++ .../platform/mac/macupdateinstaller.h | 53 +++++ .../platform/stub/updateinstallerstub.cpp | 35 ++++ .../platform/stub/updateinstallerstub.h | 38 ++++ .../update/internal/updateconfiguration.cpp | 13 ++ .../update/internal/updateconfiguration.h | 3 + framework/update/iupdateconfiguration.h | 5 + framework/update/iupdateinstaller.h | 52 +++++ .../tests/mocks/updateconfigurationmock.h | 3 + framework/update/updatemodule.cpp | 16 ++ framework/update/updatemodule.h | 2 + 23 files changed, 990 insertions(+), 19 deletions(-) create mode 100644 framework/update/helper/CMakeLists.txt create mode 100644 framework/update/helper/main.cpp create mode 100644 framework/update/helper/platform.h create mode 100644 framework/update/helper/platform_mac.cpp create mode 100644 framework/update/helper/platform_unix.cpp create mode 100644 framework/update/helper/platform_win.cpp create mode 100644 framework/update/internal/platform/mac/macupdateinstaller.cpp create mode 100644 framework/update/internal/platform/mac/macupdateinstaller.h create mode 100644 framework/update/internal/platform/stub/updateinstallerstub.cpp create mode 100644 framework/update/internal/platform/stub/updateinstallerstub.h create mode 100644 framework/update/iupdateinstaller.h diff --git a/framework/update/CMakeLists.txt b/framework/update/CMakeLists.txt index d2369b024b..1390e0e176 100644 --- a/framework/update/CMakeLists.txt +++ b/framework/update/CMakeLists.txt @@ -29,6 +29,7 @@ target_sources(muse_update PRIVATE iupdaterequestparamsprovider.h iappupdatescenario.h iappupdateservice.h + iupdateinstaller.h updatecommands.h internal/updateconfiguration.cpp @@ -47,10 +48,26 @@ target_sources(muse_update PRIVATE internal/appupdateservice.h ) +if (OS_IS_MAC) + target_sources(muse_update PRIVATE + internal/platform/mac/macupdateinstaller.cpp + internal/platform/mac/macupdateinstaller.h + ) +else() + target_sources(muse_update PRIVATE + internal/platform/stub/updateinstallerstub.cpp + internal/platform/stub/updateinstallerstub.h + ) +endif() + if (MUSE_QT_SUPPORT) target_link_libraries(muse_update PRIVATE Qt::Concurrent) endif() +if (NOT CC_IS_EMCC) + add_subdirectory(helper) +endif() + if (MUSE_MODULE_UPDATE_TESTS) add_subdirectory(tests) endif() diff --git a/framework/update/helper/CMakeLists.txt b/framework/update/helper/CMakeLists.txt new file mode 100644 index 0000000000..cef00dc6de --- /dev/null +++ b/framework/update/helper/CMakeLists.txt @@ -0,0 +1,48 @@ +# 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 . + +set(UPDATE_HELPER_TARGET museupdater) + +set(UPDATE_HELPER_SRC + main.cpp + platform.h +) + +if (OS_IS_MAC) + list(APPEND UPDATE_HELPER_SRC platform_mac.cpp) +elseif (OS_IS_WIN) + list(APPEND UPDATE_HELPER_SRC platform_win.cpp) +else() + list(APPEND UPDATE_HELPER_SRC platform_unix.cpp) +endif() + +add_executable(${UPDATE_HELPER_TARGET} ${UPDATE_HELPER_SRC}) + +set_target_properties(${UPDATE_HELPER_TARGET} PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON +) + +# On macOS the helper is embedded into the app bundle by the app target. +if (NOT OS_IS_MAC) + install(TARGETS ${UPDATE_HELPER_TARGET} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + ) +endif() diff --git a/framework/update/helper/main.cpp b/framework/update/helper/main.cpp new file mode 100644 index 0000000000..50f4beb2cf --- /dev/null +++ b/framework/update/helper/main.cpp @@ -0,0 +1,186 @@ +/* + * 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 . + */ + +//! NOTE: Standalone, zero-runtime-dependency update helper. +//! It is copied out of the application install location and run from there so +//! that replacing the install location never touches a file it is executing. +//! It waits for the host application to exit, atomically swaps the install +//! location with the freshly unpacked update (keeping a backup for rollback), +//! relaunches the application and removes itself. + +#include "platform.h" + +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { +struct Args { + long long waitPid = 0; + std::string src; // freshly unpacked new install (dir or bundle) + std::string dst; // current install location to be replaced + std::string relaunch; // path to launch after a successful swap + std::string logPath; +}; + +FILE* g_log = nullptr; + +void logLine(const std::string& msg) +{ + if (g_log) { + std::fprintf(g_log, "%s\n", msg.c_str()); + std::fflush(g_log); + } +} + +Args parseArgs(int argc, char** argv) +{ + std::map kv; + for (int i = 1; i + 1 < argc; i += 2) { + kv[argv[i]] = argv[i + 1]; + } + + Args a; + if (kv.count("--wait-pid")) { + a.waitPid = std::stoll(kv["--wait-pid"]); + } + a.src = kv.count("--src") ? kv["--src"] : std::string(); + a.dst = kv.count("--dst") ? kv["--dst"] : std::string(); + a.relaunch = kv.count("--relaunch") ? kv["--relaunch"] : std::string(); + a.logPath = kv.count("--log") ? kv["--log"] : std::string(); + return a; +} + +//! Move `from` to `to`, falling back to copy+remove when rename crosses a +//! filesystem boundary (rename only succeeds within a single volume). +bool movePath(const fs::path& from, const fs::path& to, std::error_code& ec) +{ + fs::rename(from, to, ec); + if (!ec) { + return true; + } + + ec.clear(); + fs::copy(from, to, fs::copy_options::recursive | fs::copy_options::copy_symlinks, ec); + if (ec) { + return false; + } + + std::error_code rmEc; + fs::remove_all(from, rmEc); + return true; +} +} + +int main(int argc, char** argv) +{ + Args args = parseArgs(argc, argv); + + if (!args.logPath.empty()) { + g_log = std::fopen(args.logPath.c_str(), "w"); + } + + logLine("museupdater started"); + logLine(" src=" + args.src); + logLine(" dst=" + args.dst); + logLine(" relaunch=" + args.relaunch); + logLine(" wait-pid=" + std::to_string(args.waitPid)); + + if (args.src.empty() || args.dst.empty()) { + logLine("error: --src and --dst are required"); + return 1; + } + + // 1. Wait for the host application to fully exit before touching its files. + if (args.waitPid > 0) { + platform::waitForProcessExit(args.waitPid, /*timeoutMs*/ 60000); + } + + const fs::path src(args.src); + const fs::path dst(args.dst); + const fs::path backup = fs::path(dst).concat(".bak"); + + std::error_code ec; + + if (!fs::exists(src, ec)) { + logLine("error: src does not exist"); + return 1; + } + + // 2. Backup the current install location. + fs::remove_all(backup, ec); + ec.clear(); + if (fs::exists(dst, ec)) { + if (!movePath(dst, backup, ec)) { + logLine("error: failed to backup dst: " + ec.message()); + return 1; + } + } + + // 3. Swap in the new install. + if (!movePath(src, dst, ec)) { + logLine("error: failed to move src into place: " + ec.message()); + + // Rollback. + std::error_code rbEc; + if (fs::exists(backup, rbEc)) { + movePath(backup, dst, rbEc); + } + return 1; + } + + // 4. Verify the result; rollback on failure (platform-specific check, e.g. + // code signature validity on macOS). + if (!platform::verifyInstall(dst.string())) { + logLine("error: post-swap verification failed, rolling back"); + + std::error_code rbEc; + fs::remove_all(dst, rbEc); + if (fs::exists(backup, rbEc)) { + movePath(backup, dst, rbEc); + } + return 1; + } + + // 5. Success: drop the backup. + fs::remove_all(backup, ec); + + // 6. Relaunch the updated application. + const std::string relaunchTarget = args.relaunch.empty() ? dst.string() : args.relaunch; + if (!platform::relaunch(relaunchTarget)) { + logLine("error: failed to relaunch " + relaunchTarget); + // The update itself succeeded; the user can start the app manually. + } + + logLine("museupdater finished"); + + if (g_log) { + std::fclose(g_log); + g_log = nullptr; + } + + return 0; +} diff --git a/framework/update/helper/platform.h b/framework/update/helper/platform.h new file mode 100644 index 0000000000..aef8f154b4 --- /dev/null +++ b/framework/update/helper/platform.h @@ -0,0 +1,38 @@ +/* + * 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 + +namespace platform { +//! Block until the process `pid` has exited, or `timeoutMs` elapses. +void waitForProcessExit(long long pid, int timeoutMs); + +//! Verify that the swapped-in install at `path` is launchable. +//! On macOS this checks the code signature; elsewhere it just checks existence. +bool verifyInstall(const std::string& path); + +//! Launch the updated application. `path` is an .app bundle on macOS, +//! an executable on Windows/Linux. +bool relaunch(const std::string& path); +} diff --git a/framework/update/helper/platform_mac.cpp b/framework/update/helper/platform_mac.cpp new file mode 100644 index 0000000000..419d4c822a --- /dev/null +++ b/framework/update/helper/platform_mac.cpp @@ -0,0 +1,86 @@ +/* + * 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 "platform.h" + +#include +#include +#include + +#include +#include + +extern char** environ; + +namespace { +void sleepMs(int ms) +{ + struct timespec ts; + ts.tv_sec = ms / 1000; + ts.tv_nsec = (ms % 1000) * 1000000L; + nanosleep(&ts, nullptr); +} + +int runDetachedAndWait(const char* path, char* const argv[]) +{ + pid_t pid = 0; + int rc = posix_spawn(&pid, path, nullptr, nullptr, argv, environ); + if (rc != 0) { + return -1; + } + int status = 0; + waitpid(pid, &status, 0); + return WIFEXITED(status) ? WEXITSTATUS(status) : -1; +} +} + +namespace platform { +void waitForProcessExit(long long pid, int timeoutMs) +{ + const int step = 100; + int waited = 0; + while (waited < timeoutMs) { + if (::kill(static_cast(pid), 0) != 0) { + // No such process -> it has exited. + return; + } + sleepMs(step); + waited += step; + } +} + +bool verifyInstall(const std::string& path) +{ + std::string cmd = "/usr/bin/codesign --verify --deep --strict \"" + path + "\""; + return std::system(cmd.c_str()) == 0; +} + +bool relaunch(const std::string& path) +{ + char* const argv[] = { + const_cast("open"), + const_cast(path.c_str()), + nullptr + }; + return runDetachedAndWait("/usr/bin/open", argv) == 0; +} +} diff --git a/framework/update/helper/platform_unix.cpp b/framework/update/helper/platform_unix.cpp new file mode 100644 index 0000000000..5a526f9946 --- /dev/null +++ b/framework/update/helper/platform_unix.cpp @@ -0,0 +1,78 @@ +/* + * 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 "platform.h" + +#include +#include +#include +#include + +#include + +extern char** environ; + +namespace { +void sleepMs(int ms) +{ + struct timespec ts; + ts.tv_sec = ms / 1000; + ts.tv_nsec = (ms % 1000) * 1000000L; + nanosleep(&ts, nullptr); +} +} + +namespace platform { +void waitForProcessExit(long long pid, int timeoutMs) +{ + const int step = 100; + int waited = 0; + while (waited < timeoutMs) { + if (::kill(static_cast(pid), 0) != 0) { + return; + } + sleepMs(step); + waited += step; + } +} + +bool verifyInstall(const std::string& path) +{ + struct stat st; + return ::stat(path.c_str(), &st) == 0; +} + +bool relaunch(const std::string& path) +{ + // Ensure the AppImage is executable, then launch it detached. + ::chmod(path.c_str(), 0755); + + char* const argv[] = { + const_cast(path.c_str()), + nullptr + }; + + pid_t pid = 0; + int rc = posix_spawn(&pid, path.c_str(), nullptr, nullptr, argv, environ); + return rc == 0; +} +} diff --git a/framework/update/helper/platform_win.cpp b/framework/update/helper/platform_win.cpp new file mode 100644 index 0000000000..f238fbc54c --- /dev/null +++ b/framework/update/helper/platform_win.cpp @@ -0,0 +1,62 @@ +/* + * 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 "platform.h" + +#include + +namespace platform { +void waitForProcessExit(long long pid, int timeoutMs) +{ + HANDLE hProc = OpenProcess(SYNCHRONIZE, FALSE, static_cast(pid)); + if (!hProc) { + // Already gone, or no access. + return; + } + WaitForSingleObject(hProc, static_cast(timeoutMs)); + CloseHandle(hProc); +} + +bool verifyInstall(const std::string& path) +{ + DWORD attrs = GetFileAttributesA(path.c_str()); + return attrs != INVALID_FILE_ATTRIBUTES; +} + +bool relaunch(const std::string& path) +{ + STARTUPINFOA si; + PROCESS_INFORMATION pi; + ZeroMemory(&si, sizeof(si)); + si.cb = sizeof(si); + ZeroMemory(&pi, sizeof(pi)); + + std::string cmd = "\"" + path + "\""; + BOOL ok = CreateProcessA(nullptr, cmd.data(), nullptr, nullptr, FALSE, + 0, nullptr, nullptr, &si, &pi); + if (ok) { + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + } + return ok == TRUE; +} +} diff --git a/framework/update/iappupdateservice.h b/framework/update/iappupdateservice.h index 6122e3208b..4c56fdbf14 100644 --- a/framework/update/iappupdateservice.h +++ b/framework/update/iappupdateservice.h @@ -41,5 +41,14 @@ class IAppUpdateService : MODULE_CONTEXT_INTERFACE virtual async::Promise > checkForUpdate() = 0; virtual const RetVal& lastCheckResult() const = 0; virtual RetVal downloadRelease() = 0; + + //! Whether the downloaded release can be installed in-place automatically + //! (replace the install location and restart) instead of handing the + //! installer to the user. + virtual bool canAutoInstall() const = 0; + + //! Apply a previously downloaded release in-place. Returns OK if the update + //! was successfully staged; the caller must then quit the application. + virtual Ret applyUpdate(const muse::io::path_t& packagePath) = 0; }; } diff --git a/framework/update/internal/appupdatescenario.cpp b/framework/update/internal/appupdatescenario.cpp index e64af94910..ed1ee3ef57 100644 --- a/framework/update/internal/appupdatescenario.cpp +++ b/framework/update/internal/appupdatescenario.cpp @@ -190,7 +190,51 @@ Promise AppUpdateScenario::downloadRelease() if (!rv.ret) { return processUpdateError(rv.ret.code()); } - return askToCloseAppAndCompleteInstall(rv.val.toString()); + + const io::path_t 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 askToRestartAndInstall(packagePath); + } + + return askToCloseAppAndCompleteInstall(packagePath); +} + +Promise AppUpdateScenario::askToRestartAndInstall(const io::path_t& packagePath) +{ + 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.") + .arg(application()->title().toQString()).toStdString(); + const int restartBtn = int(IInteractive::Button::CustomButton) + 1; + 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](const IInteractive::Result& res, auto resolve) { + if (res.isButton(IInteractive::Button::Cancel)) { + return resolve(muse::make_ret(Ret::Code::Cancel)); + } + + const Ret ret = service()->applyUpdate(packagePath); + if (!ret) { + LOGE() << "failed to apply update in-place, falling back to manual install: " << ret.toString(); + askToCloseAppAndCompleteInstall(packagePath).onResolve(this, [resolve](const Ret& r) { + (void)resolve(r); + }); + return Promise::dummy_result(); + } + + //! 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())); + return resolve(muse::make_ok()); + }); } Promise AppUpdateScenario::askToCloseAppAndCompleteInstall(const io::path_t& installerPath) diff --git a/framework/update/internal/appupdatescenario.h b/framework/update/internal/appupdatescenario.h index 3ef072e5d7..c45bbf337f 100644 --- a/framework/update/internal/appupdatescenario.h +++ b/framework/update/internal/appupdatescenario.h @@ -65,6 +65,7 @@ class AppUpdateScenario : public IAppUpdateScenario, public Contextable, public muse::async::Promise downloadRelease(); muse::async::Promise askToCloseAppAndCompleteInstall(const io::path_t& installerPath); + muse::async::Promise askToRestartAndInstall(const io::path_t& packagePath); bool shouldIgnoreUpdate(const ReleaseInfo& info) const; diff --git a/framework/update/internal/appupdateservice.cpp b/framework/update/internal/appupdateservice.cpp index a28ec5fe02..057978a71c 100644 --- a/framework/update/internal/appupdateservice.cpp +++ b/framework/update/internal/appupdateservice.cpp @@ -349,21 +349,27 @@ RetVal AppUpdateService::parseRelease(const QByteArray& json) const return result; } -std::string AppUpdateService::platformFileSuffix() const +std::vector AppUpdateService::platformFileSuffixes() const { switch (systemInfo()->productType()) { - case ISystemInfo::ProductType::Windows: return "msi"; - case ISystemInfo::ProductType::MacOS: return "dmg"; - case ISystemInfo::ProductType::Linux: return "appimage"; + case ISystemInfo::ProductType::Windows: return { "msi" }; + case ISystemInfo::ProductType::MacOS: + // Prefer the zip bundle for in-place auto-install, falling back to the + // dmg for the manual flow (and for releases that ship only a dmg). + if (canAutoInstall()) { + return { "zip", "dmg" }; + } + return { "dmg" }; + case ISystemInfo::ProductType::Linux: return { "appimage" }; case ISystemInfo::ProductType::Unknown: break; } - return ""; + return {}; } QJsonObject AppUpdateService::resolveReleaseAsset(const QJsonObject& release) const { - std::string fileSuffix = platformFileSuffix(); + const std::vector fileSuffixes = platformFileSuffixes(); ISystemInfo::ProductType productType = systemInfo()->productType(); ISystemInfo::CpuArchitecture arch = systemInfo()->cpuArchitecture(); @@ -372,26 +378,44 @@ QJsonObject AppUpdateService::resolveReleaseAsset(const QJsonObject& release) co assets.push_back(asset); } - for (const QJsonValue asset : assets) { - QJsonObject assetObj = asset.toObject(); - - QString name = assetObj.value("name").toString(); - if (io::suffix(name) != fileSuffix) { - continue; - } + // Honour suffix priority: scan all assets for the most preferred suffix + // before considering the next one. + for (const std::string& fileSuffix : fileSuffixes) { + for (const QJsonValue asset : assets) { + QJsonObject assetObj = asset.toObject(); - if (productType == ISystemInfo::ProductType::Linux) { - if (arch != ISystemInfo::CpuArchitecture::Unknown && arch != assetArch(name)) { + QString name = assetObj.value("name").toString(); + if (io::suffix(name) != fileSuffix) { continue; } - } - return assetObj; + if (productType == ISystemInfo::ProductType::Linux) { + if (arch != ISystemInfo::CpuArchitecture::Unknown && arch != assetArch(name)) { + continue; + } + } + + return assetObj; + } } return QJsonObject(); } +bool AppUpdateService::canAutoInstall() const +{ + if (!configuration()->autoInstallEnabled()) { + return false; + } + + return updateInstaller()->isInPlaceUpdateSupported(); +} + +Ret AppUpdateService::applyUpdate(const muse::io::path_t& packagePath) +{ + return updateInstaller()->applyUpdate(packagePath); +} + void AppUpdateService::downloadPreviousReleasesNotes(const Version& updateVersion, const PrevReleaseNotesCallback& finished) { QUrl url = QString::fromStdString(configuration()->previousAppReleasesNotesUrl()); diff --git a/framework/update/internal/appupdateservice.h b/framework/update/internal/appupdateservice.h index 958bb5d7eb..4be8508906 100644 --- a/framework/update/internal/appupdateservice.h +++ b/framework/update/internal/appupdateservice.h @@ -33,6 +33,7 @@ #include "network/inetworkmanagercreator.h" #include "update/iupdateconfiguration.h" #include "update/iupdaterequestparamsprovider.h" +#include "update/iupdateinstaller.h" namespace muse::update { class AppUpdateService : public IAppUpdateService, public Contextable, public async::Asyncable @@ -43,6 +44,7 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as GlobalInject requestParamsProvider; GlobalInject networkManagerCreator; GlobalInject application; + GlobalInject updateInstaller; public: AppUpdateService(const modularity::ContextPtr& iocCtx) @@ -54,6 +56,9 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as const RetVal& lastCheckResult() const override; RetVal downloadRelease() override; + bool canAutoInstall() const override; + Ret applyUpdate(const muse::io::path_t& packagePath) override; + private: friend class AppUpdateServiceTests; @@ -69,7 +74,10 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as RetVal parseRelease(const QByteArray& json) const; - std::string platformFileSuffix() const; + //! Ordered list of acceptable asset suffixes for this platform, most + //! preferred first (e.g. "zip" before "dmg" on macOS when auto-install is + //! available). + std::vector platformFileSuffixes() const; QJsonObject resolveReleaseAsset(const QJsonObject& release) const; using PrevReleaseNotesCallback = std::function; diff --git a/framework/update/internal/platform/mac/macupdateinstaller.cpp b/framework/update/internal/platform/mac/macupdateinstaller.cpp new file mode 100644 index 0000000000..2cad3854e3 --- /dev/null +++ b/framework/update/internal/platform/mac/macupdateinstaller.cpp @@ -0,0 +1,150 @@ +/* + * 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 "macupdateinstaller.h" + +#include + +#include +#include +#include +#include +#include +#include + +#include "../../../updateerrors.h" + +#include "log.h" + +using namespace muse; +using namespace muse::update; + +static const QString HELPER_NAME("museupdater"); + +io::path_t MacUpdateInstaller::currentBundlePath() const +{ + // applicationDirPath() == .app/Contents/MacOS + QDir dir(QCoreApplication::applicationDirPath()); + dir.cdUp(); // Contents + dir.cdUp(); // .app + return io::path_t(dir.absolutePath()); +} + +io::path_t MacUpdateInstaller::helperPath() const +{ + return io::path_t(QCoreApplication::applicationDirPath() + "/" + HELPER_NAME); +} + +bool MacUpdateInstaller::isInPlaceUpdateSupported() const +{ + const QString bundlePath = currentBundlePath().toQString(); + if (bundlePath.isEmpty() || !bundlePath.endsWith(".app")) { + return false; + } + + if (!QFileInfo::exists(helperPath().toQString())) { + return false; + } + + // In-place replacement only works if we can write the bundle without + // privilege escalation. + if (::access(bundlePath.toUtf8().constData(), W_OK) != 0) { + return false; + } + + return true; +} + +Ret MacUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath) +{ + const QString package = packagePath.toQString(); + if (!QFileInfo::exists(package)) { + LOGE() << "update package does not exist: " << package; + return make_ret(Err::UnknownError); + } + + const QString stagingDir = configuration()->updateDataPath().toQString() + "/staging"; + QDir().rmpath(stagingDir); + QDir staging(stagingDir); + if (staging.exists()) { + staging.removeRecursively(); + } + QDir().mkpath(stagingDir); + + // 1. Unpack the zip preserving extended attributes and code signatures. + int rc = QProcess::execute("/usr/bin/ditto", { "-xk", package, stagingDir }); + if (rc != 0) { + LOGE() << "failed to unpack update package, ditto rc=" << rc; + return make_ret(Err::UnknownError); + } + + // 2. Locate the unpacked .app bundle. + QString stagingApp; + const QStringList apps = staging.entryList({ "*.app" }, QDir::Dirs | QDir::NoDotAndDotDot); + if (!apps.isEmpty()) { + stagingApp = stagingDir + "/" + apps.first(); + } + if (stagingApp.isEmpty()) { + LOGE() << "no .app bundle found in unpacked update"; + return make_ret(Err::UnknownError); + } + + // 3. Remove the quarantine attribute set by the download. + QProcess::execute("/usr/bin/xattr", { "-dr", "com.apple.quarantine", stagingApp }); + + // 4. Verify the unpacked bundle is correctly signed before trusting it. + rc = QProcess::execute("/usr/bin/codesign", { "--verify", "--deep", "--strict", stagingApp }); + if (rc != 0) { + LOGE() << "code signature verification failed for unpacked update, rc=" << rc; + return make_ret(Err::UnknownError); + } + + // 5. Copy the helper out of the bundle so replacing the bundle never + // touches the running helper file. + const QString helperRun = configuration()->updateDataPath().toQString() + "/" + HELPER_NAME; + QFile::remove(helperRun); + if (!QFile::copy(helperPath().toQString(), helperRun)) { + LOGE() << "failed to copy helper to " << helperRun; + return make_ret(Err::UnknownError); + } + QFile::setPermissions(helperRun, QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner + | QFile::ReadGroup | QFile::ExeGroup | QFile::ReadOther | QFile::ExeOther); + + // 6. Spawn the detached helper. It waits for us to quit, swaps the bundle + // and relaunches. + const QString bundlePath = currentBundlePath().toQString(); + const QString logPath = configuration()->updateDataPath().toQString() + "/museupdater.log"; + const QStringList args = { + "--wait-pid", QString::number(QCoreApplication::applicationPid()), + "--src", stagingApp, + "--dst", bundlePath, + "--relaunch", bundlePath, + "--log", logPath + }; + + if (!QProcess::startDetached(helperRun, args)) { + LOGE() << "failed to start update helper"; + return make_ret(Err::UnknownError); + } + + LOGI() << "update helper started, will replace " << bundlePath << " after quit"; + return make_ok(); +} diff --git a/framework/update/internal/platform/mac/macupdateinstaller.h b/framework/update/internal/platform/mac/macupdateinstaller.h new file mode 100644 index 0000000000..08e1370388 --- /dev/null +++ b/framework/update/internal/platform/mac/macupdateinstaller.h @@ -0,0 +1,53 @@ +/* + * 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 . + */ +#ifndef MUSE_UPDATE_MACUPDATEINSTALLER_H +#define MUSE_UPDATE_MACUPDATEINSTALLER_H + +#include "../../../iupdateinstaller.h" + +#include "modularity/ioc.h" +#include "io/ifilesystem.h" +#include "../../../iupdateconfiguration.h" + +namespace muse::update { +class MacUpdateInstaller : public IUpdateInstaller, public Contextable +{ + GlobalInject fileSystem; + GlobalInject configuration; + +public: + MacUpdateInstaller(const modularity::ContextPtr& iocCtx) + : Contextable(iocCtx) {} + + bool isInPlaceUpdateSupported() const override; + Ret applyUpdate(const muse::io::path_t& packagePath) override; + +private: + //! Path to the running `*.app` bundle (the install location to replace). + muse::io::path_t currentBundlePath() const; + + //! Path to the bundled `museupdater` helper (Contents/MacOS/museupdater). + muse::io::path_t helperPath() const; +}; +} + +#endif // MUSE_UPDATE_MACUPDATEINSTALLER_H diff --git a/framework/update/internal/platform/stub/updateinstallerstub.cpp b/framework/update/internal/platform/stub/updateinstallerstub.cpp new file mode 100644 index 0000000000..10554de492 --- /dev/null +++ b/framework/update/internal/platform/stub/updateinstallerstub.cpp @@ -0,0 +1,35 @@ +/* + * 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 "updateinstallerstub.h" + +using namespace muse; +using namespace muse::update; + +bool UpdateInstallerStub::isInPlaceUpdateSupported() const +{ + return false; +} + +Ret UpdateInstallerStub::applyUpdate(const muse::io::path_t&) +{ + return make_ret(Ret::Code::NotSupported); +} diff --git a/framework/update/internal/platform/stub/updateinstallerstub.h b/framework/update/internal/platform/stub/updateinstallerstub.h new file mode 100644 index 0000000000..7abbb0f080 --- /dev/null +++ b/framework/update/internal/platform/stub/updateinstallerstub.h @@ -0,0 +1,38 @@ +/* + * 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 . + */ +#ifndef MUSE_UPDATE_UPDATEINSTALLERSTUB_H +#define MUSE_UPDATE_UPDATEINSTALLERSTUB_H + +#include "../../../iupdateinstaller.h" + +namespace muse::update { +//! Used on platforms that do not yet implement in-place updates. Callers fall +//! back to opening the downloaded installer for the user to run manually. +class UpdateInstallerStub : public IUpdateInstaller +{ +public: + bool isInPlaceUpdateSupported() const override; + Ret applyUpdate(const muse::io::path_t& packagePath) override; +}; +} + +#endif // MUSE_UPDATE_UPDATEINSTALLERSTUB_H diff --git a/framework/update/internal/updateconfiguration.cpp b/framework/update/internal/updateconfiguration.cpp index 295b23a1eb..bed6862c5d 100644 --- a/framework/update/internal/updateconfiguration.cpp +++ b/framework/update/internal/updateconfiguration.cpp @@ -34,6 +34,7 @@ static const Settings::Key CHECK_FOR_UPDATE_KEY(module_name, "application/checkF static const Settings::Key CHECK_FOR_UPDATE_TEST_MODE_KEY(module_name, "application/checkForUpdateTestMode"); static const Settings::Key ALLOW_UPDATE_ON_PRERELEASE(module_name, "application/allowUpdateOnPreRelease"); static const Settings::Key SKIPPED_VERSION_KEY(module_name, "application/skippedVersion"); +static const Settings::Key AUTO_INSTALL_KEY(module_name, "application/autoInstall"); void UpdateConfiguration::init() { @@ -53,6 +54,8 @@ void UpdateConfiguration::init() allowUpdateOnPreRelease = false; #endif settings()->setDefaultValue(ALLOW_UPDATE_ON_PRERELEASE, Val(allowUpdateOnPreRelease)); + + settings()->setDefaultValue(AUTO_INSTALL_KEY, Val(true)); } bool UpdateConfiguration::isAppUpdatable() const @@ -85,6 +88,16 @@ async::Notification UpdateConfiguration::needCheckForUpdateChanged() const return m_needCheckForUpdateChanged; } +bool UpdateConfiguration::autoInstallEnabled() const +{ + return settings()->value(AUTO_INSTALL_KEY).toBool(); +} + +void UpdateConfiguration::setAutoInstallEnabled(bool enabled) +{ + settings()->setSharedValue(AUTO_INSTALL_KEY, Val(enabled)); +} + std::string UpdateConfiguration::skippedReleaseVersion() const { return settings()->value(SKIPPED_VERSION_KEY).toString(); diff --git a/framework/update/internal/updateconfiguration.h b/framework/update/internal/updateconfiguration.h index 7c8f315ae1..8bdce200d6 100644 --- a/framework/update/internal/updateconfiguration.h +++ b/framework/update/internal/updateconfiguration.h @@ -52,6 +52,9 @@ class UpdateConfiguration : public IUpdateConfiguration, public Contextable, pub void setNeedCheckForUpdate(bool needCheck) override; muse::async::Notification needCheckForUpdateChanged() const override; + bool autoInstallEnabled() const override; + void setAutoInstallEnabled(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 a681de9c02..5e80d5d3e5 100644 --- a/framework/update/iupdateconfiguration.h +++ b/framework/update/iupdateconfiguration.h @@ -45,6 +45,11 @@ class IUpdateConfiguration : MODULE_GLOBAL_INTERFACE virtual void setNeedCheckForUpdate(bool needCheck) = 0; virtual muse::async::Notification needCheckForUpdateChanged() const = 0; + //! User preference: apply updates in-place and restart automatically (when + //! the platform supports it) instead of opening the downloaded installer. + virtual bool autoInstallEnabled() const = 0; + virtual void setAutoInstallEnabled(bool enabled) = 0; + virtual std::string skippedReleaseVersion() const = 0; virtual void setSkippedReleaseVersion(const std::string& version) = 0; diff --git a/framework/update/iupdateinstaller.h b/framework/update/iupdateinstaller.h new file mode 100644 index 0000000000..5cddf3a359 --- /dev/null +++ b/framework/update/iupdateinstaller.h @@ -0,0 +1,52 @@ +/* + * 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 . + */ +#ifndef MUSE_UPDATE_IUPDATEINSTALLER_H +#define MUSE_UPDATE_IUPDATEINSTALLER_H + +#include "types/ret.h" +#include "io/path.h" + +#include "modularity/imoduleinterface.h" + +namespace muse::update { +class IUpdateInstaller : MODULE_GLOBAL_INTERFACE +{ + INTERFACE_ID(IUpdateInstaller) + +public: + virtual ~IUpdateInstaller() = default; + + //! Whether this platform can apply an update in-place (replace the install + //! location and restart) AND the install location is writable by the + //! current user. When false, callers should fall back to opening the + //! downloaded installer for the user to run manually. + virtual bool isInPlaceUpdateSupported() const = 0; + + //! Unpack `packagePath`, spawn the standalone helper that replaces the + //! current install location once this process exits, and relaunches the + //! application. Returns OK if the helper was successfully spawned; the + //! caller must then quit the application. + virtual Ret applyUpdate(const muse::io::path_t& packagePath) = 0; +}; +} + +#endif // MUSE_UPDATE_IUPDATEINSTALLER_H diff --git a/framework/update/tests/mocks/updateconfigurationmock.h b/framework/update/tests/mocks/updateconfigurationmock.h index 8ea2cb5cd7..470a38e32f 100644 --- a/framework/update/tests/mocks/updateconfigurationmock.h +++ b/framework/update/tests/mocks/updateconfigurationmock.h @@ -39,6 +39,9 @@ class UpdateConfigurationMock : public IUpdateConfiguration MOCK_METHOD(void, setNeedCheckForUpdate, (bool), (override)); MOCK_METHOD(muse::async::Notification, needCheckForUpdateChanged, (), (const, override)); + MOCK_METHOD(bool, autoInstallEnabled, (), (const, override)); + MOCK_METHOD(void, setAutoInstallEnabled, (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 9d8a614dd5..4a21c21bce 100644 --- a/framework/update/updatemodule.cpp +++ b/framework/update/updatemodule.cpp @@ -21,6 +21,8 @@ */ #include "updatemodule.h" +#include + #include "modularity/ioc.h" #include "interactive/iinteractiveuriregister.h" @@ -37,6 +39,13 @@ #include "internal/appupdatescenario.h" #include "internal/appupdateservice.h" +#include "iupdateinstaller.h" +#ifdef Q_OS_MAC +#include "internal/platform/mac/macupdateinstaller.h" +#else +#include "internal/platform/stub/updateinstallerstub.h" +#endif + using namespace muse::update; using namespace muse::modularity; @@ -51,7 +60,14 @@ void UpdateModule::registerExports() { m_configuration = std::make_shared(globalCtx()); +#ifdef Q_OS_MAC + m_updateInstaller = std::make_shared(globalCtx()); +#else + m_updateInstaller = std::make_shared(); +#endif + globalIoc()->registerExport(mname, m_configuration); + globalIoc()->registerExport(mname, m_updateInstaller); } void UpdateModule::resolveImports() diff --git a/framework/update/updatemodule.h b/framework/update/updatemodule.h index 6fe54d62d5..8fc3d5be8d 100644 --- a/framework/update/updatemodule.h +++ b/framework/update/updatemodule.h @@ -31,6 +31,7 @@ class AppUpdateScenario; class AppUpdateService; class UpdateConfiguration; class UpdateActionController; +class IUpdateInstaller; class UpdateModule : public modularity::IModuleSetup { public: @@ -43,6 +44,7 @@ class UpdateModule : public modularity::IModuleSetup private: std::shared_ptr m_configuration; + std::shared_ptr m_updateInstaller; }; class UpdateContext : public modularity::IContextSetup From bcc0609b7f9a00124fec74041c41d979d3125654 Mon Sep 17 00:00:00 2001 From: Eism Date: Fri, 12 Jun 2026 10:49:58 +0200 Subject: [PATCH 02/13] added download update in the background --- .../stubs/update/appupdatescenariostub.cpp | 28 +++++ .../stubs/update/appupdatescenariostub.h | 9 ++ .../stubs/update/appupdateservicestub.cpp | 20 +++ framework/stubs/update/appupdateservicestub.h | 6 + framework/update/CMakeLists.txt | 2 + framework/update/iappupdatescenario.h | 15 +++ framework/update/iappupdateservice.h | 8 +- .../update/internal/appupdatescenario.cpp | 74 +++++++++++ framework/update/internal/appupdatescenario.h | 14 +++ .../update/internal/appupdateservice.cpp | 114 +++++++++++++++-- framework/update/internal/appupdateservice.h | 5 + .../update/internal/downloadfiledevice.cpp | 73 +++++++++++ .../update/internal/downloadfiledevice.h | 52 ++++++++ .../update/qml/Muse/Update/CMakeLists.txt | 3 + .../update/qml/Muse/Update/UpdateBanner.qml | 85 +++++++++++++ .../qml/Muse/Update/updatebannermodel.cpp | 51 ++++++++ .../qml/Muse/Update/updatebannermodel.h | 57 +++++++++ .../update/tests/appupdateservice_tests.cpp | 116 ++++++++++++++++++ 18 files changed, 717 insertions(+), 15 deletions(-) create mode 100644 framework/update/internal/downloadfiledevice.cpp create mode 100644 framework/update/internal/downloadfiledevice.h create mode 100644 framework/update/qml/Muse/Update/UpdateBanner.qml create mode 100644 framework/update/qml/Muse/Update/updatebannermodel.cpp create mode 100644 framework/update/qml/Muse/Update/updatebannermodel.h diff --git a/framework/stubs/update/appupdatescenariostub.cpp b/framework/stubs/update/appupdatescenariostub.cpp index b917adf530..8f5e73973c 100644 --- a/framework/stubs/update/appupdatescenariostub.cpp +++ b/framework/stubs/update/appupdatescenariostub.cpp @@ -53,3 +53,31 @@ muse::async::Promise AppUpdateScenarioStub::showUpdate() return reject(int(muse::Ret::Code::UnknownError), "stub"); }); } + +bool AppUpdateScenarioStub::canAutoInstall() const +{ + return false; +} + +void AppUpdateScenarioStub::downloadUpdateInBackground() +{ +} + +bool AppUpdateScenarioStub::hasReadyUpdate() const +{ + return false; +} + +muse::async::Notification AppUpdateScenarioStub::hasReadyUpdateChanged() const +{ + return {}; +} + +std::string AppUpdateScenarioStub::readyUpdateVersion() const +{ + return {}; +} + +void AppUpdateScenarioStub::installReadyUpdate() +{ +} diff --git a/framework/stubs/update/appupdatescenariostub.h b/framework/stubs/update/appupdatescenariostub.h index 250003f66f..e9dd2b1128 100644 --- a/framework/stubs/update/appupdatescenariostub.h +++ b/framework/stubs/update/appupdatescenariostub.h @@ -36,5 +36,14 @@ class AppUpdateScenarioStub : public IAppUpdateScenario bool hasUpdate() const override; muse::async::Promise showUpdate() override; + + bool canAutoInstall() const override; + void downloadUpdateInBackground() override; + + bool hasReadyUpdate() const override; + async::Notification hasReadyUpdateChanged() const override; + std::string readyUpdateVersion() const override; + + void installReadyUpdate() override; }; } diff --git a/framework/stubs/update/appupdateservicestub.cpp b/framework/stubs/update/appupdateservicestub.cpp index c92008a04f..967e186348 100644 --- a/framework/stubs/update/appupdateservicestub.cpp +++ b/framework/stubs/update/appupdateservicestub.cpp @@ -42,3 +42,23 @@ RetVal AppUpdateServiceStub::downloadRelease() { return RetVal::make_ret(Ret::Code::NotSupported); } + +bool AppUpdateServiceStub::canAutoInstall() const +{ + return false; +} + +Ret AppUpdateServiceStub::applyUpdate(const muse::io::path_t&) +{ + return make_ret(Ret::Code::NotSupported); +} + +bool AppUpdateServiceStub::isReleaseDownloaded() const +{ + return false; +} + +muse::io::path_t AppUpdateServiceStub::downloadedReleasePath() const +{ + return {}; +} diff --git a/framework/stubs/update/appupdateservicestub.h b/framework/stubs/update/appupdateservicestub.h index 9dda5018e2..8fab50a3dc 100644 --- a/framework/stubs/update/appupdateservicestub.h +++ b/framework/stubs/update/appupdateservicestub.h @@ -31,5 +31,11 @@ class AppUpdateServiceStub : public IAppUpdateService async::Promise > checkForUpdate() override; const RetVal& lastCheckResult() const override; RetVal downloadRelease() override; + + bool canAutoInstall() const override; + Ret applyUpdate(const muse::io::path_t& packagePath) override; + + bool isReleaseDownloaded() const override; + muse::io::path_t downloadedReleasePath() const override; }; } diff --git a/framework/update/CMakeLists.txt b/framework/update/CMakeLists.txt index 1390e0e176..8ed35aa2fc 100644 --- a/framework/update/CMakeLists.txt +++ b/framework/update/CMakeLists.txt @@ -46,6 +46,8 @@ target_sources(muse_update PRIVATE internal/appupdatescenario.h internal/appupdateservice.cpp internal/appupdateservice.h + internal/downloadfiledevice.cpp + internal/downloadfiledevice.h ) if (OS_IS_MAC) diff --git a/framework/update/iappupdatescenario.h b/framework/update/iappupdatescenario.h index a0aca9513f..0ea40da9aa 100644 --- a/framework/update/iappupdatescenario.h +++ b/framework/update/iappupdatescenario.h @@ -44,5 +44,20 @@ class IAppUpdateScenario : MODULE_CONTEXT_INTERFACE virtual bool hasUpdate() const = 0; virtual async::Promise showUpdate() = 0; + + //! Whether an available update can be installed in-place automatically. + virtual bool canAutoInstall() const = 0; + + //! Silently download the available update; on completion a "ready" update + //! becomes available (see hasReadyUpdate()). + virtual void downloadUpdateInBackground() = 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; }; } diff --git a/framework/update/iappupdateservice.h b/framework/update/iappupdateservice.h index 4c56fdbf14..4a12c6f90b 100644 --- a/framework/update/iappupdateservice.h +++ b/framework/update/iappupdateservice.h @@ -42,13 +42,11 @@ class IAppUpdateService : MODULE_CONTEXT_INTERFACE virtual const RetVal& lastCheckResult() const = 0; virtual RetVal downloadRelease() = 0; - //! Whether the downloaded release can be installed in-place automatically - //! (replace the install location and restart) instead of handing the - //! installer to the user. + virtual bool isReleaseDownloaded() const = 0; + virtual muse::io::path_t downloadedReleasePath() const = 0; + virtual bool canAutoInstall() const = 0; - //! Apply a previously downloaded release in-place. Returns OK if the update - //! was successfully staged; the caller must then quit the application. virtual Ret applyUpdate(const muse::io::path_t& packagePath) = 0; }; } diff --git a/framework/update/internal/appupdatescenario.cpp b/framework/update/internal/appupdatescenario.cpp index ed1ee3ef57..d6171826be 100644 --- a/framework/update/internal/appupdatescenario.cpp +++ b/framework/update/internal/appupdatescenario.cpp @@ -267,3 +267,77 @@ bool AppUpdateScenario::shouldIgnoreUpdate(const ReleaseInfo& info) const { return info.version == configuration()->skippedReleaseVersion() && !configuration()->checkForUpdateTestMode(); } + +bool AppUpdateScenario::canAutoInstall() const +{ + return service()->canAutoInstall(); +} + +void AppUpdateScenario::downloadUpdateInBackground() +{ + if (m_bgDownloadInProgress || hasReadyUpdate()) { + return; + } + + if (!hasUpdate() || !service()->canAutoInstall()) { + 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_hasReadyUpdateChanged.notify(); + return; + } + + RetVal progress = service()->downloadRelease(); + if (!progress.ret) { + LOGE() << progress.ret.toString(); + return; + } + + m_bgDownloadInProgress = true; + + progress.val.progressChanged().onReceive(this, [this](int64_t current, int64_t total, const std::string& msg) { + LOGE() << "progress: " << current << " / " << total << " " << msg; + }); + + progress.val.finished().onReceive(this, [this](const ProgressResult& res) { + m_bgDownloadInProgress = false; + + if (!res.ret) { + LOGE() << res.ret.toString(); + return; + } + + m_readyPackagePath = res.val.toString(); + m_readyUpdateVersion = service()->lastCheckResult().val.version; + m_hasReadyUpdateChanged.notify(); + }); +} + +bool AppUpdateScenario::hasReadyUpdate() const +{ + return !m_readyPackagePath.empty(); +} + +async::Notification AppUpdateScenario::hasReadyUpdateChanged() const +{ + return m_hasReadyUpdateChanged; +} + +std::string AppUpdateScenario::readyUpdateVersion() const +{ + return m_readyUpdateVersion; +} + +void AppUpdateScenario::installReadyUpdate() +{ + if (m_readyPackagePath.empty()) { + return; + } + + askToRestartAndInstall(m_readyPackagePath).onResolve(this, [](const Ret&) {}); +} diff --git a/framework/update/internal/appupdatescenario.h b/framework/update/internal/appupdatescenario.h index c45bbf337f..b3170b7c16 100644 --- a/framework/update/internal/appupdatescenario.h +++ b/framework/update/internal/appupdatescenario.h @@ -56,6 +56,15 @@ class AppUpdateScenario : public IAppUpdateScenario, public Contextable, public bool hasUpdate() const override; muse::async::Promise showUpdate() override; // NOTE: Resolves to "OK" if the user wants to close and complete install of update... + bool canAutoInstall() const override; + void downloadUpdateInBackground() override; + + bool hasReadyUpdate() const override; + async::Notification hasReadyUpdateChanged() const override; + std::string readyUpdateVersion() const override; + + void installReadyUpdate() override; + private: muse::async::Promise processUpdateError(int errorCode); @@ -71,5 +80,10 @@ class AppUpdateScenario : public IAppUpdateScenario, public Contextable, public bool m_checkInProgress = false; async::Notification m_checkInProgressChanged; + + bool m_bgDownloadInProgress = false; + io::path_t m_readyPackagePath; + std::string m_readyUpdateVersion; + async::Notification m_hasReadyUpdateChanged; }; } diff --git a/framework/update/internal/appupdateservice.cpp b/framework/update/internal/appupdateservice.cpp index 057978a71c..b96dee25bf 100644 --- a/framework/update/internal/appupdateservice.cpp +++ b/framework/update/internal/appupdateservice.cpp @@ -32,6 +32,8 @@ #include "update/updateerrors.h" +#include "downloadfiledevice.h" + #include "defer.h" #include "translation.h" #include "log.h" @@ -45,6 +47,8 @@ using namespace muse::io; const QString INSTALLED_WEEK_BEGINNING_KEY("Installed-Week-Beginning"); const QString PREVIOUS_REQUEST_DAY_KEY("Previous-Request-Day"); +static const std::string PARTIAL_SUFFIX(".part"); + static QDate calculateWeekBeginForDate(const QDate& date) { // 1 (Monday) + 6 mod 7 = 0 @@ -186,12 +190,14 @@ Promise > AppUpdateService::checkForUpdate() bool isPreRelease = update.preRelease(); if (!allowUpdateOnPreRelease && isPreRelease) { + cleanupStalePackages(/*keepFileName*/ std::string()); m_lastCheckResult.ret = make_ret(Err::NoUpdate); (void)resolve(m_lastCheckResult); return; } if (update <= current) { + cleanupStalePackages(/*keepFileName*/ std::string()); m_lastCheckResult.ret = make_ret(Err::NoUpdate); (void)resolve(m_lastCheckResult); return; @@ -199,6 +205,9 @@ Promise > AppUpdateService::checkForUpdate() m_lastCheckResult = releaseInfo; + //! NOTE: Keep an already-downloaded package for this release; drop stale ones. + cleanupStalePackages(releaseInfo.val.fileName); + downloadPreviousReleasesNotes(update, [this, resolve](const PrevReleasesNotesList& notes) { m_lastCheckResult.val.previousReleasesNotes = notes; (void)resolve(m_lastCheckResult); @@ -222,9 +231,29 @@ RetVal AppUpdateService::downloadRelease() const ReleaseInfo info = m_lastCheckResult.val; const QUrl fileUrl = QUrl::fromUserInput(QString::fromStdString(info.fileUrl)); - auto buff = std::make_shared(); - RetVal downloadProgress = m_networkManager->get(fileUrl, buff); + const path_t finalPath = configuration()->updateDataPath() + "/" + info.fileName; + const path_t partialPath = finalPath + PARTIAL_SUFFIX; + fileSystem()->makePath(muse::io::absoluteDirpath(partialPath)); + + //! NOTE: Resume an interrupted download by appending to the partial file and + //! requesting the remaining bytes via a Range header. + uint64_t offset = 0; + if (fileSystem()->exists(partialPath)) { + RetVal sz = fileSystem()->fileSize(partialPath); + offset = sz.ret ? sz.val : 0; + } + + RequestHeaders headers; + io::IODevice::OpenMode mode = io::IODevice::WriteOnly; + if (offset > 0) { + headers.rawHeaders["Range"] = QByteArray("bytes=") + QByteArray::number(static_cast(offset)) + "-"; + mode = io::IODevice::Append; + } + + auto device = std::make_shared(partialPath, mode); + + RetVal downloadProgress = m_networkManager->get(fileUrl, device, headers); if (!downloadProgress.ret) { return RetVal::make_ret(downloadProgress.ret); } @@ -237,26 +266,37 @@ RetVal AppUpdateService::downloadRelease() m_updateProgress.canceled().disconnect(this); }); - downloadProgress.val.progressChanged().onReceive(this, [this](int64_t current, int64_t total, const std::string& msg) { - m_updateProgress.progress(current, total, msg); + 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); }); - downloadProgress.val.finished().onReceive(this, [this, info, buff](const ProgressResult& res) { + downloadProgress.val.finished().onReceive(this, [this, finalPath, partialPath, offset](const ProgressResult& res) { if (!res.ret) { + //! NOTE: Keep the partial file so the next attempt can resume from it. m_updateProgress.finish(ProgressResult::make_ret(res.ret)); return; } - const path_t installerPath = configuration()->updateDataPath() + "/" + info.fileName; - fileSystem()->makePath(muse::io::absoluteDirpath(installerPath)); + const int status = res.ret.data("status", 0); - const Ret ret = fileSystem()->writeFile(installerPath, ByteArray::fromQByteArrayNoCopy(buff->data())); + //! NOTE: We requested a range but the server sent the full file (200) or + //! rejected the range (416); the partial file is now stale/corrupt - drop + //! it so the next attempt starts clean. + if (offset > 0 && (status == 200 || status == 416)) { + fileSystem()->remove(partialPath); + m_updateProgress.finish(ProgressResult::make_ret(make_ret(Err::NetworkError, "range request not honoured"))); + return; + } + + //! Success (200 fresh download or 206 resumed): promote the partial file + //! to the final package name. + const Ret ret = fileSystem()->move(partialPath, finalPath, /*replace*/ true); if (!ret) { m_updateProgress.finish(ProgressResult::make_ret(ret)); return; } - m_updateProgress.finish(ProgressResult::make_ok(Val(installerPath))); + m_updateProgress.finish(ProgressResult::make_ok(Val(finalPath))); }); return RetVal::make_ok(m_updateProgress); @@ -479,8 +519,62 @@ void AppUpdateService::downloadPreviousReleasesNotes(const Version& updateVersio void AppUpdateService::clear() { m_lastCheckResult = RetVal::make_ok(ReleaseInfo()); +} +void AppUpdateService::cleanupStalePackages(const std::string& keepFileName) +{ #if !defined(Q_OS_LINUX) - fileSystem()->remove(configuration()->updateDataPath()); + const io::path_t dir = configuration()->updateDataPath(); + if (!fileSystem()->exists(dir)) { + return; + } + + //! NOTE: No relevant package to keep -> drop everything. + if (keepFileName.empty()) { + fileSystem()->remove(dir); + return; + } + + RetVal entries = fileSystem()->scanFiles(dir, {}, io::ScanMode::FilesAndFoldersInCurrentDir); + if (!entries.ret) { + return; + } + + //! NOTE: Keep both the finished package and its in-progress ".part" file so an + //! interrupted download of the current release can still be resumed. + const std::string keepPartial = keepFileName + PARTIAL_SUFFIX; + + for (const io::path_t& entry : entries.val) { + const std::string name = io::filename(entry).toStdString(); + if (name != keepFileName && name != keepPartial) { + fileSystem()->remove(entry); + } + } +#else + UNUSED(keepFileName); #endif } + +bool AppUpdateService::isReleaseDownloaded() const +{ + return !downloadedReleasePath().empty(); +} + +io::path_t AppUpdateService::downloadedReleasePath() const +{ + if (!m_lastCheckResult.ret) { + return {}; + } + + const std::string& fileName = m_lastCheckResult.val.fileName; + if (fileName.empty()) { + return {}; + } + + const io::path_t path = configuration()->updateDataPath() + "/" + fileName; + if (!fileSystem()->exists(path)) { + return {}; + } + + return path; +} diff --git a/framework/update/internal/appupdateservice.h b/framework/update/internal/appupdateservice.h index 4be8508906..6e874f9460 100644 --- a/framework/update/internal/appupdateservice.h +++ b/framework/update/internal/appupdateservice.h @@ -59,6 +59,9 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as bool canAutoInstall() const override; Ret applyUpdate(const muse::io::path_t& packagePath) override; + bool isReleaseDownloaded() const override; + muse::io::path_t downloadedReleasePath() const override; + private: friend class AppUpdateServiceTests; @@ -80,6 +83,8 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as std::vector platformFileSuffixes() const; QJsonObject resolveReleaseAsset(const QJsonObject& release) const; + void cleanupStalePackages(const std::string& keepFileName); + using PrevReleaseNotesCallback = std::function; void downloadPreviousReleasesNotes(const Version& updateVersion, const PrevReleaseNotesCallback& finished); diff --git a/framework/update/internal/downloadfiledevice.cpp b/framework/update/internal/downloadfiledevice.cpp new file mode 100644 index 0000000000..fceb5575ac --- /dev/null +++ b/framework/update/internal/downloadfiledevice.cpp @@ -0,0 +1,73 @@ +/* + * 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 "downloadfiledevice.h" + +using namespace muse; +using namespace muse::update; + +DownloadFileDevice::DownloadFileDevice(const io::path_t& path, io::IODevice::OpenMode mode, QObject* parent) + : QIODevice(parent), m_stream(path), m_mode(mode) +{ +} + +DownloadFileDevice::~DownloadFileDevice() +{ + DownloadFileDevice::close(); +} + +bool DownloadFileDevice::isSequential() const +{ + return true; +} + +bool DownloadFileDevice::open(QIODevice::OpenMode) +{ + //! NOTE: Ignore the mode requested by the network manager (WriteOnly, which + //! truncates); open the file in the mode chosen at construction so that an + //! interrupted download can be resumed via Append. + if (!m_stream.open(m_mode)) { + setErrorString(QString::fromStdString(m_stream.errorString())); + return false; + } + + QIODevice::open(QIODevice::WriteOnly); + return true; +} + +void DownloadFileDevice::close() +{ + if (m_stream.isOpen()) { + m_stream.close(); + } + QIODevice::close(); +} + +qint64 DownloadFileDevice::readData(char*, qint64) +{ + return -1; +} + +qint64 DownloadFileDevice::writeData(const char* data, qint64 len) +{ + const size_t written = m_stream.write(reinterpret_cast(data), static_cast(len)); + return static_cast(written); +} diff --git a/framework/update/internal/downloadfiledevice.h b/framework/update/internal/downloadfiledevice.h new file mode 100644 index 0000000000..85f54d9b15 --- /dev/null +++ b/framework/update/internal/downloadfiledevice.h @@ -0,0 +1,52 @@ +/* + * 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 "io/filestream.h" +#include "io/path.h" + +namespace muse::update { +//! A write-only QIODevice that streams incoming network data straight to disk +//! via muse::io::FileStream. It opens the underlying file in the mode chosen at +//! construction (Append to resume an interrupted download), ignoring the open +//! mode requested by the network manager (which would otherwise truncate). +class DownloadFileDevice : public QIODevice +{ +public: + DownloadFileDevice(const io::path_t& path, io::IODevice::OpenMode mode, QObject* parent = nullptr); + ~DownloadFileDevice() override; + + bool isSequential() const override; + bool open(QIODevice::OpenMode mode) override; + void close() override; + +protected: + qint64 readData(char* data, qint64 maxlen) override; + qint64 writeData(const char* data, qint64 len) override; + +private: + io::FileStream m_stream; + io::IODevice::OpenMode m_mode = io::IODevice::WriteOnly; +}; +} diff --git a/framework/update/qml/Muse/Update/CMakeLists.txt b/framework/update/qml/Muse/Update/CMakeLists.txt index 6537436475..f8cb4b141c 100644 --- a/framework/update/qml/Muse/Update/CMakeLists.txt +++ b/framework/update/qml/Muse/Update/CMakeLists.txt @@ -26,9 +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/ReleaseNotesView.qml IMPORTS diff --git a/framework/update/qml/Muse/Update/UpdateBanner.qml b/framework/update/qml/Muse/Update/UpdateBanner.qml new file mode 100644 index 0000000000..00b7a2830a --- /dev/null +++ b/framework/update/qml/Muse/Update/UpdateBanner.qml @@ -0,0 +1,85 @@ +/* + * 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 +import Muse.Update + +Rectangle { + id: root + + readonly property bool hasReadyUpdate: updateBannerModel.updateReady + readonly property string updateVersion: updateBannerModel.updateVersion + + implicitHeight: content.implicitHeight + 24 + + visible: hasReadyUpdate + + radius: 4 + color: ui.theme.backgroundPrimaryColor + border.width: 1 + border.color: ui.theme.accentColor + + UpdateBannerModel { + id: updateBannerModel + } + + Component.onCompleted: { + updateBannerModel.load() + } + + ColumnLayout { + id: content + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 12 + + spacing: 8 + + StyledTextLabel { + Layout.fillWidth: true + + horizontalAlignment: Text.AlignLeft + wrapMode: Text.WordWrap + + text: root.updateVersion.length > 0 + ? qsTrc("update", "Update to version %1 is ready").arg(root.updateVersion) + : qsTrc("update", "An update is ready") + } + + FlatButton { + Layout.fillWidth: true + + text: qsTrc("update", "Restart and update") + accentButton: true + + onClicked: { + updateBannerModel.install() + } + } + } +} diff --git a/framework/update/qml/Muse/Update/updatebannermodel.cpp b/framework/update/qml/Muse/Update/updatebannermodel.cpp new file mode 100644 index 0000000000..394f27d809 --- /dev/null +++ b/framework/update/qml/Muse/Update/updatebannermodel.cpp @@ -0,0 +1,51 @@ +/* + * 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(); + }); +} + +bool UpdateBannerModel::updateReady() const +{ + return scenario()->hasReadyUpdate(); +} + +QString UpdateBannerModel::updateVersion() const +{ + return QString::fromStdString(scenario()->readyUpdateVersion()); +} + +void UpdateBannerModel::install() +{ + scenario()->installReadyUpdate(); +} diff --git a/framework/update/qml/Muse/Update/updatebannermodel.h b/framework/update/qml/Muse/Update/updatebannermodel.h new file mode 100644 index 0000000000..db674eca2e --- /dev/null +++ b/framework/update/qml/Muse/Update/updatebannermodel.h @@ -0,0 +1,57 @@ +/* + * 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 "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) + + QML_ELEMENT + + ContextInject scenario = { this }; + +public: + explicit UpdateBannerModel(QObject* parent = nullptr); + + Q_INVOKABLE void load(); + Q_INVOKABLE void install(); + + bool updateReady() const; + QString updateVersion() const; + +signals: + void updateReadyChanged(); +}; +} diff --git a/framework/update/tests/appupdateservice_tests.cpp b/framework/update/tests/appupdateservice_tests.cpp index 1d9a6567d8..73a5e8850f 100644 --- a/framework/update/tests/appupdateservice_tests.cpp +++ b/framework/update/tests/appupdateservice_tests.cpp @@ -33,6 +33,7 @@ using ::testing::Return; #include "network/tests/mocks/networkmanagercreatormock.h" #include "network/tests/mocks/networkmanagermock.h" #include "global/tests/mocks/systeminfomock.h" +#include "global/tests/mocks/filesystemmock.h" #include "update/internal/appupdateservice.h" @@ -72,6 +73,9 @@ class AppUpdateServiceTests : public ::testing::Test, public ::async::Asyncable ON_CALL(*m_systemInfoMock, productType()) .WillByDefault(Return(ISystemInfo::ProductType::Linux)); + + m_fileSystem = std::make_shared >(); + m_service->fileSystem.set(m_fileSystem); } void TearDown() override @@ -136,13 +140,32 @@ class AppUpdateServiceTests : public ::testing::Test, public ::async::Asyncable })); } + //! [GIVEN] An available release is ready to be downloaded. + void givenAvailableRelease(const std::string& fileName = "MuseScore.dmg", + const std::string& dataPath = "/tmp/upd") + { + ReleaseInfo info; + info.version = "1000.0"; + info.fileName = fileName; + info.fileUrl = "http://test/" + fileName; + m_service->m_lastCheckResult = RetVal::make_ok(info); + + ON_CALL(*m_configuration, updateDataPath()) + .WillByDefault(Return(io::path_t(dataPath))); + + ON_CALL(*m_fileSystem, makePath(_)) + .WillByDefault(Return(muse::make_ok())); + } + AppUpdateService* m_service = nullptr; std::shared_ptr m_configuration; std::shared_ptr m_networkManagerCreator; std::shared_ptr m_networkManager; std::shared_ptr m_systemInfoMock; + std::shared_ptr m_fileSystem; Progress m_getReleaseInfoProgress; Progress m_getPrevReleasesInfoProgress; + Progress m_downloadProgress; }; } @@ -375,3 +398,96 @@ TEST_F(AppUpdateServiceTests, CheckForUpdate_ReleasesNotes) EXPECT_TRUE(retVal.ret); EXPECT_EQ(retVal.val.previousReleasesNotes, expectedReleasesNotes); } + +TEST_F(AppUpdateServiceTests, DownloadRelease_FreshDownload_NoRangeHeader) +{ + //! [GIVEN] An available release and no partial download on disk + givenAvailableRelease(); + ON_CALL(*m_fileSystem, exists(_)) + .WillByDefault(Return(Ret(false))); + + //! [WHEN] Download the release + RequestHeaders capturedHeaders; + EXPECT_CALL(*m_networkManager, get(_, _, _)) + .WillOnce(testing::Invoke([this, &capturedHeaders](const QUrl&, IncomingDevicePtr, const RequestHeaders& headers) { + capturedHeaders = headers; + return RetVal::make_ok(m_downloadProgress); + })); + + m_service->downloadRelease(); + + //! [THEN] No Range header is sent (download starts from scratch) + EXPECT_FALSE(capturedHeaders.rawHeaders.contains("Range")); +} + +TEST_F(AppUpdateServiceTests, DownloadRelease_ResumesFromPartial_SendsRangeHeader) +{ + //! [GIVEN] An available release with a 1000-byte partial download on disk + givenAvailableRelease(); + ON_CALL(*m_fileSystem, exists(_)) + .WillByDefault(Return(Ret(true))); + ON_CALL(*m_fileSystem, fileSize(_)) + .WillByDefault(Return(RetVal::make_ok(static_cast(1000)))); + + //! [WHEN] Download the release + RequestHeaders capturedHeaders; + EXPECT_CALL(*m_networkManager, get(_, _, _)) + .WillOnce(testing::Invoke([this, &capturedHeaders](const QUrl&, IncomingDevicePtr, const RequestHeaders& headers) { + capturedHeaders = headers; + return RetVal::make_ok(m_downloadProgress); + })); + + m_service->downloadRelease(); + + //! [THEN] A Range header requests the remaining bytes + EXPECT_EQ(capturedHeaders.rawHeaders.value("Range"), QByteArray("bytes=1000-")); +} + +TEST_F(AppUpdateServiceTests, DownloadRelease_Success_PromotesPartialToFinal) +{ + //! [GIVEN] A fresh download of an available release + givenAvailableRelease(); + ON_CALL(*m_fileSystem, exists(_)) + .WillByDefault(Return(Ret(false))); + EXPECT_CALL(*m_networkManager, get(_, _, _)) + .WillOnce(testing::Invoke([this](const QUrl&, IncomingDevicePtr, const RequestHeaders&) { + return RetVal::make_ok(m_downloadProgress); + })); + + //! [THEN] On success the partial file is promoted to the final package name + EXPECT_CALL(*m_fileSystem, move(io::path_t("/tmp/upd/MuseScore.dmg.part"), + io::path_t("/tmp/upd/MuseScore.dmg"), true)) + .WillOnce(Return(muse::make_ok())); + + m_service->downloadRelease(); + + //! [WHEN] The download finishes with HTTP 200 (full content received) + ProgressResult res = ProgressResult::make_ok(Val()); + res.ret.setData("status", 200); + m_downloadProgress.finish(res); +} + +TEST_F(AppUpdateServiceTests, DownloadRelease_RangeNotHonoured_DiscardsPartial) +{ + //! [GIVEN] A resume attempt (partial on disk -> Range requested) + givenAvailableRelease(); + ON_CALL(*m_fileSystem, exists(_)) + .WillByDefault(Return(Ret(true))); + ON_CALL(*m_fileSystem, fileSize(_)) + .WillByDefault(Return(RetVal::make_ok(static_cast(1000)))); + EXPECT_CALL(*m_networkManager, get(_, _, _)) + .WillOnce(testing::Invoke([this](const QUrl&, IncomingDevicePtr, const RequestHeaders&) { + return RetVal::make_ok(m_downloadProgress); + })); + + //! [THEN] The now-stale partial file is removed so the next attempt starts clean + EXPECT_CALL(*m_fileSystem, remove(io::path_t("/tmp/upd/MuseScore.dmg.part"), false)) + .WillOnce(Return(muse::make_ok())); + + m_service->downloadRelease(); + + //! [WHEN] The server ignored the Range request and replied with HTTP 200 + ProgressResult res = ProgressResult::make_ok(Val()); + res.ret.setData("status", 200); + m_downloadProgress.finish(res); +} From 54ae566daafcafca2fecee935284118c8da2a583 Mon Sep 17 00:00:00 2001 From: Eism Date: Thu, 6 Aug 2026 12:34:05 +0300 Subject: [PATCH 03/13] added updater for windows --- framework/update/CMakeLists.txt | 8 + framework/update/helper/CMakeLists.txt | 45 +- framework/update/helper/main.cpp | 188 +-- framework/update/helper/platform.h | 4 +- framework/update/helper/platform_win.cpp | 24 - framework/update/helper/swap.cpp | 180 +++ framework/update/helper/swap.h | 35 + framework/update/helper/updatetask_win.cpp | 1012 +++++++++++++++++ framework/update/helper/updatetask_win.h | 41 + .../platform/win/winupdateinstaller.cpp | 262 +++++ .../platform/win/winupdateinstaller.h | 57 + .../internal/platform/win/winupdateshared.h | 323 ++++++ framework/update/updatemodule.cpp | 8 +- 13 files changed, 1994 insertions(+), 193 deletions(-) create mode 100644 framework/update/helper/swap.cpp create mode 100644 framework/update/helper/swap.h create mode 100644 framework/update/helper/updatetask_win.cpp create mode 100644 framework/update/helper/updatetask_win.h create mode 100644 framework/update/internal/platform/win/winupdateinstaller.cpp create mode 100644 framework/update/internal/platform/win/winupdateinstaller.h create mode 100644 framework/update/internal/platform/win/winupdateshared.h diff --git a/framework/update/CMakeLists.txt b/framework/update/CMakeLists.txt index 8ed35aa2fc..45a6165ed5 100644 --- a/framework/update/CMakeLists.txt +++ b/framework/update/CMakeLists.txt @@ -55,6 +55,14 @@ if (OS_IS_MAC) internal/platform/mac/macupdateinstaller.cpp internal/platform/mac/macupdateinstaller.h ) +elseif (OS_IS_WIN) + target_sources(muse_update PRIVATE + internal/platform/win/winupdateinstaller.cpp + internal/platform/win/winupdateinstaller.h + internal/platform/win/winupdateshared.h + ) + + target_link_libraries(muse_update PRIVATE taskschd ole32 oleaut32) else() target_sources(muse_update PRIVATE internal/platform/stub/updateinstallerstub.cpp diff --git a/framework/update/helper/CMakeLists.txt b/framework/update/helper/CMakeLists.txt index cef00dc6de..f50c01e38a 100644 --- a/framework/update/helper/CMakeLists.txt +++ b/framework/update/helper/CMakeLists.txt @@ -25,12 +25,24 @@ set(UPDATE_HELPER_SRC platform.h ) -if (OS_IS_MAC) - list(APPEND UPDATE_HELPER_SRC platform_mac.cpp) -elseif (OS_IS_WIN) - list(APPEND UPDATE_HELPER_SRC platform_win.cpp) +if (OS_IS_WIN) + list(APPEND UPDATE_HELPER_SRC + platform_win.cpp + updatetask_win.cpp + updatetask_win.h + ../internal/platform/win/winupdateshared.h + ) else() - list(APPEND UPDATE_HELPER_SRC platform_unix.cpp) + list(APPEND UPDATE_HELPER_SRC + swap.cpp + swap.h + ) + + if (OS_IS_MAC) + list(APPEND UPDATE_HELPER_SRC platform_mac.cpp) + else() + list(APPEND UPDATE_HELPER_SRC platform_unix.cpp) + endif() endif() add_executable(${UPDATE_HELPER_TARGET} ${UPDATE_HELPER_SRC}) @@ -40,9 +52,30 @@ set_target_properties(${UPDATE_HELPER_TARGET} PROPERTIES CXX_STANDARD_REQUIRED ON ) +if (OS_IS_WIN) + target_link_libraries(${UPDATE_HELPER_TARGET} PRIVATE + taskschd # CLSID_TaskScheduler, IID_ITaskService, IID_IExecAction + ole32 # CoInitializeEx, CoCreateInstance + oleaut32 # SysAllocString, VariantInit + advapi32 # registry, security descriptors, DuplicateTokenEx, CreateProcessAsUserW + shell32 # CommandLineToArgvW + wintrust # WinVerifyTrust and the WTHelper* signer accessors + crypt32 # CertGetNameStringW + userenv # CreateEnvironmentBlock + wtsapi32 # WTSQueryUserToken + ) +endif() + # On macOS the helper is embedded into the app bundle by the app target. if (NOT OS_IS_MAC) + set(UPDATE_HELPER_INSTALL_DIR "${CMAKE_INSTALL_BINDIR}") + if (NOT UPDATE_HELPER_INSTALL_DIR) + # GNUInstallDirs is not necessarily in use; the application itself is + # installed into "bin" and the helper has to sit next to it. + set(UPDATE_HELPER_INSTALL_DIR "bin") + endif() + install(TARGETS ${UPDATE_HELPER_TARGET} - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + RUNTIME DESTINATION ${UPDATE_HELPER_INSTALL_DIR} ) endif() diff --git a/framework/update/helper/main.cpp b/framework/update/helper/main.cpp index 50f4beb2cf..87c12e05e6 100644 --- a/framework/update/helper/main.cpp +++ b/framework/update/helper/main.cpp @@ -20,167 +20,35 @@ * along with this program. If not, see . */ -//! NOTE: Standalone, zero-runtime-dependency update helper. -//! It is copied out of the application install location and run from there so -//! that replacing the install location never touches a file it is executing. -//! It waits for the host application to exit, atomically swaps the install -//! location with the freshly unpacked update (keeping a backup for rollback), -//! relaunches the application and removes itself. - -#include "platform.h" - -#include -#include -#include -#include -#include - -namespace fs = std::filesystem; - -namespace { -struct Args { - long long waitPid = 0; - std::string src; // freshly unpacked new install (dir or bundle) - std::string dst; // current install location to be replaced - std::string relaunch; // path to launch after a successful swap - std::string logPath; -}; - -FILE* g_log = nullptr; - -void logLine(const std::string& msg) -{ - if (g_log) { - std::fprintf(g_log, "%s\n", msg.c_str()); - std::fflush(g_log); - } -} - -Args parseArgs(int argc, char** argv) -{ - std::map kv; - for (int i = 1; i + 1 < argc; i += 2) { - kv[argv[i]] = argv[i + 1]; - } - - Args a; - if (kv.count("--wait-pid")) { - a.waitPid = std::stoll(kv["--wait-pid"]); - } - a.src = kv.count("--src") ? kv["--src"] : std::string(); - a.dst = kv.count("--dst") ? kv["--dst"] : std::string(); - a.relaunch = kv.count("--relaunch") ? kv["--relaunch"] : std::string(); - a.logPath = kv.count("--log") ? kv["--log"] : std::string(); - return a; -} - -//! Move `from` to `to`, falling back to copy+remove when rename crosses a -//! filesystem boundary (rename only succeeds within a single volume). -bool movePath(const fs::path& from, const fs::path& to, std::error_code& ec) -{ - fs::rename(from, to, ec); - if (!ec) { - return true; - } - - ec.clear(); - fs::copy(from, to, fs::copy_options::recursive | fs::copy_options::copy_symlinks, ec); - if (ec) { - return false; - } - - std::error_code rmEc; - fs::remove_all(from, rmEc); - return true; -} -} +//! NOTE: Standalone, zero-runtime-dependency update helper. It runs from outside +//! the install location so that updating it never touches a file it is +//! executing. +//! +//! How the update is applied differs enough between platforms to be two separate +//! programs sharing an executable rather than one program with branches: +//! +//! - where the install location is writable by the user running the +//! application, the helper swaps it for the freshly unpacked update itself +//! (swap.h); +//! - on Windows the application is installed per-machine and no unprivileged +//! process can replace it, so the helper instead runs as SYSTEM from a +//! scheduled task and installs a signed package (updatetask_win.h). +//! +//! Only the one belonging to the platform is built. + +#ifdef _WIN32 +#include "updatetask_win.h" +#else +#include "swap.h" +#endif int main(int argc, char** argv) { - Args args = parseArgs(argc, argv); - - if (!args.logPath.empty()) { - g_log = std::fopen(args.logPath.c_str(), "w"); - } - - logLine("museupdater started"); - logLine(" src=" + args.src); - logLine(" dst=" + args.dst); - logLine(" relaunch=" + args.relaunch); - logLine(" wait-pid=" + std::to_string(args.waitPid)); - - if (args.src.empty() || args.dst.empty()) { - logLine("error: --src and --dst are required"); - return 1; - } - - // 1. Wait for the host application to fully exit before touching its files. - if (args.waitPid > 0) { - platform::waitForProcessExit(args.waitPid, /*timeoutMs*/ 60000); - } - - const fs::path src(args.src); - const fs::path dst(args.dst); - const fs::path backup = fs::path(dst).concat(".bak"); - - std::error_code ec; - - if (!fs::exists(src, ec)) { - logLine("error: src does not exist"); - return 1; - } - - // 2. Backup the current install location. - fs::remove_all(backup, ec); - ec.clear(); - if (fs::exists(dst, ec)) { - if (!movePath(dst, backup, ec)) { - logLine("error: failed to backup dst: " + ec.message()); - return 1; - } - } - - // 3. Swap in the new install. - if (!movePath(src, dst, ec)) { - logLine("error: failed to move src into place: " + ec.message()); - - // Rollback. - std::error_code rbEc; - if (fs::exists(backup, rbEc)) { - movePath(backup, dst, rbEc); - } - return 1; - } - - // 4. Verify the result; rollback on failure (platform-specific check, e.g. - // code signature validity on macOS). - if (!platform::verifyInstall(dst.string())) { - logLine("error: post-swap verification failed, rolling back"); - - std::error_code rbEc; - fs::remove_all(dst, rbEc); - if (fs::exists(backup, rbEc)) { - movePath(backup, dst, rbEc); - } - return 1; - } - - // 5. Success: drop the backup. - fs::remove_all(backup, ec); - - // 6. Relaunch the updated application. - const std::string relaunchTarget = args.relaunch.empty() ? dst.string() : args.relaunch; - if (!platform::relaunch(relaunchTarget)) { - logLine("error: failed to relaunch " + relaunchTarget); - // The update itself succeeded; the user can start the app manually. - } - - logLine("museupdater finished"); - - if (g_log) { - std::fclose(g_log); - g_log = nullptr; - } - - return 0; +#ifdef _WIN32 + (void)argc; + (void)argv; + return updatetask::runCommandLine(); +#else + return swapper::run(argc, argv); +#endif } diff --git a/framework/update/helper/platform.h b/framework/update/helper/platform.h index aef8f154b4..c2eca5d00d 100644 --- a/framework/update/helper/platform.h +++ b/framework/update/helper/platform.h @@ -28,11 +28,13 @@ namespace platform { //! Block until the process `pid` has exited, or `timeoutMs` elapses. void waitForProcessExit(long long pid, int timeoutMs); +#ifndef _WIN32 //! Verify that the swapped-in install at `path` is launchable. //! On macOS this checks the code signature; elsewhere it just checks existence. bool verifyInstall(const std::string& path); //! Launch the updated application. `path` is an .app bundle on macOS, -//! an executable on Windows/Linux. +//! an executable on Linux. bool relaunch(const std::string& path); +#endif } diff --git a/framework/update/helper/platform_win.cpp b/framework/update/helper/platform_win.cpp index f238fbc54c..8e0494defb 100644 --- a/framework/update/helper/platform_win.cpp +++ b/framework/update/helper/platform_win.cpp @@ -35,28 +35,4 @@ void waitForProcessExit(long long pid, int timeoutMs) WaitForSingleObject(hProc, static_cast(timeoutMs)); CloseHandle(hProc); } - -bool verifyInstall(const std::string& path) -{ - DWORD attrs = GetFileAttributesA(path.c_str()); - return attrs != INVALID_FILE_ATTRIBUTES; -} - -bool relaunch(const std::string& path) -{ - STARTUPINFOA si; - PROCESS_INFORMATION pi; - ZeroMemory(&si, sizeof(si)); - si.cb = sizeof(si); - ZeroMemory(&pi, sizeof(pi)); - - std::string cmd = "\"" + path + "\""; - BOOL ok = CreateProcessA(nullptr, cmd.data(), nullptr, nullptr, FALSE, - 0, nullptr, nullptr, &si, &pi); - if (ok) { - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - } - return ok == TRUE; -} } diff --git a/framework/update/helper/swap.cpp b/framework/update/helper/swap.cpp new file mode 100644 index 0000000000..112d80d4f3 --- /dev/null +++ b/framework/update/helper/swap.cpp @@ -0,0 +1,180 @@ +/* + * 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 "swap.h" + +#include "platform.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { +struct Args { + long long waitPid = 0; + std::string src; // freshly unpacked new install (dir or bundle) + std::string dst; // current install location to be replaced + std::string relaunch; // path to launch after a successful swap + std::string logPath; +}; + +FILE* g_log = nullptr; + +void logLine(const std::string& msg) +{ + if (g_log) { + std::fprintf(g_log, "%s\n", msg.c_str()); + std::fflush(g_log); + } +} + +Args parseArgs(int argc, char** argv) +{ + std::map kv; + for (int i = 1; i + 1 < argc; i += 2) { + kv[argv[i]] = argv[i + 1]; + } + + Args a; + if (kv.count("--wait-pid")) { + a.waitPid = std::stoll(kv["--wait-pid"]); + } + a.src = kv.count("--src") ? kv["--src"] : std::string(); + a.dst = kv.count("--dst") ? kv["--dst"] : std::string(); + a.relaunch = kv.count("--relaunch") ? kv["--relaunch"] : std::string(); + a.logPath = kv.count("--log") ? kv["--log"] : std::string(); + return a; +} + +//! Move `from` to `to`, falling back to copy+remove when rename crosses a +//! filesystem boundary (rename only succeeds within a single volume). +bool movePath(const fs::path& from, const fs::path& to, std::error_code& ec) +{ + fs::rename(from, to, ec); + if (!ec) { + return true; + } + + ec.clear(); + fs::copy(from, to, fs::copy_options::recursive | fs::copy_options::copy_symlinks, ec); + if (ec) { + return false; + } + + std::error_code rmEc; + fs::remove_all(from, rmEc); + return true; +} +} + +int swapper::run(int argc, char** argv) +{ + Args args = parseArgs(argc, argv); + + if (!args.logPath.empty()) { + g_log = std::fopen(args.logPath.c_str(), "w"); + } + + logLine("museupdater started"); + logLine(" src=" + args.src); + logLine(" dst=" + args.dst); + logLine(" relaunch=" + args.relaunch); + logLine(" wait-pid=" + std::to_string(args.waitPid)); + + if (args.src.empty() || args.dst.empty()) { + logLine("error: --src and --dst are required"); + return 1; + } + + // 1. Wait for the host application to fully exit before touching its files. + if (args.waitPid > 0) { + platform::waitForProcessExit(args.waitPid, /*timeoutMs*/ 60000); + } + + const fs::path src(args.src); + const fs::path dst(args.dst); + const fs::path backup = fs::path(dst).concat(".bak"); + + std::error_code ec; + + if (!fs::exists(src, ec)) { + logLine("error: src does not exist"); + return 1; + } + + // 2. Backup the current install location. + fs::remove_all(backup, ec); + ec.clear(); + if (fs::exists(dst, ec)) { + if (!movePath(dst, backup, ec)) { + logLine("error: failed to backup dst: " + ec.message()); + return 1; + } + } + + // 3. Swap in the new install. + if (!movePath(src, dst, ec)) { + logLine("error: failed to move src into place: " + ec.message()); + + // Rollback. + std::error_code rbEc; + if (fs::exists(backup, rbEc)) { + movePath(backup, dst, rbEc); + } + return 1; + } + + // 4. Verify the result; rollback on failure (platform-specific check, e.g. + // code signature validity on macOS). + if (!platform::verifyInstall(dst.string())) { + logLine("error: post-swap verification failed, rolling back"); + + std::error_code rbEc; + fs::remove_all(dst, rbEc); + if (fs::exists(backup, rbEc)) { + movePath(backup, dst, rbEc); + } + return 1; + } + + // 5. Success: drop the backup. + fs::remove_all(backup, ec); + + // 6. Relaunch the updated application. + const std::string relaunchTarget = args.relaunch.empty() ? dst.string() : args.relaunch; + if (!platform::relaunch(relaunchTarget)) { + logLine("error: failed to relaunch " + relaunchTarget); + // The update itself succeeded; the user can start the app manually. + } + + logLine("museupdater finished"); + + if (g_log) { + std::fclose(g_log); + g_log = nullptr; + } + + return 0; +} diff --git a/framework/update/helper/swap.h b/framework/update/helper/swap.h new file mode 100644 index 0000000000..1c08bf782a --- /dev/null +++ b/framework/update/helper/swap.h @@ -0,0 +1,35 @@ +/* + * 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 + +namespace swapper { +//! Waits for the host application to exit, swaps the install location with the +//! freshly unpacked update (keeping a backup for rollback) and relaunches the +//! application. +//! +//! --wait-pid --src --dst [--relaunch ] [--log ] +//! +//! Used where the install location is writable by the user running the +//! application, which on Windows it is not - see command_win.h for that. +int run(int argc, char** argv); +} diff --git a/framework/update/helper/updatetask_win.cpp b/framework/update/helper/updatetask_win.cpp new file mode 100644 index 0000000000..f717e2c043 --- /dev/null +++ b/framework/update/helper/updatetask_win.cpp @@ -0,0 +1,1012 @@ +/* + * 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. + */ + +#include "updatetask_win.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "platform.h" + +#include "../internal/platform/win/winupdateshared.h" + +//! The layout shared with the application: paths, the registry key and the +//! format of the request file. +namespace shared = muse::update::win; + +namespace { +const wchar_t* TASK_AUTHOR = L"Muse"; + +//! Users (BU) get read + execute so that an unprivileged application can start +//! the task on demand; only administrators and SYSTEM may change it. +const wchar_t* TASK_SDDL = L"D:P(A;;GA;;;BA)(A;;GA;;;SY)(A;;GRGX;;;BU)"; + +//! Users may traverse and read the working area but write nothing into it: the +//! helper copies itself here before running the installer, and an unprivileged +//! user must not be able to plant anything we then execute as SYSTEM. +const wchar_t* ROOT_SDDL = L"O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGX;;;BU)"; + +//! SYSTEM and administrators only - the package is verified and installed from +//! here, so an unprivileged user must not be able to touch it. +const wchar_t* STAGING_SDDL = L"O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"; + +//! Users (BU) may read, write and delete requests. Whoever writes one does not +//! own it exclusively, so a request left behind by one user cannot keep another +//! from placing theirs. The contents are untrusted either way. +const wchar_t* REQUESTS_SDDL = L"O:BAD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GRGWGXSD;;;BU)"; + +HANDLE g_logFile = INVALID_HANDLE_VALUE; + +void logLine(const std::wstring& message) +{ + if (g_logFile == INVALID_HANDLE_VALUE) { + return; + } + + SYSTEMTIME time = { }; + ::GetLocalTime(&time); + + auto padded = [](int value, size_t width) { + std::wstring str = std::to_wstring(value); + while (str.size() < width) { + str.insert(str.begin(), L'0'); + } + return str; + }; + + const std::wstring stamp = padded(time.wYear, 4) + L"-" + padded(time.wMonth, 2) + L"-" + padded(time.wDay, 2) + + L" " + padded(time.wHour, 2) + L":" + padded(time.wMinute, 2) + L":" + padded(time.wSecond, 2) + + L" "; + + const std::string line = shared::wideToUtf8(stamp + message + L"\r\n"); + + DWORD written = 0; + ::WriteFile(g_logFile, line.data(), static_cast(line.size()), &written, nullptr); + ::FlushFileBuffers(g_logFile); +} + +void openLog(const std::wstring& appId) +{ + const std::wstring path = shared::logFilePath(appId); + g_logFile = ::CreateFileW(path.c_str(), FILE_APPEND_DATA, FILE_SHARE_READ, nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); +} + +void closeLog() +{ + if (g_logFile != INVALID_HANDLE_VALUE) { + ::CloseHandle(g_logFile); + g_logFile = INVALID_HANDLE_VALUE; + } +} + +std::wstring quoted(const std::wstring& str) +{ + return L"\"" + str + L"\""; +} + +std::wstring trimTrailingSeparators(const std::wstring& path) +{ + std::wstring result = path; + while (result.size() > 3 && (result.back() == L'\\' || result.back() == L'/')) { + result.pop_back(); + } + return result; +} + +std::wstring parentDir(const std::wstring& path) +{ + const std::wstring trimmed = trimTrailingSeparators(path); + const size_t pos = trimmed.find_last_of(L"\\/"); + if (pos == std::wstring::npos) { + return std::wstring(); + } + return trimmed.substr(0, pos); +} + +std::wstring modulePath() +{ + std::vector buffer(MAX_PATH); + for (;;) { + const DWORD size = ::GetModuleFileNameW(nullptr, buffer.data(), static_cast(buffer.size())); + if (size == 0) { + return std::wstring(); + } + + if (size < buffer.size() - 1) { + return std::wstring(buffer.data(), size); + } + + buffer.resize(buffer.size() * 2); + } +} + +std::wstring systemDirPath() +{ + wchar_t buffer[MAX_PATH] = { 0 }; + const UINT size = ::GetSystemDirectoryW(buffer, MAX_PATH); + if (size == 0 || size >= MAX_PATH) { + return L"C:\\Windows\\System32"; + } + return std::wstring(buffer, size); +} + +bool makeDirectories(const std::wstring& path) +{ + if (path.empty()) { + return false; + } + + size_t pos = path.find_first_of(L"\\/", path.find(L":\\") != std::wstring::npos ? 3 : 0); + while (pos != std::wstring::npos) { + const std::wstring part = path.substr(0, pos); + if (!part.empty()) { + ::CreateDirectoryW(part.c_str(), nullptr); + } + pos = path.find_first_of(L"\\/", pos + 1); + } + + ::CreateDirectoryW(path.c_str(), nullptr); + + const DWORD attributes = ::GetFileAttributesW(path.c_str()); + return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY); +} + +//! Takes ownership of `path` and replaces its inherited DACL with `sddl`. +//! +//! Ownership matters as much as the DACL here: any user can create +//! `%ProgramData%\Muse\...` ahead of the installer, and the owner of a directory +//! can always rewrite its DACL - resetting the permissions of a directory +//! somebody else owns would achieve nothing. +bool secureDirectory(const std::wstring& path, const wchar_t* sddl) +{ + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!::ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, SDDL_REVISION_1, &descriptor, nullptr)) { + return false; + } + + BOOL daclPresent = FALSE; + BOOL daclDefaulted = FALSE; + PACL dacl = nullptr; + + PSID owner = nullptr; + BOOL ownerDefaulted = FALSE; + + bool ok = false; + + if (::GetSecurityDescriptorDacl(descriptor, &daclPresent, &dacl, &daclDefaulted) && daclPresent + && ::GetSecurityDescriptorOwner(descriptor, &owner, &ownerDefaulted) && owner) { + const DWORD result = ::SetNamedSecurityInfoW(const_cast(path.c_str()), SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION, + owner, nullptr, dacl, nullptr); + ok = result == ERROR_SUCCESS; + } + + ::LocalFree(descriptor); + return ok; +} + +//! Creates the working area and makes sure it belongs to us, whether or not it +//! already existed. All callers run as SYSTEM or elevated from the installer. +bool ensureSecureRoot(const std::wstring& appId) +{ + const std::wstring root = shared::updateRootPath(appId); + return makeDirectories(root) && secureDirectory(root, ROOT_SDDL); +} + +//! Copying can transiently fail while an antivirus or the shell holds the file. +bool copyFileWithRetries(const std::wstring& from, const std::wstring& to, int attempts = 30) +{ + for (int i = 0; i < attempts; ++i) { + if (::CopyFileW(from.c_str(), to.c_str(), FALSE)) { + return true; + } + ::Sleep(200); + } + + return false; +} + +bool regWriteString(const std::wstring& subKey, const wchar_t* name, const std::wstring& value) +{ + HKEY key = nullptr; + LSTATUS status = ::RegCreateKeyExW(HKEY_LOCAL_MACHINE, subKey.c_str(), 0, nullptr, REG_OPTION_NON_VOLATILE, + KEY_SET_VALUE | KEY_WOW64_64KEY, nullptr, &key, nullptr); + if (status != ERROR_SUCCESS) { + return false; + } + + const DWORD size = static_cast((value.size() + 1) * sizeof(wchar_t)); + status = ::RegSetValueExW(key, name, 0, REG_SZ, reinterpret_cast(value.c_str()), size); + ::RegCloseKey(key); + + return status == ERROR_SUCCESS; +} + +std::wstring regReadString(const std::wstring& subKey, const wchar_t* name) +{ + HKEY key = nullptr; + if (::RegOpenKeyExW(HKEY_LOCAL_MACHINE, subKey.c_str(), 0, KEY_QUERY_VALUE | KEY_WOW64_64KEY, &key) != ERROR_SUCCESS) { + return std::wstring(); + } + + wchar_t buffer[1024] = { 0 }; + DWORD size = sizeof(buffer); + DWORD type = 0; + const LSTATUS status = ::RegQueryValueExW(key, name, nullptr, &type, reinterpret_cast(buffer), &size); + ::RegCloseKey(key); + + if (status != ERROR_SUCCESS || type != REG_SZ) { + return std::wstring(); + } + + return std::wstring(buffer); +} + +//! Verifies the Authenticode signature of the package and, in the same pass, +//! reports the display name of the signing certificate. +//! +//! Signer and validity have to be established together: `WinVerifyTrust` is the +//! only thing that understands every subject type (an MSI keeps its signature in +//! a stream rather than embedded the way a PE does, so parsing the file for a +//! PKCS#7 blob would find nothing). +//! +//! Revocation is not checked: the helper runs unattended and may well have no +//! network by then. +bool verifySignature(const std::wstring& path, std::wstring& signer) +{ + signer.clear(); + + WINTRUST_FILE_INFO fileInfo = { }; + fileInfo.cbStruct = sizeof(WINTRUST_FILE_INFO); + fileInfo.pcwszFilePath = path.c_str(); + + WINTRUST_DATA data = { }; + data.cbStruct = sizeof(WINTRUST_DATA); + data.dwUIChoice = WTD_UI_NONE; + data.fdwRevocationChecks = WTD_REVOKE_NONE; + data.dwUnionChoice = WTD_CHOICE_FILE; + data.pFile = &fileInfo; + data.dwStateAction = WTD_STATEACTION_VERIFY; + data.dwProvFlags = WTD_SAFER_FLAG; + + GUID action = WINTRUST_ACTION_GENERIC_VERIFY_V2; + const LONG status = ::WinVerifyTrust(static_cast(INVALID_HANDLE_VALUE), &action, &data); + + if (status == ERROR_SUCCESS) { + CRYPT_PROVIDER_DATA* providerData = ::WTHelperProvDataFromStateData(data.hWVTStateData); + CRYPT_PROVIDER_SGNR* providerSigner = providerData + ? ::WTHelperGetProvSignerFromChain(providerData, 0, FALSE, 0) + : nullptr; + CRYPT_PROVIDER_CERT* providerCert = providerSigner + ? ::WTHelperGetProvCertFromChain(providerSigner, 0) + : nullptr; + + if (providerCert && providerCert->pCert) { + const DWORD size = ::CertGetNameStringW(providerCert->pCert, CERT_NAME_SIMPLE_DISPLAY_TYPE, 0, + nullptr, nullptr, 0); + if (size > 1) { + std::vector name(size); + ::CertGetNameStringW(providerCert->pCert, CERT_NAME_SIMPLE_DISPLAY_TYPE, 0, nullptr, name.data(), size); + signer.assign(name.data()); + } + } + } + + data.dwStateAction = WTD_STATEACTION_CLOSE; + ::WinVerifyTrust(static_cast(INVALID_HANDLE_VALUE), &action, &data); + + return status == ERROR_SUCCESS; +} + +bool runProcessAndWait(const std::wstring& application, const std::wstring& commandLine, DWORD& exitCode) +{ + std::vector mutableCommandLine(commandLine.begin(), commandLine.end()); + mutableCommandLine.push_back(L'\0'); + + STARTUPINFOW startupInfo = { }; + startupInfo.cb = sizeof(startupInfo); + + PROCESS_INFORMATION processInfo = { }; + + if (!::CreateProcessW(application.c_str(), mutableCommandLine.data(), nullptr, nullptr, FALSE, + CREATE_NO_WINDOW, nullptr, nullptr, &startupInfo, &processInfo)) { + return false; + } + + ::WaitForSingleObject(processInfo.hProcess, INFINITE); + ::GetExitCodeProcess(processInfo.hProcess, &exitCode); + + ::CloseHandle(processInfo.hThread); + ::CloseHandle(processInfo.hProcess); + + return true; +} + +bool startProcessDetached(const std::wstring& application, const std::wstring& commandLine) +{ + std::vector mutableCommandLine(commandLine.begin(), commandLine.end()); + mutableCommandLine.push_back(L'\0'); + + STARTUPINFOW startupInfo = { }; + startupInfo.cb = sizeof(startupInfo); + + PROCESS_INFORMATION processInfo = { }; + + if (!::CreateProcessW(application.c_str(), mutableCommandLine.data(), nullptr, nullptr, FALSE, + DETACHED_PROCESS, nullptr, nullptr, &startupInfo, &processInfo)) { + return false; + } + + ::CloseHandle(processInfo.hThread); + ::CloseHandle(processInfo.hProcess); + + return true; +} + +//! We run as SYSTEM; starting the application directly would give it SYSTEM +//! privileges too. Launch it with the token of the interactive user instead. +bool relaunchInUserSession(const std::wstring& application) +{ + const DWORD sessionId = ::WTSGetActiveConsoleSessionId(); + if (sessionId == 0xFFFFFFFF) { + return false; + } + + HANDLE userToken = nullptr; + if (!::WTSQueryUserToken(sessionId, &userToken)) { + return false; + } + + HANDLE primaryToken = nullptr; + if (!::DuplicateTokenEx(userToken, MAXIMUM_ALLOWED, nullptr, SecurityImpersonation, TokenPrimary, &primaryToken)) { + ::CloseHandle(userToken); + return false; + } + + void* environment = nullptr; + const BOOL hasEnvironment = ::CreateEnvironmentBlock(&environment, primaryToken, FALSE); + + std::wstring commandLine = quoted(application); + std::vector mutableCommandLine(commandLine.begin(), commandLine.end()); + mutableCommandLine.push_back(L'\0'); + + const std::wstring workingDir = parentDir(application); + + STARTUPINFOW startupInfo = { }; + startupInfo.cb = sizeof(startupInfo); + startupInfo.lpDesktop = const_cast(L"winsta0\\default"); + + PROCESS_INFORMATION processInfo = { }; + + const BOOL ok = ::CreateProcessAsUserW(primaryToken, application.c_str(), mutableCommandLine.data(), + nullptr, nullptr, FALSE, + CREATE_UNICODE_ENVIRONMENT | NORMAL_PRIORITY_CLASS, + hasEnvironment ? environment : nullptr, + workingDir.empty() ? nullptr : workingDir.c_str(), + &startupInfo, &processInfo); + if (ok) { + ::CloseHandle(processInfo.hThread); + ::CloseHandle(processInfo.hProcess); + } + + if (hasEnvironment) { + ::DestroyEnvironmentBlock(environment); + } + ::CloseHandle(primaryToken); + ::CloseHandle(userToken); + + return ok == TRUE; +} + +// ============================================================================ +// Task Scheduler +// ============================================================================ + +class ComScope +{ +public: + ComScope() + { + const HRESULT hr = ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + m_ok = SUCCEEDED(hr) || hr == RPC_E_CHANGED_MODE; + m_needUninitialize = SUCCEEDED(hr); + } + + ~ComScope() + { + if (m_needUninitialize) { + ::CoUninitialize(); + } + } + + bool isOk() const { return m_ok; } + +private: + bool m_ok = false; + bool m_needUninitialize = false; +}; + +ITaskService* connectTaskService() +{ + ITaskService* service = nullptr; + HRESULT hr = ::CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER, IID_ITaskService, + reinterpret_cast(&service)); + if (FAILED(hr) || !service) { + return nullptr; + } + + VARIANT empty; + ::VariantInit(&empty); + + hr = service->Connect(empty, empty, empty, empty); + if (FAILED(hr)) { + service->Release(); + return nullptr; + } + + return service; +} + +ITaskFolder* openTaskFolder(ITaskService* service, bool create) +{ + ITaskFolder* rootFolder = nullptr; + BSTR rootPath = ::SysAllocString(L"\\"); + HRESULT hr = service->GetFolder(rootPath, &rootFolder); + ::SysFreeString(rootPath); + + if (FAILED(hr) || !rootFolder) { + return nullptr; + } + + BSTR folderName = ::SysAllocString(shared::taskFolderName().c_str()); + + ITaskFolder* folder = nullptr; + const std::wstring folderPath = L"\\" + shared::taskFolderName(); + BSTR folderPathStr = ::SysAllocString(folderPath.c_str()); + hr = service->GetFolder(folderPathStr, &folder); + ::SysFreeString(folderPathStr); + + if (FAILED(hr) && create) { + VARIANT empty; + ::VariantInit(&empty); + hr = rootFolder->CreateFolder(folderName, empty, &folder); + } + + ::SysFreeString(folderName); + rootFolder->Release(); + + return SUCCEEDED(hr) ? folder : nullptr; +} + +struct Registration { + std::wstring appId; + std::wstring appExe; // relative to the install directory + std::wstring installDir; + std::wstring packageType; // "msi" or "exe" + std::wstring installArgs; + std::wstring certSubject; +}; + +int registerTask(const Registration& registration) +{ + const std::wstring& appId = registration.appId; + const std::wstring installDir = trimTrailingSeparators(registration.installDir); + + if (appId.empty() || registration.appExe.empty() || installDir.empty()) { + logLine(L"register-task: missing --app-id, --app-exe or --install-dir"); + return 1; + } + + if (registration.packageType != shared::PACKAGE_TYPE_MSI && registration.packageType != shared::PACKAGE_TYPE_EXE) { + logLine(L"register-task: --package-type must be \"msi\" or \"exe\""); + return 1; + } + + // The working area is created up front so that the application can drop a + // request into it without needing to create anything itself. + if (!ensureSecureRoot(appId)) { + logLine(L"register-task: failed to prepare the working directory"); + return 1; + } + + if (!makeDirectories(shared::requestsDirPath(appId)) + || !secureDirectory(shared::requestsDirPath(appId), REQUESTS_SDDL)) { + logLine(L"register-task: failed to prepare the requests directory"); + return 1; + } + + if (!makeDirectories(shared::stagingDirPath(appId)) + || !secureDirectory(shared::stagingDirPath(appId), STAGING_SDDL)) { + logLine(L"register-task: failed to prepare the staging directory"); + return 1; + } + + //! NOTE: The absolute path is resolved here, once, rather than composed at + //! apply time from an assumed layout - the executable sits in a "bin" + //! subdirectory in some applications and at the root of the installation in + //! others. + const std::wstring appPath = installDir + L"\\" + registration.appExe; + + const std::wstring key = shared::registryKeyPath(appId); + + if (!regWriteString(key, shared::REG_VALUE_INSTALL_DIR, installDir) + || !regWriteString(key, shared::REG_VALUE_APP_PATH, appPath) + || !regWriteString(key, shared::REG_VALUE_PACKAGE_TYPE, registration.packageType)) { + logLine(L"register-task: failed to write HKLM values"); + return 1; + } + + regWriteString(key, shared::REG_VALUE_INSTALL_ARGS, registration.installArgs); + regWriteString(key, shared::REG_VALUE_CERT_SUBJECT, registration.certSubject); + + if (registration.certSubject.empty()) { + logLine(L"register-task: warning - no --cert-subject given, updates will be refused"); + } + + ComScope com; + if (!com.isOk()) { + logLine(L"register-task: failed to initialize COM"); + return 1; + } + + ITaskService* service = connectTaskService(); + if (!service) { + logLine(L"register-task: failed to connect to the Task Scheduler"); + return 1; + } + + ITaskDefinition* definition = nullptr; + HRESULT hr = service->NewTask(0, &definition); + if (FAILED(hr) || !definition) { + service->Release(); + logLine(L"register-task: failed to create a task definition"); + return 1; + } + + IRegistrationInfo* registrationInfo = nullptr; + if (SUCCEEDED(definition->get_RegistrationInfo(®istrationInfo)) && registrationInfo) { + BSTR author = ::SysAllocString(TASK_AUTHOR); + registrationInfo->put_Author(author); + ::SysFreeString(author); + + const std::wstring description = L"Installs " + appId + L" updates in the background."; + BSTR descriptionStr = ::SysAllocString(description.c_str()); + registrationInfo->put_Description(descriptionStr); + ::SysFreeString(descriptionStr); + + registrationInfo->Release(); + } + + ITaskSettings* settings = nullptr; + if (SUCCEEDED(definition->get_Settings(&settings)) && settings) { + settings->put_AllowDemandStart(VARIANT_TRUE); + settings->put_StartWhenAvailable(VARIANT_FALSE); + settings->put_DisallowStartIfOnBatteries(VARIANT_FALSE); + settings->put_StopIfGoingOnBatteries(VARIANT_FALSE); + settings->put_Enabled(VARIANT_TRUE); + settings->put_Hidden(VARIANT_FALSE); + settings->put_MultipleInstances(TASK_INSTANCES_IGNORE_NEW); + + BSTR timeLimit = ::SysAllocString(L"PT30M"); + settings->put_ExecutionTimeLimit(timeLimit); + ::SysFreeString(timeLimit); + + IIdleSettings* idleSettings = nullptr; + if (SUCCEEDED(settings->get_IdleSettings(&idleSettings)) && idleSettings) { + idleSettings->put_StopOnIdleEnd(VARIANT_FALSE); + idleSettings->Release(); + } + + settings->Release(); + } + + IPrincipal* principal = nullptr; + if (SUCCEEDED(definition->get_Principal(&principal)) && principal) { + BSTR id = ::SysAllocString(L"Principal"); + principal->put_Id(id); + ::SysFreeString(id); + + BSTR userId = ::SysAllocString(L"S-1-5-18"); + principal->put_UserId(userId); + ::SysFreeString(userId); + + principal->put_LogonType(TASK_LOGON_SERVICE_ACCOUNT); + principal->put_RunLevel(TASK_RUNLEVEL_HIGHEST); + principal->Release(); + } + + //! NOTE: We are that helper, running from the installation the task is being + //! registered for - no need to guess where it was put. + const std::wstring helperPath = modulePath(); + if (helperPath.empty()) { + logLine(L"register-task: failed to determine own path"); + return 1; + } + + IActionCollection* actions = nullptr; + if (SUCCEEDED(definition->get_Actions(&actions)) && actions) { + IAction* action = nullptr; + if (SUCCEEDED(actions->Create(TASK_ACTION_EXEC, &action)) && action) { + IExecAction* execAction = nullptr; + if (SUCCEEDED(action->QueryInterface(IID_IExecAction, reinterpret_cast(&execAction))) && execAction) { + BSTR path = ::SysAllocString(helperPath.c_str()); + execAction->put_Path(path); + ::SysFreeString(path); + + const std::wstring arguments = L"--apply --app-id " + quoted(appId); + BSTR argumentsStr = ::SysAllocString(arguments.c_str()); + execAction->put_Arguments(argumentsStr); + ::SysFreeString(argumentsStr); + + const std::wstring workingDir = parentDir(helperPath); + BSTR workingDirStr = ::SysAllocString(workingDir.c_str()); + execAction->put_WorkingDirectory(workingDirStr); + ::SysFreeString(workingDirStr); + + execAction->Release(); + } + action->Release(); + } + actions->Release(); + } + + ITaskFolder* folder = openTaskFolder(service, /*create*/ true); + if (!folder) { + definition->Release(); + service->Release(); + logLine(L"register-task: failed to open or create the task folder"); + return 1; + } + + VARIANT userId; + ::VariantInit(&userId); + userId.vt = VT_BSTR; + userId.bstrVal = ::SysAllocString(L"S-1-5-18"); + + VARIANT password; + ::VariantInit(&password); + + VARIANT sddl; + ::VariantInit(&sddl); + sddl.vt = VT_BSTR; + sddl.bstrVal = ::SysAllocString(TASK_SDDL); + + BSTR taskNameStr = ::SysAllocString(shared::taskName(appId).c_str()); + + IRegisteredTask* registeredTask = nullptr; + hr = folder->RegisterTaskDefinition(taskNameStr, definition, TASK_CREATE_OR_UPDATE, + userId, password, TASK_LOGON_SERVICE_ACCOUNT, sddl, ®isteredTask); + + ::SysFreeString(taskNameStr); + ::VariantClear(&userId); + ::VariantClear(&sddl); + + if (registeredTask) { + registeredTask->Release(); + } + + folder->Release(); + definition->Release(); + service->Release(); + + if (FAILED(hr)) { + logLine(L"register-task: RegisterTaskDefinition failed"); + return 1; + } + + logLine(L"register-task: registered " + shared::taskPath(appId) + L" -> " + helperPath); + return 0; +} + +int unregisterTask(const std::wstring& appId) +{ + if (appId.empty()) { + return 1; + } + + ComScope com; + if (com.isOk()) { + ITaskService* service = connectTaskService(); + if (service) { + ITaskFolder* folder = openTaskFolder(service, /*create*/ false); + if (folder) { + BSTR taskNameStr = ::SysAllocString(shared::taskName(appId).c_str()); + folder->DeleteTask(taskNameStr, 0); + ::SysFreeString(taskNameStr); + folder->Release(); + } + service->Release(); + } + } + + ::RegDeleteKeyExW(HKEY_LOCAL_MACHINE, shared::registryKeyPath(appId).c_str(), KEY_WOW64_64KEY, 0); + + ::DeleteFileW(shared::requestFilePath(appId).c_str()); + ::DeleteFileW(shared::stagedPackagePath(appId, shared::PACKAGE_TYPE_MSI).c_str()); + ::DeleteFileW(shared::stagedPackagePath(appId, shared::PACKAGE_TYPE_EXE).c_str()); + + logLine(L"unregister-task: removed " + shared::taskPath(appId)); + return 0; +} + +// ============================================================================ +// Applying an update +// ============================================================================ + +//! The task action. The installer will replace the files of the install +//! location, this image among them, so hand the work over to a copy running +//! from outside it and get out of the way. +int applyDetach(const std::wstring& appId) +{ + const std::wstring self = modulePath(); + const std::wstring detached = shared::detachedHelperPath(appId); + + if (!ensureSecureRoot(appId)) { + logLine(L"apply: failed to prepare the working directory"); + return 1; + } + + if (!copyFileWithRetries(self, detached)) { + logLine(L"apply: failed to copy the helper to " + detached); + return 1; + } + + const std::wstring commandLine = quoted(detached) + L" --apply-run --app-id " + quoted(appId); + if (!startProcessDetached(detached, commandLine)) { + logLine(L"apply: failed to start the detached helper"); + return 1; + } + + return 0; +} + +int applyRun(const std::wstring& appId) +{ + const std::wstring installDir = regReadString(shared::registryKeyPath(appId), shared::REG_VALUE_INSTALL_DIR); + const std::wstring appPath = regReadString(shared::registryKeyPath(appId), shared::REG_VALUE_APP_PATH); + const std::wstring certSubject = regReadString(shared::registryKeyPath(appId), shared::REG_VALUE_CERT_SUBJECT); + const std::wstring packageType = regReadString(shared::registryKeyPath(appId), shared::REG_VALUE_PACKAGE_TYPE); + const std::wstring installArgs = regReadString(shared::registryKeyPath(appId), shared::REG_VALUE_INSTALL_ARGS); + + if (installDir.empty() || appPath.empty() || packageType.empty()) { + logLine(L"apply-run: the HKLM registration is missing or incomplete"); + return 1; + } + + if (packageType != shared::PACKAGE_TYPE_MSI && packageType != shared::PACKAGE_TYPE_EXE) { + logLine(L"apply-run: unknown package type \"" + packageType + L"\""); + return 1; + } + + shared::UpdateRequest request; + if (!shared::readRequest(appId, request)) { + logLine(L"apply-run: no update request found"); + return 1; + } + + logLine(L"apply-run: request package=" + request.packagePath + L" pid=" + std::to_wstring(request.pid)); + + // 1. Let the application finish quitting before touching its files. + if (request.pid > 0) { + platform::waitForProcessExit(static_cast(request.pid), /*timeoutMs*/ 60000); + } + + // 2. Copy the package somewhere an unprivileged user cannot reach, so that + // it cannot be swapped after we have verified it. + const std::wstring staged = shared::stagedPackagePath(appId, packageType); + + if (!makeDirectories(shared::stagingDirPath(appId)) || !secureDirectory(shared::stagingDirPath(appId), STAGING_SDDL)) { + logLine(L"apply-run: failed to prepare the staging directory"); + return 1; + } + + ::DeleteFileW(staged.c_str()); + + if (!copyFileWithRetries(request.packagePath, staged)) { + logLine(L"apply-run: failed to copy the package to " + staged); + return 1; + } + + // 3. Only now decide whether to trust it. + auto reject = [&](const std::wstring& reason) { + logLine(L"apply-run: " + reason); + ::DeleteFileW(staged.c_str()); + ::DeleteFileW(shared::requestFilePath(appId).c_str()); + }; + + //! NOTE: A valid Authenticode signature alone is not enough - the package + //! path comes from an unprivileged caller, who could otherwise have us + //! install any signed installer at all. Without a configured signer we have + //! nothing to compare against, so refuse rather than guess. + if (certSubject.empty()) { + reject(L"no expected signer configured, refusing to install"); + return 1; + } + + std::wstring signer; + if (!verifySignature(staged, signer)) { + reject(L"the package is not validly signed, refusing to install it"); + return 1; + } + + if (!shared::isExpectedSigner(signer, certSubject)) { + reject(L"unexpected signer \"" + signer + L"\", expected \"" + certSubject + L"\""); + return 1; + } + + // 4. Install silently, the way the installer registered. + // + // The extra arguments come from the registration rather than from here: + // an MSI needs its install directory passed as a property, an Inno Setup + // installer as a switch, and a silent upgrade that is told neither would + // relocate an installation the user had put somewhere else. + const std::wstring extraArgs = shared::expandInstallArgs(installArgs, trimTrailingSeparators(installDir)); + + std::wstring application; + std::wstring commandLine; + + if (packageType == shared::PACKAGE_TYPE_MSI) { + application = systemDirPath() + L"\\msiexec.exe"; + commandLine = quoted(application) + L" /i " + quoted(staged) + L" /qn /norestart"; + } else { + application = staged; + commandLine = quoted(staged); + } + + if (!extraArgs.empty()) { + commandLine += L" " + extraArgs; + } + + logLine(L"apply-run: running " + commandLine); + + DWORD exitCode = 0; + if (!runProcessAndWait(application, commandLine, exitCode)) { + logLine(L"apply-run: failed to start the installer"); + return 1; + } + + // ERROR_SUCCESS_REBOOT_INITIATED (1641) and ERROR_SUCCESS_REBOOT_REQUIRED (3010) + // both mean the installation itself succeeded. + const bool installed = exitCode == 0 || exitCode == 1641 || exitCode == 3010; + if (!installed) { + logLine(L"apply-run: the installer failed with exit code " + std::to_wstring(exitCode)); + ::DeleteFileW(shared::requestFilePath(appId).c_str()); + return 1; + } + + logLine(L"apply-run: installed successfully, exit code " + std::to_wstring(exitCode)); + + // 5. Clean up before relaunching; the request must not survive to be + // replayed on the next run. + ::DeleteFileW(shared::requestFilePath(appId).c_str()); + ::DeleteFileW(staged.c_str()); + ::DeleteFileW(request.packagePath.c_str()); + + // 6. Bring the application back, as the interactive user rather than as us. + if (!relaunchInUserSession(appPath)) { + logLine(L"apply-run: failed to relaunch " + appPath); + // The update itself succeeded; the user can start the application manually. + } + + return 0; +} + +std::map parseArguments(const std::vector& arguments) +{ + std::map result; + + for (size_t i = 0; i < arguments.size(); ++i) { + if (arguments[i].rfind(L"--", 0) != 0) { + continue; + } + + if (i + 1 < arguments.size() && arguments[i + 1].rfind(L"--", 0) != 0) { + result[arguments[i]] = arguments[i + 1]; + ++i; + } else { + result[arguments[i]] = std::wstring(); + } + } + + return result; +} + +std::wstring valueOf(const std::map& arguments, const wchar_t* key) +{ + const auto it = arguments.find(key); + return it != arguments.end() ? it->second : std::wstring(); +} +} + +namespace updatetask { +int runCommandLine() +{ + int argc = 0; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (!argv) { + return 1; + } + + std::vector arguments; + for (int i = 1; i < argc; ++i) { + arguments.emplace_back(argv[i]); + } + ::LocalFree(argv); + + const std::map parsed = parseArguments(arguments); + + const bool isRegister = parsed.count(L"--register-task") > 0; + const bool isUnregister = parsed.count(L"--unregister-task") > 0; + const bool isApply = parsed.count(L"--apply") > 0; + const bool isApplyRun = parsed.count(L"--apply-run") > 0; + + if (!isRegister && !isUnregister && !isApply && !isApplyRun) { + return 1; + } + + const std::wstring appId = valueOf(parsed, L"--app-id"); + if (appId.empty()) { + return 1; + } + + //! NOTE: Secured before the log is opened - every one of these commands runs + //! privileged, and the log lives in that same directory. + ensureSecureRoot(appId); + openLog(appId); + + int returnCode = 1; + + if (isRegister) { + Registration registration; + registration.appId = appId; + registration.appExe = valueOf(parsed, L"--app-exe"); + registration.installDir = valueOf(parsed, L"--install-dir"); + registration.packageType = parsed.count(L"--package-type") ? valueOf(parsed, L"--package-type") + : std::wstring(shared::PACKAGE_TYPE_MSI); + registration.installArgs = valueOf(parsed, L"--install-args"); + registration.certSubject = valueOf(parsed, L"--cert-subject"); + + returnCode = registerTask(registration); + } else if (isUnregister) { + returnCode = unregisterTask(appId); + } else if (isApply) { + returnCode = applyDetach(appId); + } else { + returnCode = applyRun(appId); + } + + closeLog(); + + return returnCode; +} +} diff --git a/framework/update/helper/updatetask_win.h b/framework/update/helper/updatetask_win.h new file mode 100644 index 0000000000..44ae6d516a --- /dev/null +++ b/framework/update/helper/updatetask_win.h @@ -0,0 +1,41 @@ +/* + * 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 + +//! The privileged side of updating on Windows: sets up the scheduled task that +//! runs as SYSTEM, and, from inside that task, installs a downloaded package. +namespace updatetask { +//! Parses the command line, carries out the sub-command it names and returns the +//! process exit code. One of: +//! +//! --register-task --app-id --app-exe +//! --install-dir [--package-type msi|exe] +//! [--install-args ] [--cert-subject ] +//! --unregister-task --app-id +//! --apply --app-id (the action of the scheduled task) +//! --apply-run --app-id (internal: the detached copy doing the work) +//! +//! The arguments are read from `GetCommandLineW` rather than from `argv`, which +//! cannot represent paths outside the ANSI code page. +int runCommandLine(); +} diff --git a/framework/update/internal/platform/win/winupdateinstaller.cpp b/framework/update/internal/platform/win/winupdateinstaller.cpp new file mode 100644 index 0000000000..c45e6722d7 --- /dev/null +++ b/framework/update/internal/platform/win/winupdateinstaller.cpp @@ -0,0 +1,262 @@ +/* + * 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 "winupdateinstaller.h" + +//! NOTE: Qt first - defines min/max and a pile of A/W macros that +//! break headers included after it. +#include +#include +#include + +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include + +#include "winupdateshared.h" + +#include "../../../updateerrors.h" + +#include "log.h" + +using namespace muse; +using namespace muse::update; + +namespace { +//! COM may or may not already be initialised on the calling thread; initialise +//! it if needed and undo only what we did ourselves. +class ComScope +{ +public: + ComScope() + { + const HRESULT hr = ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + m_ok = SUCCEEDED(hr) || hr == RPC_E_CHANGED_MODE; + m_needUninitialize = SUCCEEDED(hr); + } + + ~ComScope() + { + if (m_needUninitialize) { + ::CoUninitialize(); + } + } + + bool isOk() const { return m_ok; } + +private: + bool m_ok = false; + bool m_needUninitialize = false; +}; + +//! Returns the task registered by the installer, or nullptr. The caller owns the +//! returned interface. +IRegisteredTask* openUpdateTask(const std::wstring& appId) +{ + ITaskService* service = nullptr; + HRESULT hr = ::CoCreateInstance(CLSID_TaskScheduler, nullptr, CLSCTX_INPROC_SERVER, IID_ITaskService, + reinterpret_cast(&service)); + if (FAILED(hr) || !service) { + return nullptr; + } + + VARIANT empty; + ::VariantInit(&empty); + + hr = service->Connect(empty, empty, empty, empty); + if (FAILED(hr)) { + service->Release(); + return nullptr; + } + + const std::wstring folderPath = L"\\" + win::taskFolderName(); + BSTR folderPathStr = ::SysAllocString(folderPath.c_str()); + + ITaskFolder* folder = nullptr; + hr = service->GetFolder(folderPathStr, &folder); + ::SysFreeString(folderPathStr); + service->Release(); + + if (FAILED(hr) || !folder) { + return nullptr; + } + + const std::wstring name = win::taskName(appId); + BSTR nameStr = ::SysAllocString(name.c_str()); + + IRegisteredTask* task = nullptr; + hr = folder->GetTask(nameStr, &task); + ::SysFreeString(nameStr); + folder->Release(); + + if (FAILED(hr)) { + return nullptr; + } + + return task; +} + +bool isTaskEnabled(IRegisteredTask* task) +{ + VARIANT_BOOL enabled = VARIANT_FALSE; + return SUCCEEDED(task->get_Enabled(&enabled)) && enabled != VARIANT_FALSE; +} + +QString registeredValue(const std::wstring& appId, const wchar_t* name) +{ + HKEY key = nullptr; + if (::RegOpenKeyExW(HKEY_LOCAL_MACHINE, muse::update::win::registryKeyPath(appId).c_str(), 0, + KEY_QUERY_VALUE | KEY_WOW64_64KEY, &key) != ERROR_SUCCESS) { + return QString(); + } + + wchar_t buffer[1024] = { 0 }; + DWORD size = sizeof(buffer); + DWORD type = 0; + const LSTATUS status = ::RegQueryValueExW(key, name, nullptr, &type, reinterpret_cast(buffer), &size); + ::RegCloseKey(key); + + if (status != ERROR_SUCCESS || type != REG_SZ) { + return QString(); + } + + return QString::fromWCharArray(buffer); +} + +//! Whether the task belongs to the copy of the application that is running. +//! +//! The registered executable is compared directly rather than derived from the +//! install directory: where the executable sits inside an installation differs +//! between applications, and a portable copy running next to an installed one +//! would otherwise hand its package to the task and update the *other* one. +bool isRegisteredForThisInstall(const std::wstring& appId) +{ + const QString registered = registeredValue(appId, muse::update::win::REG_VALUE_APP_PATH); + if (registered.isEmpty()) { + return false; + } + + const QFileInfo registeredInfo(registered); + const QFileInfo runningInfo(QCoreApplication::applicationFilePath()); + + if (!registeredInfo.exists()) { + return false; + } + + return registeredInfo.canonicalFilePath().compare(runningInfo.canonicalFilePath(), Qt::CaseInsensitive) == 0; +} +} + +std::wstring WinUpdateInstaller::appId() const +{ + const QString baseName = QFileInfo(QCoreApplication::applicationFilePath()).completeBaseName(); + return baseName.toStdWString(); +} + +bool WinUpdateInstaller::isInPlaceUpdateSupported() const +{ + if (!isRegisteredForThisInstall(appId())) { + return false; + } + + ComScope com; + if (!com.isOk()) { + return false; + } + + IRegisteredTask* task = openUpdateTask(appId()); + if (!task) { + return false; + } + + const bool enabled = isTaskEnabled(task); + task->Release(); + + return enabled; +} + +Ret WinUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath) +{ + if (!fileSystem()->exists(packagePath)) { + LOGE() << "update package does not exist: " << packagePath; + return make_ret(Err::UnknownError); + } + + ComScope com; + if (!com.isOk()) { + LOGE() << "failed to initialize COM"; + return make_ret(Err::UnknownError); + } + + const std::wstring id = appId(); + + if (!isRegisteredForThisInstall(id)) { + LOGE() << "the update task is registered for a different installation"; + return make_ret(Err::UnknownError); + } + + IRegisteredTask* task = openUpdateTask(id); + if (!task) { + LOGE() << "update task is not registered"; + return make_ret(Err::UnknownError); + } + + if (!isTaskEnabled(task)) { + LOGE() << "update task is disabled"; + task->Release(); + return make_ret(Err::UnknownError); + } + + //! NOTE: An absolute native path; the helper reads it as untrusted input and + //! re-verifies the package signature after copying it out of reach. + const QString nativePath = QDir::toNativeSeparators(QFileInfo(packagePath.toQString()).absoluteFilePath()); + + win::UpdateRequest request; + request.packagePath = nativePath.toStdWString(); + request.pid = static_cast(QCoreApplication::applicationPid()); + + if (!win::writeRequest(id, request)) { + LOGE() << "failed to write update request to " << QString::fromStdWString(win::requestFilePath(id)); + task->Release(); + return make_ret(Err::UnknownError); + } + + VARIANT empty; + ::VariantInit(&empty); + + IRunningTask* runningTask = nullptr; + const HRESULT hr = task->Run(empty, &runningTask); + if (runningTask) { + runningTask->Release(); + } + task->Release(); + + if (FAILED(hr)) { + LOGE() << "failed to run update task, hr=" << static_cast(hr); + ::DeleteFileW(win::requestFilePath(id).c_str()); + return make_ret(Err::UnknownError); + } + + LOGI() << "update task started, it will install " << nativePath << " once this process quits"; + return make_ok(); +} diff --git a/framework/update/internal/platform/win/winupdateinstaller.h b/framework/update/internal/platform/win/winupdateinstaller.h new file mode 100644 index 0000000000..d0170d0a95 --- /dev/null +++ b/framework/update/internal/platform/win/winupdateinstaller.h @@ -0,0 +1,57 @@ +/* + * 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 "../../../iupdateinstaller.h" + +#include "modularity/ioc.h" +#include "io/ifilesystem.h" +#include "../../../iupdateconfiguration.h" + +namespace muse::update { +//! NOTE: The application is installed per-machine (Program Files), which an +//! ordinary user cannot write to. Rather than prompting for elevation, the +//! installer registers a scheduled task that runs as SYSTEM on demand; this +//! class hands the downloaded package to that task, which installs it silently. +//! If the task is missing (portable build, task removed by policy), in-place +//! update is reported as unsupported and the caller falls back to letting the +//! user run the installer manually. +class WinUpdateInstaller : public IUpdateInstaller, public Contextable +{ + GlobalInject fileSystem; + GlobalInject configuration; + +public: + WinUpdateInstaller(const modularity::ContextPtr& iocCtx) + : Contextable(iocCtx) {} + + bool isInPlaceUpdateSupported() const override; + Ret applyUpdate(const muse::io::path_t& packagePath) override; + +private: + //! Identifier shared with the installer-registered task and the HKLM key; + //! the base name of the running executable, e.g. "MuseScore4". + std::wstring appId() const; +}; +} diff --git a/framework/update/internal/platform/win/winupdateshared.h b/framework/update/internal/platform/win/winupdateshared.h new file mode 100644 index 0000000000..e5217c860b --- /dev/null +++ b/framework/update/internal/platform/win/winupdateshared.h @@ -0,0 +1,323 @@ +/* + * 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 + +namespace muse::update::win { +inline std::wstring utf8ToWide(const std::string& str) +{ + if (str.empty()) { + return std::wstring(); + } + + const int size = ::MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), nullptr, 0); + if (size <= 0) { + return std::wstring(); + } + + std::wstring result(static_cast(size), L'\0'); + ::MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), result.data(), size); + return result; +} + +inline std::string wideToUtf8(const std::wstring& str) +{ + if (str.empty()) { + return std::string(); + } + + const int size = ::WideCharToMultiByte(CP_UTF8, 0, str.c_str(), static_cast(str.size()), nullptr, 0, nullptr, nullptr); + if (size <= 0) { + return std::string(); + } + + std::string result(static_cast(size), '\0'); + ::WideCharToMultiByte(CP_UTF8, 0, str.c_str(), static_cast(str.size()), result.data(), size, nullptr, nullptr); + return result; +} + +inline std::wstring programDataPath() +{ + wchar_t buffer[MAX_PATH] = { 0 }; + const DWORD size = ::GetEnvironmentVariableW(L"ProgramData", buffer, MAX_PATH); + if (size > 0 && size < MAX_PATH) { + return std::wstring(buffer, size); + } + + return L"C:\\ProgramData"; +} + +//! Root of the update working area, e.g. `C:\ProgramData\Muse\Update\MuseScore4`. +inline std::wstring updateRootPath(const std::wstring& appId) +{ + return programDataPath() + L"\\Muse\\Update\\" + appId; +} + +inline std::wstring requestsDirPath(const std::wstring& appId) +{ + return updateRootPath(appId) + L"\\requests"; +} + +inline std::wstring requestFilePath(const std::wstring& appId) +{ + return requestsDirPath(appId) + L"\\update.req"; +} + +inline std::wstring stagingDirPath(const std::wstring& appId) +{ + return updateRootPath(appId) + L"\\staging"; +} + +//! `packageType` doubles as the extension, and comes from the registry rather +//! than from the request: the helper executes this file, so an unprivileged +//! caller must have no say in what it is called. +inline std::wstring stagedPackagePath(const std::wstring& appId, const std::wstring& packageType) +{ + return stagingDirPath(appId) + L"\\update." + packageType; +} + +//! The helper copies itself here before running the installer, so that the +//! installer replacing the install location never touches a running image. +inline std::wstring detachedHelperPath(const std::wstring& appId) +{ + return updateRootPath(appId) + L"\\museupdater-run.exe"; +} + +inline std::wstring logFilePath(const std::wstring& appId) +{ + return updateRootPath(appId) + L"\\museupdater.log"; +} + +inline std::wstring taskFolderName() +{ + return L"Muse"; +} + +inline std::wstring taskName(const std::wstring& appId) +{ + return appId + L" Update"; +} + +//! Full Task Scheduler path, e.g. `\Muse\MuseScore4 Update`. +inline std::wstring taskPath(const std::wstring& appId) +{ + return L"\\" + taskFolderName() + L"\\" + taskName(appId); +} + +//! HKLM key written by the installer; the only trusted source of what to install +//! and what to relaunch. Nothing here is layout-specific: an application that +//! keeps its executable at the root of the install directory, or ships an Inno +//! Setup installer instead of an MSI, only registers different values. +inline std::wstring registryKeyPath(const std::wstring& appId) +{ + return L"SOFTWARE\\Muse\\Update\\" + appId; +} + +//! Root of the installation. +inline const wchar_t* REG_VALUE_INSTALL_DIR = L"InstallDir"; + +//! Absolute path of the application executable, used both to relaunch it and to +//! tell whether a running application belongs to this installation. +inline const wchar_t* REG_VALUE_APP_PATH = L"AppPath"; + +//! "msi" (installed with msiexec) or "exe" (a self-contained installer, run +//! directly). Also the extension the staged package is given. +inline const wchar_t* REG_VALUE_PACKAGE_TYPE = L"PackageType"; + +//! Extra arguments for the silent installation, e.g. `INSTALL_ROOT={install-dir}` +//! for an MSI or `/VERYSILENT /NORESTART /DIR={install-dir}` for Inno Setup. The +//! `{install-dir}` token expands to the install location, already quoted. +inline const wchar_t* REG_VALUE_INSTALL_ARGS = L"InstallArgs"; + +//! Expected common name of the signing certificate; several may be given, +//! separated by "|". +//! +//! This is written by the installer of the version that is *currently* running, +//! so a package is always judged against what the previous release knew about. +//! Accepting more than one name is what makes it possible to rotate the +//! certificate: ship the new name alongside the old one first, and only start +//! signing with it once that release is out. +inline const wchar_t* REG_VALUE_CERT_SUBJECT = L"CertSubject"; + +//! Whether `signer` is among the "|"-separated names in `expected`. Empty +//! `expected` matches nothing - there is then nothing to verify against. +inline bool isExpectedSigner(const std::wstring& signer, const std::wstring& expected) +{ + if (signer.empty() || expected.empty()) { + return false; + } + + size_t pos = 0; + while (pos <= expected.size()) { + size_t end = expected.find(L'|', pos); + if (end == std::wstring::npos) { + end = expected.size(); + } + + std::wstring name = expected.substr(pos, end - pos); + + // Tolerate spaces around the separator. + const size_t first = name.find_first_not_of(L" \t"); + const size_t last = name.find_last_not_of(L" \t"); + if (first != std::wstring::npos) { + name = name.substr(first, last - first + 1); + } else { + name.clear(); + } + + if (!name.empty() && name == signer) { + return true; + } + + pos = end + 1; + } + + return false; +} + +inline const wchar_t* PACKAGE_TYPE_MSI = L"msi"; +inline const wchar_t* PACKAGE_TYPE_EXE = L"exe"; + +//! Replaces `{install-dir}` in `args` with `installDir` in quotes. +inline std::wstring expandInstallArgs(const std::wstring& args, const std::wstring& installDir) +{ + const std::wstring token = L"{install-dir}"; + const std::wstring value = L"\"" + installDir + L"\""; + + std::wstring result = args; + for (size_t pos = result.find(token); pos != std::wstring::npos; pos = result.find(token, pos + value.size())) { + result.replace(pos, token.size(), value); + } + + return result; +} + +struct UpdateRequest { + std::wstring packagePath; + unsigned long long pid = 0; +}; + +inline bool writeFileContent(const std::wstring& path, const std::string& content) +{ + const HANDLE file = ::CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) { + return false; + } + + DWORD written = 0; + const BOOL ok = ::WriteFile(file, content.data(), static_cast(content.size()), &written, nullptr); + ::CloseHandle(file); + + return ok && written == content.size(); +} + +inline bool readFileContent(const std::wstring& path, std::string& content) +{ + const HANDLE file = ::CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) { + return false; + } + + LARGE_INTEGER size = { }; + if (!::GetFileSizeEx(file, &size) || size.QuadPart <= 0 || size.QuadPart > 64 * 1024) { + ::CloseHandle(file); + return false; + } + + content.resize(static_cast(size.QuadPart)); + + DWORD read = 0; + const BOOL ok = ::ReadFile(file, content.data(), static_cast(content.size()), &read, nullptr); + ::CloseHandle(file); + + if (!ok) { + return false; + } + + content.resize(read); + return true; +} + +inline bool writeRequest(const std::wstring& appId, const UpdateRequest& request) +{ + std::string content; + content += "package=" + wideToUtf8(request.packagePath) + "\n"; + content += "pid=" + std::to_string(request.pid) + "\n"; + + return writeFileContent(requestFilePath(appId), content); +} + +inline bool readRequest(const std::wstring& appId, UpdateRequest& request) +{ + std::string content; + if (!readFileContent(requestFilePath(appId), content)) { + return false; + } + + //! NOTE: The application writes no byte order mark, but a request written by + //! hand while testing usually has one. + if (content.size() >= 3 && static_cast(content[0]) == 0xEF + && static_cast(content[1]) == 0xBB && static_cast(content[2]) == 0xBF) { + content.erase(0, 3); + } + + size_t pos = 0; + while (pos < content.size()) { + size_t end = content.find('\n', pos); + if (end == std::string::npos) { + end = content.size(); + } + + std::string line = content.substr(pos, end - pos); + pos = end + 1; + + while (!line.empty() && (line.back() == '\r' || line.back() == ' ')) { + line.pop_back(); + } + + const size_t eq = line.find('='); + if (eq == std::string::npos) { + continue; + } + + const std::string key = line.substr(0, eq); + const std::string value = line.substr(eq + 1); + + if (key == "package") { + request.packagePath = utf8ToWide(value); + } else if (key == "pid") { + try { + request.pid = std::stoull(value); + } catch (...) { + request.pid = 0; + } + } + } + + return !request.packagePath.empty(); +} +} diff --git a/framework/update/updatemodule.cpp b/framework/update/updatemodule.cpp index 4a21c21bce..6b7ef0ce2d 100644 --- a/framework/update/updatemodule.cpp +++ b/framework/update/updatemodule.cpp @@ -40,8 +40,10 @@ #include "internal/appupdateservice.h" #include "iupdateinstaller.h" -#ifdef Q_OS_MAC +#if defined(Q_OS_MAC) #include "internal/platform/mac/macupdateinstaller.h" +#elif defined(Q_OS_WIN) +#include "internal/platform/win/winupdateinstaller.h" #else #include "internal/platform/stub/updateinstallerstub.h" #endif @@ -60,8 +62,10 @@ void UpdateModule::registerExports() { m_configuration = std::make_shared(globalCtx()); -#ifdef Q_OS_MAC +#if defined(Q_OS_MAC) m_updateInstaller = std::make_shared(globalCtx()); +#elif defined(Q_OS_WIN) + m_updateInstaller = std::make_shared(globalCtx()); #else m_updateInstaller = std::make_shared(); #endif From dc4e09bd8a879369151cfb922e99046ce25818bb Mon Sep 17 00:00:00 2001 From: Elnur Ismailzada Date: Mon, 10 Aug 2026 12:02:23 +0300 Subject: [PATCH 04/13] added updater for linux --- framework/update/CMakeLists.txt | 5 + framework/update/helper/CMakeLists.txt | 11 + framework/update/helper/platform_unix.cpp | 39 +++- .../update/internal/appupdateservice.cpp | 4 - .../platform/linux/linuxupdateinstaller.cpp | 190 ++++++++++++++++++ .../platform/linux/linuxupdateinstaller.h | 60 ++++++ .../update/internal/updateconfiguration.cpp | 4 - framework/update/updatemodule.cpp | 4 + 8 files changed, 308 insertions(+), 9 deletions(-) create mode 100644 framework/update/internal/platform/linux/linuxupdateinstaller.cpp create mode 100644 framework/update/internal/platform/linux/linuxupdateinstaller.h diff --git a/framework/update/CMakeLists.txt b/framework/update/CMakeLists.txt index 45a6165ed5..de21eff5af 100644 --- a/framework/update/CMakeLists.txt +++ b/framework/update/CMakeLists.txt @@ -63,6 +63,11 @@ elseif (OS_IS_WIN) ) target_link_libraries(muse_update PRIVATE taskschd ole32 oleaut32) +elseif (OS_IS_LIN) + target_sources(muse_update PRIVATE + internal/platform/linux/linuxupdateinstaller.cpp + internal/platform/linux/linuxupdateinstaller.h + ) else() target_sources(muse_update PRIVATE internal/platform/stub/updateinstallerstub.cpp diff --git a/framework/update/helper/CMakeLists.txt b/framework/update/helper/CMakeLists.txt index f50c01e38a..e259bbf400 100644 --- a/framework/update/helper/CMakeLists.txt +++ b/framework/update/helper/CMakeLists.txt @@ -52,6 +52,17 @@ set_target_properties(${UPDATE_HELPER_TARGET} PROPERTIES CXX_STANDARD_REQUIRED ON ) +if (OS_IS_LIN) + # The helper is copied out of the AppImage and runs after it has been + # unmounted, so it cannot rely on the libraries bundled inside it. Those are + # generally newer than what the host provides, and linking against them + # dynamically would make the helper fail to start on exactly the systems + # where it is needed. + if (CC_IS_GCC OR CC_IS_CLANG) + target_link_options(${UPDATE_HELPER_TARGET} PRIVATE -static-libstdc++ -static-libgcc) + endif() +endif() + if (OS_IS_WIN) target_link_libraries(${UPDATE_HELPER_TARGET} PRIVATE taskschd # CLSID_TaskScheduler, IID_ITaskService, IID_IExecAction diff --git a/framework/update/helper/platform_unix.cpp b/framework/update/helper/platform_unix.cpp index 5a526f9946..0365c6904a 100644 --- a/framework/update/helper/platform_unix.cpp +++ b/framework/update/helper/platform_unix.cpp @@ -23,6 +23,8 @@ #include "platform.h" #include +#include +#include #include #include #include @@ -39,6 +41,31 @@ void sleepMs(int ms) ts.tv_nsec = (ms % 1000) * 1000000L; nanosleep(&ts, nullptr); } + +//! An AppImage is an ELF executable carrying "AI" plus the format version in the +//! bytes the ELF header reserves for the OS ABI. Only type 2 is published. +bool hasAppImageHeader(const std::string& path) +{ + FILE* file = std::fopen(path.c_str(), "rb"); + if (!file) { + return false; + } + + unsigned char header[11] = { 0 }; + const size_t read = std::fread(header, 1, sizeof(header), file); + std::fclose(file); + + if (read != sizeof(header)) { + return false; + } + + static const unsigned char ELF_MAGIC[] = { 0x7f, 'E', 'L', 'F' }; + if (std::memcmp(header, ELF_MAGIC, sizeof(ELF_MAGIC)) != 0) { + return false; + } + + return header[8] == 'A' && header[9] == 'I' && header[10] == 0x02; +} } namespace platform { @@ -57,8 +84,18 @@ void waitForProcessExit(long long pid, int timeoutMs) bool verifyInstall(const std::string& path) { + //! NOTE: There is no signature to check against on Linux, so this only + //! establishes that what was swapped in is a launchable AppImage - enough to + //! catch a truncated or half-copied file and roll back instead of leaving + //! the user without a working application. + //! The executable bit is not an integrity property - `relaunch` sets it + //! unconditionally - so it is deliberately not checked here. struct stat st; - return ::stat(path.c_str(), &st) == 0; + if (::stat(path.c_str(), &st) != 0 || !S_ISREG(st.st_mode)) { + return false; + } + + return hasAppImageHeader(path); } bool relaunch(const std::string& path) diff --git a/framework/update/internal/appupdateservice.cpp b/framework/update/internal/appupdateservice.cpp index b96dee25bf..45648ebe5a 100644 --- a/framework/update/internal/appupdateservice.cpp +++ b/framework/update/internal/appupdateservice.cpp @@ -523,7 +523,6 @@ void AppUpdateService::clear() void AppUpdateService::cleanupStalePackages(const std::string& keepFileName) { -#if !defined(Q_OS_LINUX) const io::path_t dir = configuration()->updateDataPath(); if (!fileSystem()->exists(dir)) { return; @@ -550,9 +549,6 @@ void AppUpdateService::cleanupStalePackages(const std::string& keepFileName) fileSystem()->remove(entry); } } -#else - UNUSED(keepFileName); -#endif } bool AppUpdateService::isReleaseDownloaded() const diff --git a/framework/update/internal/platform/linux/linuxupdateinstaller.cpp b/framework/update/internal/platform/linux/linuxupdateinstaller.cpp new file mode 100644 index 0000000000..a32c473bb9 --- /dev/null +++ b/framework/update/internal/platform/linux/linuxupdateinstaller.cpp @@ -0,0 +1,190 @@ +/* + * 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 "linuxupdateinstaller.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "../../../updateerrors.h" + +#include "log.h" + +using namespace muse; +using namespace muse::update; + +static const QString HELPER_NAME("museupdater"); + +namespace { +//! An AppImage is an ELF executable with a marker in the bytes the ELF header +//! reserves for the OS ABI: "AI" followed by the AppImage format version. +//! Only type 2 is produced nowadays, and it is what we publish. +constexpr int APPIMAGE_HEADER_SIZE = 11; +constexpr char APPIMAGE_TYPE_2 = 0x02; + +bool isAppImageFile(const QString& path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + LOGE() << "failed to open for reading: " << path; + return false; + } + + char header[APPIMAGE_HEADER_SIZE] = { 0 }; + if (file.read(header, APPIMAGE_HEADER_SIZE) != APPIMAGE_HEADER_SIZE) { + LOGE() << "file is too small to be an AppImage: " << path; + return false; + } + + static const char ELF_MAGIC[] = { 0x7f, 'E', 'L', 'F' }; + if (std::memcmp(header, ELF_MAGIC, sizeof(ELF_MAGIC)) != 0) { + LOGE() << "not an ELF file: " << path; + return false; + } + + if (header[8] != 'A' || header[9] != 'I' || header[10] != APPIMAGE_TYPE_2) { + LOGE() << "not a type 2 AppImage: " << path; + return false; + } + + return true; +} + +bool isWritable(const QString& path) +{ + return ::access(path.toUtf8().constData(), W_OK) == 0; +} +} + +io::path_t LinuxUpdateInstaller::currentAppImagePath() const +{ + //! NOTE: Set by the AppImage runtime. Absent for every other way of running + //! the application, none of which can be updated by replacing one file. + const QByteArray appImage = qgetenv("APPIMAGE"); + if (appImage.isEmpty()) { + return {}; + } + + const QFileInfo info(QFile::decodeName(appImage)); + if (!info.exists() || !info.isFile()) { + return {}; + } + + return io::path_t(info.canonicalFilePath()); +} + +io::path_t LinuxUpdateInstaller::helperPath() const +{ + return io::path_t(QCoreApplication::applicationDirPath() + "/" + HELPER_NAME); +} + +bool LinuxUpdateInstaller::isInPlaceUpdateSupported() const +{ + const QString appImage = currentAppImagePath().toQString(); + if (appImage.isEmpty()) { + return false; + } + + if (!QFileInfo::exists(helperPath().toQString())) { + return false; + } + + //! NOTE: Replacing the AppImage creates a new directory entry, so the + //! containing directory has to be writable too - a writable file inside a + //! read-only directory (a system-wide install) is not enough. + if (!isWritable(appImage) || !isWritable(QFileInfo(appImage).absolutePath())) { + return false; + } + + return true; +} + +Ret LinuxUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath) +{ + const QString package = packagePath.toQString(); + if (!QFileInfo::exists(package)) { + LOGE() << "update package does not exist: " << package; + return make_ret(Err::UnknownError); + } + + const QString appImagePath = currentAppImagePath().toQString(); + if (appImagePath.isEmpty()) { + LOGE() << "not running from an AppImage, cannot update in place"; + return make_ret(Ret::Code::NotSupported); + } + + // 1. The downloaded package replaces the running file as-is, so make sure it + // really is an AppImage before letting the helper move it into place. + if (!isAppImageFile(package)) { + LOGE() << "update package is not a valid AppImage: " << package; + return make_ret(Err::UnknownError); + } + + // 2. The download has no executable bit; the swapped-in file must be + // runnable. + const QFile::Permissions permissions + = QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner + | QFile::ReadGroup | QFile::ExeGroup + | QFile::ReadOther | QFile::ExeOther; + + if (!QFile::setPermissions(package, permissions)) { + LOGE() << "failed to make update package executable: " << package; + return make_ret(Err::UnknownError); + } + + // 3. Copy the helper out of the AppImage. Its mount point disappears as soon + // as this process exits, which is exactly when the helper starts working. + fileSystem()->makePath(configuration()->updateDataPath()); + + const QString helperRun = configuration()->updateDataPath().toQString() + "/" + HELPER_NAME; + QFile::remove(helperRun); + if (!QFile::copy(helperPath().toQString(), helperRun)) { + LOGE() << "failed to copy helper to " << helperRun; + return make_ret(Err::UnknownError); + } + QFile::setPermissions(helperRun, permissions); + + // 4. Spawn the detached helper. It waits for us to quit, replaces the + // AppImage and relaunches it. + const QString logPath = configuration()->updateDataPath().toQString() + "/museupdater.log"; + const QStringList args = { + "--wait-pid", QString::number(QCoreApplication::applicationPid()), + "--src", package, + "--dst", appImagePath, + "--relaunch", appImagePath, + "--log", logPath + }; + + if (!QProcess::startDetached(helperRun, args)) { + LOGE() << "failed to start update helper"; + return make_ret(Err::UnknownError); + } + + LOGI() << "update helper started, will replace " << appImagePath << " after quit"; + return make_ok(); +} diff --git a/framework/update/internal/platform/linux/linuxupdateinstaller.h b/framework/update/internal/platform/linux/linuxupdateinstaller.h new file mode 100644 index 0000000000..6367fb7948 --- /dev/null +++ b/framework/update/internal/platform/linux/linuxupdateinstaller.h @@ -0,0 +1,60 @@ +/* + * 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 "../../../iupdateinstaller.h" + +#include "modularity/ioc.h" +#include "io/ifilesystem.h" +#include "../../../iupdateconfiguration.h" + +namespace muse::update { +//! NOTE: The whole application is a single AppImage file, so an update is +//! nothing more than replacing that file - the downloaded package needs no +//! unpacking. This only applies when we are actually running from an AppImage; +//! distribution packages, Flatpak and Snap manage their own updates and a plain +//! build has no single file to replace, so in-place update is reported as +//! unsupported there and the caller falls back to handing the download to the +//! user. +class LinuxUpdateInstaller : public IUpdateInstaller, public Contextable +{ + GlobalInject fileSystem; + GlobalInject configuration; + +public: + LinuxUpdateInstaller(const modularity::ContextPtr& iocCtx) + : Contextable(iocCtx) {} + + bool isInPlaceUpdateSupported() const override; + Ret applyUpdate(const muse::io::path_t& packagePath) override; + +private: + //! Path to the running AppImage file (the install location to replace), or + //! empty when not running from an AppImage. Symlinks are resolved, so that + //! launching through e.g. `~/.local/bin/mscore` replaces the real file and + //! leaves the symlink pointing at it. + muse::io::path_t currentAppImagePath() const; + + //! Path to the `museupdater` helper bundled next to the application binary. + muse::io::path_t helperPath() const; +}; +} diff --git a/framework/update/internal/updateconfiguration.cpp b/framework/update/internal/updateconfiguration.cpp index bed6862c5d..64524213b9 100644 --- a/framework/update/internal/updateconfiguration.cpp +++ b/framework/update/internal/updateconfiguration.cpp @@ -144,11 +144,7 @@ std::string UpdateConfiguration::privacyPolicyUrl() const muse::io::path_t UpdateConfiguration::updateDataPath() const { -#if defined(Q_OS_LINUX) - return globalConfiguration()->downloadsPath() + "/"; -#else return globalConfiguration()->userAppDataPath() + "/update"; -#endif } muse::io::path_t UpdateConfiguration::updateRequestHistoryJsonPath() const diff --git a/framework/update/updatemodule.cpp b/framework/update/updatemodule.cpp index 6b7ef0ce2d..fde8aaed8f 100644 --- a/framework/update/updatemodule.cpp +++ b/framework/update/updatemodule.cpp @@ -44,6 +44,8 @@ #include "internal/platform/mac/macupdateinstaller.h" #elif defined(Q_OS_WIN) #include "internal/platform/win/winupdateinstaller.h" +#elif defined(Q_OS_LINUX) +#include "internal/platform/linux/linuxupdateinstaller.h" #else #include "internal/platform/stub/updateinstallerstub.h" #endif @@ -66,6 +68,8 @@ void UpdateModule::registerExports() m_updateInstaller = std::make_shared(globalCtx()); #elif defined(Q_OS_WIN) m_updateInstaller = std::make_shared(globalCtx()); +#elif defined(Q_OS_LINUX) + m_updateInstaller = std::make_shared(globalCtx()); #else m_updateInstaller = std::make_shared(); #endif From 27abb83a1dba97bbb30b9d3e3808617b05d5776b Mon Sep 17 00:00:00 2001 From: Eism Date: Fri, 14 Aug 2026 11:38:27 +0300 Subject: [PATCH 05/13] refactor update process for improved robustness and atomicity Stage the unpacked update next to the install location first, so the only non-atomic phase (cross-volume copy) happens while the current install is still intact. Verify the staged update before touching the install instead of verifying after the swap and rolling back. Then swap it into place with a single atomic exchange (renamex_np on macOS, renameat2 on Linux), falling back to two same-volume renames. Previously a crash mid-update could leave the install location empty or half-copied with no way to recover. Also abort the swap when the host process is still running after the wait timeout instead of replacing files under a live application. --- framework/update/helper/platform.h | 3 +- framework/update/helper/platform_mac.cpp | 5 +- framework/update/helper/platform_unix.cpp | 5 +- framework/update/helper/swap.cpp | 116 ++++++++++++++++------ framework/update/helper/swap.h | 6 +- 5 files changed, 96 insertions(+), 39 deletions(-) diff --git a/framework/update/helper/platform.h b/framework/update/helper/platform.h index c2eca5d00d..ae355294eb 100644 --- a/framework/update/helper/platform.h +++ b/framework/update/helper/platform.h @@ -26,7 +26,8 @@ namespace platform { //! Block until the process `pid` has exited, or `timeoutMs` elapses. -void waitForProcessExit(long long pid, int timeoutMs); +//! Returns false when the process is still running after the timeout. +bool waitForProcessExit(long long pid, int timeoutMs); #ifndef _WIN32 //! Verify that the swapped-in install at `path` is launchable. diff --git a/framework/update/helper/platform_mac.cpp b/framework/update/helper/platform_mac.cpp index 419d4c822a..75873eb88d 100644 --- a/framework/update/helper/platform_mac.cpp +++ b/framework/update/helper/platform_mac.cpp @@ -54,18 +54,19 @@ int runDetachedAndWait(const char* path, char* const argv[]) } namespace platform { -void waitForProcessExit(long long pid, int timeoutMs) +bool waitForProcessExit(long long pid, int timeoutMs) { const int step = 100; int waited = 0; while (waited < timeoutMs) { if (::kill(static_cast(pid), 0) != 0) { // No such process -> it has exited. - return; + return true; } sleepMs(step); waited += step; } + return ::kill(static_cast(pid), 0) != 0; } bool verifyInstall(const std::string& path) diff --git a/framework/update/helper/platform_unix.cpp b/framework/update/helper/platform_unix.cpp index 0365c6904a..60d2cb5ca5 100644 --- a/framework/update/helper/platform_unix.cpp +++ b/framework/update/helper/platform_unix.cpp @@ -69,17 +69,18 @@ bool hasAppImageHeader(const std::string& path) } namespace platform { -void waitForProcessExit(long long pid, int timeoutMs) +bool waitForProcessExit(long long pid, int timeoutMs) { const int step = 100; int waited = 0; while (waited < timeoutMs) { if (::kill(static_cast(pid), 0) != 0) { - return; + return true; } sleepMs(step); waited += step; } + return ::kill(static_cast(pid), 0) != 0; } bool verifyInstall(const std::string& path) diff --git a/framework/update/helper/swap.cpp b/framework/update/helper/swap.cpp index 112d80d4f3..cc41f0094e 100644 --- a/framework/update/helper/swap.cpp +++ b/framework/update/helper/swap.cpp @@ -24,10 +24,21 @@ #include "platform.h" +#include +#include +#include #include -#include #include -#include +#include + +#ifdef __linux__ +#include +#include +#include +#ifndef RENAME_EXCHANGE +#define RENAME_EXCHANGE (1 << 1) +#endif +#endif namespace fs = std::filesystem; @@ -70,6 +81,8 @@ Args parseArgs(int argc, char** argv) //! Move `from` to `to`, falling back to copy+remove when rename crosses a //! filesystem boundary (rename only succeeds within a single volume). +//! Only safe while the install location is untouched: the copy is not atomic +//! and a crash leaves a partial `to` behind. bool movePath(const fs::path& from, const fs::path& to, std::error_code& ec) { fs::rename(from, to, ec); @@ -87,6 +100,23 @@ bool movePath(const fs::path& from, const fs::path& to, std::error_code& ec) fs::remove_all(from, rmEc); return true; } + +//! Atomically exchange `a` and `b` in a single syscall, so that there is no +//! moment when the install location is empty or half-populated. Fails when the +//! filesystem does not support it; the caller then falls back to two renames. +bool exchangePaths(const fs::path& a, const fs::path& b) +{ +#if defined(__APPLE__) + return ::renamex_np(a.c_str(), b.c_str(), RENAME_SWAP) == 0; +#elif defined(SYS_renameat2) + return ::syscall(SYS_renameat2, AT_FDCWD, a.c_str(), AT_FDCWD, b.c_str(), RENAME_EXCHANGE) == 0; +#else + (void)a; + (void)b; + errno = ENOTSUP; + return false; +#endif +} } int swapper::run(int argc, char** argv) @@ -110,11 +140,15 @@ int swapper::run(int argc, char** argv) // 1. Wait for the host application to fully exit before touching its files. if (args.waitPid > 0) { - platform::waitForProcessExit(args.waitPid, /*timeoutMs*/ 60000); + if (!platform::waitForProcessExit(args.waitPid, /*timeoutMs*/ 60000)) { + logLine("error: host process is still running"); + return 1; + } } const fs::path src(args.src); const fs::path dst(args.dst); + const fs::path staging = fs::path(dst).concat(".staging"); const fs::path backup = fs::path(dst).concat(".bak"); std::error_code ec; @@ -124,45 +158,65 @@ int swapper::run(int argc, char** argv) return 1; } - // 2. Backup the current install location. + // 2. Stage the update next to dst. This is the only non-atomic phase (the + // copy fallback when src is on another volume), and dst is still intact + // throughout it: a crash here loses nothing but the downloaded update. + // From here on every step that exposes dst is a same-volume rename. + fs::remove_all(staging, ec); fs::remove_all(backup, ec); ec.clear(); - if (fs::exists(dst, ec)) { - if (!movePath(dst, backup, ec)) { - logLine("error: failed to backup dst: " + ec.message()); - return 1; - } + if (!movePath(src, staging, ec)) { + logLine("error: failed to stage the update: " + ec.message()); + std::error_code rmEc; + fs::remove_all(staging, rmEc); + return 1; } - // 3. Swap in the new install. - if (!movePath(src, dst, ec)) { - logLine("error: failed to move src into place: " + ec.message()); - - // Rollback. - std::error_code rbEc; - if (fs::exists(backup, rbEc)) { - movePath(backup, dst, rbEc); - } + // 3. Verify the staged install while dst is untouched (platform-specific + // check, e.g. code signature validity on macOS), instead of verifying + // after the swap and rolling back: this way a bad update never replaces + // a working install even for a moment. + if (!platform::verifyInstall(staging.string())) { + logLine("error: staged install failed verification"); + std::error_code rmEc; + fs::remove_all(staging, rmEc); return 1; } - // 4. Verify the result; rollback on failure (platform-specific check, e.g. - // code signature validity on macOS). - if (!platform::verifyInstall(dst.string())) { - logLine("error: post-swap verification failed, rolling back"); + // 4. Swap the staged install into place: preferably one atomic exchange + // (no window at all), otherwise two renames (the window is only between + // them, and the old install survives as a backup until the new one is + // in place). + const bool dstExists = fs::exists(dst, ec); + ec.clear(); - std::error_code rbEc; - fs::remove_all(dst, rbEc); - if (fs::exists(backup, rbEc)) { - movePath(backup, dst, rbEc); + if (dstExists && exchangePaths(staging, dst)) { + std::error_code rmEc; + fs::remove_all(staging, rmEc); // now holds the old install + } else { + if (dstExists) { + logLine(std::string("atomic exchange unavailable: ") + std::strerror(errno)); + fs::rename(dst, backup, ec); + if (ec) { + logLine("error: failed to move dst aside: " + ec.message()); + std::error_code rmEc; + fs::remove_all(staging, rmEc); + return 1; + } } - return 1; - } + fs::rename(staging, dst, ec); + if (ec) { + logLine("error: failed to move staged install into place: " + ec.message()); - // 5. Success: drop the backup. - fs::remove_all(backup, ec); + std::error_code rbEc; + fs::rename(backup, dst, rbEc); + return 1; + } + std::error_code rmEc; + fs::remove_all(backup, rmEc); + } - // 6. Relaunch the updated application. + // 5. Relaunch the updated application. const std::string relaunchTarget = args.relaunch.empty() ? dst.string() : args.relaunch; if (!platform::relaunch(relaunchTarget)) { logLine("error: failed to relaunch " + relaunchTarget); diff --git a/framework/update/helper/swap.h b/framework/update/helper/swap.h index 1c08bf782a..9691cdc258 100644 --- a/framework/update/helper/swap.h +++ b/framework/update/helper/swap.h @@ -23,9 +23,9 @@ #pragma once namespace swapper { -//! Waits for the host application to exit, swaps the install location with the -//! freshly unpacked update (keeping a backup for rollback) and relaunches the -//! application. +//! Waits for the host application to exit, stages the freshly unpacked update +//! next to the install location, verifies it, atomically swaps it into place +//! and relaunches the application. //! //! --wait-pid --src --dst [--relaunch ] [--log ] //! From 0e5499f76d900d7228e2d4df6c5ac7b00fef8989 Mon Sep 17 00:00:00 2001 From: Eism Date: Fri, 14 Aug 2026 18:00:40 +0300 Subject: [PATCH 06/13] updated the update dialog --- .../update/internal/appupdatescenario.cpp | 33 ++++++++++++- .../qml/Muse/Update/AppReleaseInfoDialog.qml | 46 +++++++++++++++++-- .../update/qml/Muse/Update/UpdateBanner.qml | 2 +- .../internal/AppReleaseInfoBottomPanel.qml | 2 + 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/framework/update/internal/appupdatescenario.cpp b/framework/update/internal/appupdatescenario.cpp index d6171826be..a6aeb1f4c0 100644 --- a/framework/update/internal/appupdatescenario.cpp +++ b/framework/update/internal/appupdatescenario.cpp @@ -339,5 +339,36 @@ void AppUpdateScenario::installReadyUpdate() return; } - askToRestartAndInstall(m_readyPackagePath).onResolve(this, [](const Ret&) {}); + 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") { + configuration()->setSkippedReleaseVersion(m_readyUpdateVersion); + m_readyPackagePath = io::path_t(); + m_hasReadyUpdateChanged.notify(); + return; + } + + if (actionCode != "install") { + return; + } + + const Ret ret = service()->applyUpdate(m_readyPackagePath); + if (!ret) { + LOGE() << "failed to apply update in-place, falling back to manual install: " << ret.toString(); + askToCloseAppAndCompleteInstall(m_readyPackagePath).onResolve(this, [](const Ret&) {}); + return; + } + + dispatcher()->dispatch("quit", ActionData::make_arg2(false, std::string())); + }); } diff --git a/framework/update/qml/Muse/Update/AppReleaseInfoDialog.qml b/framework/update/qml/Muse/Update/AppReleaseInfoDialog.qml index d18bfec71b..96afc3a139 100644 --- a/framework/update/qml/Muse/Update/AppReleaseInfoDialog.qml +++ b/framework/update/qml/Muse/Update/AppReleaseInfoDialog.qml @@ -31,6 +31,8 @@ StyledDialogView { id: root property string appName: "" + property string version: "" + property bool readyToInstall: false property alias notes: view.notes property alias previousReleasesNotes: view.previousReleasesNotes @@ -79,13 +81,32 @@ StyledDialogView { StyledTextLabel { id: releaseTitleLabel - text: qsTrc("update", "A new version of %1 is available!").arg(root.appName) + 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 } + StyledTextLabel { + id: releaseDescriptionLabel + + width: content.width + + visible: root.readyToInstall + + text: qsTrc("update", "%1 has downloaded an update and is ready to install. " + + "A restart will be required 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 + } + StyledTextLabel { id: releaseNotesLabel + visible: !root.readyToInstall + text: qsTrc("update", "Release notes") font: ui.theme.largeBodyBoldFont horizontalAlignment: Qt.AlignLeft @@ -97,11 +118,28 @@ StyledDialogView { Layout.rightMargin: -root.margins } - ReleaseNotesView { - id: view - + ColumnLayout { Layout.fillWidth: true Layout.fillHeight: true + + spacing: 12 + + StyledTextLabel { + visible: root.readyToInstall + + text: root.version.length > 0 + ? qsTrc("update", "%1 Release notes").arg(root.version) + : qsTrc("update", "Release notes") + font: ui.theme.largeBodyBoldFont + horizontalAlignment: Qt.AlignLeft + } + + ReleaseNotesView { + id: view + + Layout.fillWidth: true + Layout.fillHeight: true + } } SeparatorLine { diff --git a/framework/update/qml/Muse/Update/UpdateBanner.qml b/framework/update/qml/Muse/Update/UpdateBanner.qml index 00b7a2830a..08227cd07d 100644 --- a/framework/update/qml/Muse/Update/UpdateBanner.qml +++ b/framework/update/qml/Muse/Update/UpdateBanner.qml @@ -74,7 +74,7 @@ Rectangle { FlatButton { Layout.fillWidth: true - text: qsTrc("update", "Restart and update") + text: qsTrc("update", "Update") accentButton: true onClicked: { diff --git a/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml b/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml index 645f10a178..da488bcb85 100644 --- a/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml +++ b/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml @@ -68,6 +68,7 @@ RowLayout { Layout.alignment: Qt.AlignVCenter text: qsTrc("update", "Remind me later") + icon: IconCode.CLOCK navigation.name: "RemindMeLaterButton" navigation.panel: root.navigationPanel @@ -84,6 +85,7 @@ RowLayout { Layout.alignment: Qt.AlignVCenter text: qsTrc("update", "Install update") + icon: IconCode.IMPORT accentButton: true From fdfa43b0d3210257f180fab3b33e3b900a1d2cd4 Mon Sep 17 00:00:00 2001 From: Eism Date: Mon, 17 Aug 2026 10:11:32 +0300 Subject: [PATCH 07/13] refine update package management and download mechanisms Distinguish download locations for auto-installable updates (updateDataPath) and manually installed updates (downloadsPath). Track the last downloaded package to improve cleanup and enable more targeted handling. Internalize background download logic within `AppUpdateScenario`, triggering it automatically for auto-installable updates after a successful check. Simplify the `IAppUpdateScenario` public interface by removing redundant methods. --- .../stubs/update/appupdatescenariostub.cpp | 16 ----- .../stubs/update/appupdatescenariostub.h | 4 -- .../stubs/update/updateconfigurationstub.cpp | 14 ++++ .../stubs/update/updateconfigurationstub.h | 4 ++ framework/update/iappupdatescenario.h | 11 ---- .../update/internal/appupdatescenario.cpp | 65 +++++++------------ framework/update/internal/appupdatescenario.h | 10 +-- .../update/internal/appupdateservice.cpp | 41 ++++++++---- framework/update/internal/appupdateservice.h | 2 + .../platform/mac/macupdateinstaller.cpp | 3 +- .../update/internal/updateconfiguration.cpp | 16 +++++ .../update/internal/updateconfiguration.h | 4 ++ framework/update/iupdateconfiguration.h | 4 ++ .../update/tests/appupdateservice_tests.cpp | 38 +++++++++++ .../tests/mocks/updateconfigurationmock.h | 4 ++ 15 files changed, 142 insertions(+), 94 deletions(-) diff --git a/framework/stubs/update/appupdatescenariostub.cpp b/framework/stubs/update/appupdatescenariostub.cpp index 8f5e73973c..48853ff2cd 100644 --- a/framework/stubs/update/appupdatescenariostub.cpp +++ b/framework/stubs/update/appupdatescenariostub.cpp @@ -47,22 +47,6 @@ bool AppUpdateScenarioStub::hasUpdate() const return false; } -muse::async::Promise AppUpdateScenarioStub::showUpdate() -{ - return muse::async::Promise([](auto /*resolve*/, auto reject) { - return reject(int(muse::Ret::Code::UnknownError), "stub"); - }); -} - -bool AppUpdateScenarioStub::canAutoInstall() const -{ - return false; -} - -void AppUpdateScenarioStub::downloadUpdateInBackground() -{ -} - bool AppUpdateScenarioStub::hasReadyUpdate() const { return false; diff --git a/framework/stubs/update/appupdatescenariostub.h b/framework/stubs/update/appupdatescenariostub.h index e9dd2b1128..77af819d81 100644 --- a/framework/stubs/update/appupdatescenariostub.h +++ b/framework/stubs/update/appupdatescenariostub.h @@ -35,10 +35,6 @@ class AppUpdateScenarioStub : public IAppUpdateScenario async::Notification checkInProgressChanged() const override; bool hasUpdate() const override; - muse::async::Promise showUpdate() override; - - bool canAutoInstall() const override; - void downloadUpdateInBackground() override; bool hasReadyUpdate() const override; async::Notification hasReadyUpdateChanged() const override; diff --git a/framework/stubs/update/updateconfigurationstub.cpp b/framework/stubs/update/updateconfigurationstub.cpp index 289d6422ee..1efdc76bd9 100644 --- a/framework/stubs/update/updateconfigurationstub.cpp +++ b/framework/stubs/update/updateconfigurationstub.cpp @@ -57,6 +57,15 @@ std::string UpdateConfigurationStub::skippedReleaseVersion() const return ""; } +muse::io::path_t UpdateConfigurationStub::lastDownloadedPackagePath() const +{ + return ""; +} + +void UpdateConfigurationStub::setLastDownloadedPackagePath(const io::path_t&) +{ +} + void UpdateConfigurationStub::setSkippedReleaseVersion(const std::string&) { } @@ -91,6 +100,11 @@ std::string UpdateConfigurationStub::privacyPolicyUrl() const return ""; } +muse::io::path_t UpdateConfigurationStub::downloadsPath() const +{ + return ""; +} + muse::io::path_t UpdateConfigurationStub::updateDataPath() const { return ""; diff --git a/framework/stubs/update/updateconfigurationstub.h b/framework/stubs/update/updateconfigurationstub.h index e3d3d7f0e5..f700b6550c 100644 --- a/framework/stubs/update/updateconfigurationstub.h +++ b/framework/stubs/update/updateconfigurationstub.h @@ -40,6 +40,9 @@ class UpdateConfigurationStub : public IUpdateConfiguration std::string skippedReleaseVersion() const override; void setSkippedReleaseVersion(const std::string& version) override; + io::path_t lastDownloadedPackagePath() const override; + void setLastDownloadedPackagePath(const io::path_t& path) override; + bool checkForUpdateTestMode() const override; std::string checkForAppUpdateUrl() const override; @@ -51,6 +54,7 @@ class UpdateConfigurationStub : public IUpdateConfiguration std::string privacyPolicyUrl() const override; io::path_t updateDataPath() const override; + io::path_t downloadsPath() const override; io::path_t updateRequestHistoryJsonPath() const override; }; } diff --git a/framework/update/iappupdatescenario.h b/framework/update/iappupdatescenario.h index 0ea40da9aa..c02738e71a 100644 --- a/framework/update/iappupdatescenario.h +++ b/framework/update/iappupdatescenario.h @@ -39,18 +39,7 @@ class IAppUpdateScenario : MODULE_CONTEXT_INTERFACE virtual bool needCheckForUpdate() const = 0; virtual void checkForUpdate(bool manual) = 0; - virtual bool checkInProgress() const = 0; - virtual async::Notification checkInProgressChanged() const = 0; - virtual bool hasUpdate() const = 0; - virtual async::Promise showUpdate() = 0; - - //! Whether an available update can be installed in-place automatically. - virtual bool canAutoInstall() const = 0; - - //! Silently download the available update; on completion a "ready" update - //! becomes available (see hasReadyUpdate()). - virtual void downloadUpdateInBackground() = 0; //! A downloaded update is ready to be installed in-place. virtual bool hasReadyUpdate() const = 0; diff --git a/framework/update/internal/appupdatescenario.cpp b/framework/update/internal/appupdatescenario.cpp index a6aeb1f4c0..9ed25a170d 100644 --- a/framework/update/internal/appupdatescenario.cpp +++ b/framework/update/internal/appupdatescenario.cpp @@ -28,7 +28,6 @@ #include "types/val.h" #include "translation.h" -#include "defer.h" #include "log.h" using namespace muse; @@ -48,7 +47,6 @@ void AppUpdateScenario::checkForUpdate(bool manual) } m_checkInProgress = true; - m_checkInProgressChanged.notify(); service()->checkForUpdate().onResolve(this, [this, manual](const RetVal& res) { const bool noUpdate = res.ret.code() == static_cast(Err::NoUpdate); @@ -61,23 +59,16 @@ void AppUpdateScenario::checkForUpdate(bool manual) } else { showReleaseInfo(res.val); } - } else if (!noUpdate) { + } else if (!res.ret && !noUpdate) { LOGE() << res.ret.toString(); } m_checkInProgress = false; - m_checkInProgressChanged.notify(); - }); -} -bool AppUpdateScenario::checkInProgress() const -{ - return m_checkInProgress; -} - -async::Notification AppUpdateScenario::checkInProgressChanged() const -{ - return m_checkInProgressChanged; + if (!manual && res.ret) { + downloadUpdateInBackground(); + } + }); } bool AppUpdateScenario::hasUpdate() const @@ -98,17 +89,6 @@ bool AppUpdateScenario::hasUpdate() const return !shouldIgnoreUpdate(lastCheckResult.val); } -Promise AppUpdateScenario::showUpdate() -{ - const RetVal& lastCheckResult = service()->lastCheckResult(); - if (lastCheckResult.ret) { - return showReleaseInfo(lastCheckResult.val); - } - return async::make_promise([lastCheckResult](auto resolve, auto) { - return resolve(lastCheckResult.ret); - }); -} - Promise AppUpdateScenario::processUpdateError(int errorCode) { const auto unknownError = async::make_promise([](auto resolve, auto) { @@ -186,12 +166,15 @@ Promise AppUpdateScenario::showServerErrorMsg() Promise AppUpdateScenario::downloadRelease() { - RetVal rv = interactive()->openSync("muse://update/app?mode=download"); - if (!rv.ret) { - return processUpdateError(rv.ret.code()); - } + io::path_t packagePath = service()->downloadedReleasePath(); - const io::path_t packagePath = rv.val.toString(); + if (packagePath.empty()) { + RetVal rv = interactive()->openSync("muse://update/app?mode=download"); + if (!rv.ret) { + return processUpdateError(rv.ret.code()); + } + 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. @@ -237,7 +220,7 @@ Promise AppUpdateScenario::askToRestartAndInstall(const io::path_t& package }); } -Promise AppUpdateScenario::askToCloseAppAndCompleteInstall(const io::path_t& installerPath) +Promise AppUpdateScenario::askToCloseAppAndCompleteInstall(const io::path_t& packagePath) { 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.") @@ -249,16 +232,16 @@ Promise AppUpdateScenario::askToCloseAppAndCompleteInstall(const io::path_t }; return interactive()->info("", info, buttons, closeBtn) - .then(this, [this, installerPath](const IInteractive::Result& res, auto resolve) { + .then(this, [this, packagePath](const IInteractive::Result& res, auto resolve) { if (res.isButton(IInteractive::Button::Cancel)) { return resolve(muse::make_ret(Ret::Code::Cancel)); } if (multiwindowsProvider()->windowCount() != 1) { - multiwindowsProvider()->quitAllAndRunInstallation(installerPath); + multiwindowsProvider()->quitAllAndRunInstallation(packagePath); } - dispatcher()->dispatch("quit", ActionData::make_arg2(false, installerPath.toStdString())); + dispatcher()->dispatch("quit", ActionData::make_arg2(false, packagePath.toStdString())); return resolve(muse::make_ok()); }); } @@ -268,18 +251,13 @@ bool AppUpdateScenario::shouldIgnoreUpdate(const ReleaseInfo& info) const return info.version == configuration()->skippedReleaseVersion() && !configuration()->checkForUpdateTestMode(); } -bool AppUpdateScenario::canAutoInstall() const -{ - return service()->canAutoInstall(); -} - void AppUpdateScenario::downloadUpdateInBackground() { if (m_bgDownloadInProgress || hasReadyUpdate()) { return; } - if (!hasUpdate() || !service()->canAutoInstall()) { + if (!hasUpdate() || !configuration()->autoInstallEnabled()) { return; } @@ -300,7 +278,7 @@ void AppUpdateScenario::downloadUpdateInBackground() m_bgDownloadInProgress = true; - progress.val.progressChanged().onReceive(this, [this](int64_t current, int64_t total, const std::string& msg) { + progress.val.progressChanged().onReceive(this, [](int64_t current, int64_t total, const std::string& msg) { LOGE() << "progress: " << current << " / " << total << " " << msg; }); @@ -362,6 +340,11 @@ void AppUpdateScenario::installReadyUpdate() return; } + if (!service()->canAutoInstall() || multiwindowsProvider()->windowCount() != 1) { + askToCloseAppAndCompleteInstall(m_readyPackagePath).onResolve(this, [](const Ret&) {}); + return; + } + const Ret ret = service()->applyUpdate(m_readyPackagePath); if (!ret) { LOGE() << "failed to apply update in-place, falling back to manual install: " << ret.toString(); diff --git a/framework/update/internal/appupdatescenario.h b/framework/update/internal/appupdatescenario.h index b3170b7c16..d2e1de1780 100644 --- a/framework/update/internal/appupdatescenario.h +++ b/framework/update/internal/appupdatescenario.h @@ -50,14 +50,7 @@ class AppUpdateScenario : public IAppUpdateScenario, public Contextable, public bool needCheckForUpdate() const override; void checkForUpdate(bool manual) override; - bool checkInProgress() const override; - async::Notification checkInProgressChanged() const override; - bool hasUpdate() const override; - muse::async::Promise showUpdate() override; // NOTE: Resolves to "OK" if the user wants to close and complete install of update... - - bool canAutoInstall() const override; - void downloadUpdateInBackground() override; bool hasReadyUpdate() const override; async::Notification hasReadyUpdateChanged() const override; @@ -72,6 +65,8 @@ class AppUpdateScenario : public IAppUpdateScenario, public Contextable, public muse::async::Promise showReleaseInfo(const ReleaseInfo& info); async::Promise showServerErrorMsg(); + void downloadUpdateInBackground(); + muse::async::Promise downloadRelease(); muse::async::Promise askToCloseAppAndCompleteInstall(const io::path_t& installerPath); muse::async::Promise askToRestartAndInstall(const io::path_t& packagePath); @@ -79,7 +74,6 @@ class AppUpdateScenario : public IAppUpdateScenario, public Contextable, public bool shouldIgnoreUpdate(const ReleaseInfo& info) const; bool m_checkInProgress = false; - async::Notification m_checkInProgressChanged; bool m_bgDownloadInProgress = false; io::path_t m_readyPackagePath; diff --git a/framework/update/internal/appupdateservice.cpp b/framework/update/internal/appupdateservice.cpp index 45648ebe5a..4e709bce30 100644 --- a/framework/update/internal/appupdateservice.cpp +++ b/framework/update/internal/appupdateservice.cpp @@ -225,6 +225,10 @@ const RetVal& AppUpdateService::lastCheckResult() const RetVal AppUpdateService::downloadRelease() { + if (m_downloadInProgress) { + return RetVal::make_ok(m_updateProgress); + } + if (!m_networkManager) { m_networkManager = networkManagerCreator()->makeNetworkManager(); } @@ -232,10 +236,12 @@ RetVal AppUpdateService::downloadRelease() const ReleaseInfo info = m_lastCheckResult.val; const QUrl fileUrl = QUrl::fromUserInput(QString::fromStdString(info.fileUrl)); - const path_t finalPath = configuration()->updateDataPath() + "/" + info.fileName; + const path_t finalPath = packagesDir() + "/" + info.fileName; const path_t partialPath = finalPath + PARTIAL_SUFFIX; fileSystem()->makePath(muse::io::absoluteDirpath(partialPath)); + configuration()->setLastDownloadedPackagePath(finalPath); + //! NOTE: Resume an interrupted download by appending to the partial file and //! requesting the remaining bytes via a Range header. uint64_t offset = 0; @@ -264,13 +270,15 @@ RetVal AppUpdateService::downloadRelease() Progress mutProgress = downloadProgress.val; mutProgress.cancel(); m_updateProgress.canceled().disconnect(this); - }); + }, 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); - }); + }, 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_updateProgress.finish(ProgressResult::make_ret(res.ret)); @@ -297,8 +305,9 @@ RetVal AppUpdateService::downloadRelease() } m_updateProgress.finish(ProgressResult::make_ok(Val(finalPath))); - }); + }, Asyncable::Mode::SetReplace); + m_downloadInProgress = true; return RetVal::make_ok(m_updateProgress); } @@ -394,8 +403,8 @@ std::vector AppUpdateService::platformFileSuffixes() const switch (systemInfo()->productType()) { case ISystemInfo::ProductType::Windows: return { "msi" }; case ISystemInfo::ProductType::MacOS: - // Prefer the zip bundle for in-place auto-install, falling back to the - // dmg for the manual flow (and for releases that ship only a dmg). + // In-place auto-install works with the zip bundle only, the manual + // flow hands the user a dmg. if (canAutoInstall()) { return { "zip", "dmg" }; } @@ -523,14 +532,15 @@ void AppUpdateService::clear() void AppUpdateService::cleanupStalePackages(const std::string& keepFileName) { - const io::path_t dir = configuration()->updateDataPath(); - if (!fileSystem()->exists(dir)) { - return; + const io::path_t recorded = configuration()->lastDownloadedPackagePath(); + if (!recorded.empty() && io::filename(recorded).toStdString() != keepFileName) { + fileSystem()->remove(recorded); + fileSystem()->remove(recorded + PARTIAL_SUFFIX); + configuration()->setLastDownloadedPackagePath(io::path_t()); } - //! NOTE: No relevant package to keep -> drop everything. - if (keepFileName.empty()) { - fileSystem()->remove(dir); + const io::path_t dir = configuration()->updateDataPath(); + if (!fileSystem()->exists(dir)) { return; } @@ -567,10 +577,15 @@ io::path_t AppUpdateService::downloadedReleasePath() const return {}; } - const io::path_t path = configuration()->updateDataPath() + "/" + fileName; + const io::path_t path = packagesDir() + "/" + fileName; if (!fileSystem()->exists(path)) { return {}; } return path; } + +io::path_t AppUpdateService::packagesDir() const +{ + return canAutoInstall() ? configuration()->updateDataPath() : configuration()->downloadsPath(); +} diff --git a/framework/update/internal/appupdateservice.h b/framework/update/internal/appupdateservice.h index 6e874f9460..4904360ce5 100644 --- a/framework/update/internal/appupdateservice.h +++ b/framework/update/internal/appupdateservice.h @@ -84,6 +84,7 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as QJsonObject resolveReleaseAsset(const QJsonObject& release) const; void cleanupStalePackages(const std::string& keepFileName); + muse::io::path_t packagesDir() const; using PrevReleaseNotesCallback = std::function; void downloadPreviousReleasesNotes(const Version& updateVersion, const PrevReleaseNotesCallback& finished); @@ -93,5 +94,6 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as RetVal m_lastCheckResult; network::INetworkManagerPtr m_networkManager; Progress m_updateProgress; + bool m_downloadInProgress = false; }; } diff --git a/framework/update/internal/platform/mac/macupdateinstaller.cpp b/framework/update/internal/platform/mac/macupdateinstaller.cpp index 2cad3854e3..62866b62ff 100644 --- a/framework/update/internal/platform/mac/macupdateinstaller.cpp +++ b/framework/update/internal/platform/mac/macupdateinstaller.cpp @@ -66,7 +66,8 @@ bool MacUpdateInstaller::isInPlaceUpdateSupported() const // In-place replacement only works if we can write the bundle without // privilege escalation. - if (::access(bundlePath.toUtf8().constData(), W_OK) != 0) { + if (::access(bundlePath.toUtf8().constData(), W_OK) != 0 + && ::access(io::dirpath(bundlePath.toStdString()).toStdString().c_str(), W_OK) != 0) { return false; } diff --git a/framework/update/internal/updateconfiguration.cpp b/framework/update/internal/updateconfiguration.cpp index 64524213b9..cee764fc8a 100644 --- a/framework/update/internal/updateconfiguration.cpp +++ b/framework/update/internal/updateconfiguration.cpp @@ -34,6 +34,7 @@ static const Settings::Key CHECK_FOR_UPDATE_KEY(module_name, "application/checkF static const Settings::Key CHECK_FOR_UPDATE_TEST_MODE_KEY(module_name, "application/checkForUpdateTestMode"); static const Settings::Key ALLOW_UPDATE_ON_PRERELEASE(module_name, "application/allowUpdateOnPreRelease"); static const Settings::Key SKIPPED_VERSION_KEY(module_name, "application/skippedVersion"); +static const Settings::Key LAST_DOWNLOADED_PACKAGE_KEY(module_name, "application/lastDownloadedPackage"); static const Settings::Key AUTO_INSTALL_KEY(module_name, "application/autoInstall"); void UpdateConfiguration::init() @@ -108,6 +109,16 @@ void UpdateConfiguration::setSkippedReleaseVersion(const std::string& version) settings()->setSharedValue(SKIPPED_VERSION_KEY, Val(version)); } +muse::io::path_t UpdateConfiguration::lastDownloadedPackagePath() const +{ + return settings()->value(LAST_DOWNLOADED_PACKAGE_KEY).toPath(); +} + +void UpdateConfiguration::setLastDownloadedPackagePath(const muse::io::path_t& path) +{ + settings()->setSharedValue(LAST_DOWNLOADED_PACKAGE_KEY, Val(path)); +} + bool UpdateConfiguration::checkForUpdateTestMode() const { return settings()->value(CHECK_FOR_UPDATE_TEST_MODE_KEY).toBool(); @@ -147,6 +158,11 @@ muse::io::path_t UpdateConfiguration::updateDataPath() const return globalConfiguration()->userAppDataPath() + "/update"; } +muse::io::path_t UpdateConfiguration::downloadsPath() const +{ + return globalConfiguration()->downloadsPath(); +} + muse::io::path_t UpdateConfiguration::updateRequestHistoryJsonPath() const { return globalConfiguration()->userAppDataPath() + "/update_request_history.json"; diff --git a/framework/update/internal/updateconfiguration.h b/framework/update/internal/updateconfiguration.h index 8bdce200d6..d018789000 100644 --- a/framework/update/internal/updateconfiguration.h +++ b/framework/update/internal/updateconfiguration.h @@ -58,6 +58,9 @@ class UpdateConfiguration : public IUpdateConfiguration, public Contextable, pub std::string skippedReleaseVersion() const override; void setSkippedReleaseVersion(const std::string& version) override; + muse::io::path_t lastDownloadedPackagePath() const override; + void setLastDownloadedPackagePath(const muse::io::path_t& path) override; + bool checkForUpdateTestMode() const override; std::string checkForAppUpdateUrl() const override; @@ -69,6 +72,7 @@ class UpdateConfiguration : public IUpdateConfiguration, public Contextable, pub std::string privacyPolicyUrl() const override; muse::io::path_t updateDataPath() const override; + muse::io::path_t downloadsPath() const override; muse::io::path_t updateRequestHistoryJsonPath() const override; private: diff --git a/framework/update/iupdateconfiguration.h b/framework/update/iupdateconfiguration.h index 5e80d5d3e5..20ba788848 100644 --- a/framework/update/iupdateconfiguration.h +++ b/framework/update/iupdateconfiguration.h @@ -53,6 +53,9 @@ class IUpdateConfiguration : MODULE_GLOBAL_INTERFACE virtual std::string skippedReleaseVersion() const = 0; virtual void setSkippedReleaseVersion(const std::string& version) = 0; + virtual muse::io::path_t lastDownloadedPackagePath() const = 0; + virtual void setLastDownloadedPackagePath(const muse::io::path_t& path) = 0; + virtual bool checkForUpdateTestMode() const = 0; virtual std::string checkForAppUpdateUrl() const = 0; @@ -64,6 +67,7 @@ class IUpdateConfiguration : MODULE_GLOBAL_INTERFACE virtual std::string privacyPolicyUrl() const = 0; virtual muse::io::path_t updateDataPath() const = 0; + virtual muse::io::path_t downloadsPath() const = 0; virtual muse::io::path_t updateRequestHistoryJsonPath() const = 0; }; } diff --git a/framework/update/tests/appupdateservice_tests.cpp b/framework/update/tests/appupdateservice_tests.cpp index 73a5e8850f..91273ae572 100644 --- a/framework/update/tests/appupdateservice_tests.cpp +++ b/framework/update/tests/appupdateservice_tests.cpp @@ -93,6 +93,7 @@ class AppUpdateServiceTests : public ::testing::Test, public ::async::Asyncable "\"tag_name\": \"v1000.0\"," "\"assets\": [" "{ \"name\": \"MuseScore.dmg\", \"browser_download_url\": \"blabla\" }," + "{ \"name\": \"MuseScore.zip\", \"browser_download_url\": \"blabla\" }," "{ \"name\": \"MuseScore.msi\", \"browser_download_url\": \"blabla\" }," "{ \"name\": \"MuseScore.AppImage\", \"browser_download_url\": \"blabla\" }" "]," @@ -153,6 +154,9 @@ class AppUpdateServiceTests : public ::testing::Test, public ::async::Asyncable ON_CALL(*m_configuration, updateDataPath()) .WillByDefault(Return(io::path_t(dataPath))); + ON_CALL(*m_configuration, downloadsPath()) + .WillByDefault(Return(io::path_t(dataPath))); + ON_CALL(*m_fileSystem, makePath(_)) .WillByDefault(Return(muse::make_ok())); } @@ -467,6 +471,40 @@ TEST_F(AppUpdateServiceTests, DownloadRelease_Success_PromotesPartialToFinal) m_downloadProgress.finish(res); } +TEST_F(AppUpdateServiceTests, DownloadRelease_AlreadyInProgress_AttachesToIt) +{ + //! [GIVEN] A download is already running + givenAvailableRelease(); + ON_CALL(*m_fileSystem, exists(_)) + .WillByDefault(Return(Ret(false))); + + //! [THEN] Only one network request is made + EXPECT_CALL(*m_networkManager, get(_, _, _)) + .WillOnce(testing::Invoke([this](const QUrl&, IncomingDevicePtr, const RequestHeaders&) { + return RetVal::make_ok(m_downloadProgress); + })); + + RetVal first = m_service->downloadRelease(); + EXPECT_TRUE(first.ret); + + //! [WHEN] A second download is requested while the first is in progress + RetVal second = m_service->downloadRelease(); + + //! [THEN] The caller is attached to the running download instead + EXPECT_TRUE(second.ret); + + //! [WHEN] The download finishes, a new one may be started again + m_downloadProgress.finish(ProgressResult::make_ret(muse::make_ret(muse::Ret::Code::Cancel))); + + EXPECT_CALL(*m_networkManager, get(_, _, _)) + .WillOnce(testing::Invoke([this](const QUrl&, IncomingDevicePtr, const RequestHeaders&) { + return RetVal::make_ok(m_downloadProgress); + })); + + RetVal third = m_service->downloadRelease(); + EXPECT_TRUE(third.ret); +} + TEST_F(AppUpdateServiceTests, DownloadRelease_RangeNotHonoured_DiscardsPartial) { //! [GIVEN] A resume attempt (partial on disk -> Range requested) diff --git a/framework/update/tests/mocks/updateconfigurationmock.h b/framework/update/tests/mocks/updateconfigurationmock.h index 470a38e32f..f8c9e2bb76 100644 --- a/framework/update/tests/mocks/updateconfigurationmock.h +++ b/framework/update/tests/mocks/updateconfigurationmock.h @@ -45,6 +45,9 @@ class UpdateConfigurationMock : public IUpdateConfiguration MOCK_METHOD(std::string, skippedReleaseVersion, (), (const, override)); MOCK_METHOD(void, setSkippedReleaseVersion, (const std::string&), (override)); + MOCK_METHOD(muse::io::path_t, lastDownloadedPackagePath, (), (const, override)); + MOCK_METHOD(void, setLastDownloadedPackagePath, (const muse::io::path_t&), (override)); + MOCK_METHOD(bool, checkForUpdateTestMode, (), (const, override)); MOCK_METHOD(std::string, checkForAppUpdateUrl, (), (const, override)); @@ -56,6 +59,7 @@ class UpdateConfigurationMock : public IUpdateConfiguration MOCK_METHOD(std::string, privacyPolicyUrl, (), (const, override)); MOCK_METHOD(muse::io::path_t, updateDataPath, (), (const, override)); + MOCK_METHOD(muse::io::path_t, downloadsPath, (), (const, override)); MOCK_METHOD(muse::io::path_t, updateRequestHistoryJsonPath, (), (const, override)); }; } From 3e76731aa351227385a5e8a245149f292d2d0194 Mon Sep 17 00:00:00 2001 From: Eism Date: Mon, 17 Aug 2026 13:00:35 +0300 Subject: [PATCH 08/13] added UI for install helper on Windows --- framework/update/helper/CMakeLists.txt | 5 + framework/update/helper/platform_win.cpp | 8 +- framework/update/helper/updatetask_win.cpp | 419 ++++++++++-- framework/update/helper/updateui_win.cpp | 642 ++++++++++++++++++ framework/update/helper/updateui_win.h | 50 ++ .../update/internal/appupdateservice.cpp | 29 +- framework/update/internal/appupdateservice.h | 4 + .../platform/linux/linuxupdateinstaller.cpp | 2 +- .../platform/linux/linuxupdateinstaller.h | 2 +- .../platform/mac/macupdateinstaller.cpp | 2 +- .../platform/mac/macupdateinstaller.h | 2 +- .../platform/stub/updateinstallerstub.cpp | 2 +- .../platform/stub/updateinstallerstub.h | 2 +- .../platform/win/winupdateinstaller.cpp | 8 +- .../platform/win/winupdateinstaller.h | 2 +- .../internal/platform/win/winupdateshared.h | 82 +++ framework/update/iupdateinstaller.h | 6 +- framework/update/updatetypes.h | 8 + 18 files changed, 1223 insertions(+), 52 deletions(-) create mode 100644 framework/update/helper/updateui_win.cpp create mode 100644 framework/update/helper/updateui_win.h diff --git a/framework/update/helper/CMakeLists.txt b/framework/update/helper/CMakeLists.txt index e259bbf400..3a25d13a0b 100644 --- a/framework/update/helper/CMakeLists.txt +++ b/framework/update/helper/CMakeLists.txt @@ -30,6 +30,8 @@ if (OS_IS_WIN) platform_win.cpp updatetask_win.cpp updatetask_win.h + updateui_win.cpp + updateui_win.h ../internal/platform/win/winupdateshared.h ) else() @@ -74,6 +76,9 @@ if (OS_IS_WIN) crypt32 # CertGetNameStringW userenv # CreateEnvironmentBlock wtsapi32 # WTSQueryUserToken + msi # MsiInstallProductW and the external UI handler + user32 # the progress window + gdi32 # and what it is drawn with ) endif() diff --git a/framework/update/helper/platform_win.cpp b/framework/update/helper/platform_win.cpp index 8e0494defb..b539752880 100644 --- a/framework/update/helper/platform_win.cpp +++ b/framework/update/helper/platform_win.cpp @@ -25,14 +25,16 @@ #include namespace platform { -void waitForProcessExit(long long pid, int timeoutMs) +bool waitForProcessExit(long long pid, int timeoutMs) { HANDLE hProc = OpenProcess(SYNCHRONIZE, FALSE, static_cast(pid)); if (!hProc) { // Already gone, or no access. - return; + return true; } - WaitForSingleObject(hProc, static_cast(timeoutMs)); + const DWORD result = WaitForSingleObject(hProc, static_cast(timeoutMs)); CloseHandle(hProc); + + return result == WAIT_OBJECT_0; } } diff --git a/framework/update/helper/updatetask_win.cpp b/framework/update/helper/updatetask_win.cpp index f717e2c043..43a7e76e91 100644 --- a/framework/update/helper/updatetask_win.cpp +++ b/framework/update/helper/updatetask_win.cpp @@ -29,12 +29,15 @@ #include #include #include +#include +#include #include #include #include #include "platform.h" +#include "updateui_win.h" #include "../internal/platform/win/winupdateshared.h" @@ -149,16 +152,6 @@ std::wstring modulePath() } } -std::wstring systemDirPath() -{ - wchar_t buffer[MAX_PATH] = { 0 }; - const UINT size = ::GetSystemDirectoryW(buffer, MAX_PATH); - if (size == 0 || size >= MAX_PATH) { - return L"C:\\Windows\\System32"; - } - return std::wstring(buffer, size); -} - bool makeDirectories(const std::wstring& path) { if (path.empty()) { @@ -373,11 +366,30 @@ bool startProcessDetached(const std::wstring& application, const std::wstring& c return true; } -//! We run as SYSTEM; starting the application directly would give it SYSTEM -//! privileges too. Launch it with the token of the interactive user instead. -bool relaunchInUserSession(const std::wstring& application) +//! The session the application asking for the update is running in - the one +//! its user is looking at, which is not necessarily the console session when +//! more than one is logged on. Has to be asked while that process is still +//! alive; falls back to the console session once it is gone. +DWORD userSessionId(unsigned long long pid) +{ + DWORD sessionId = 0; + if (pid > 0 && ::ProcessIdToSessionId(static_cast(pid), &sessionId)) { + return sessionId; + } + + return ::WTSGetActiveConsoleSessionId(); +} + +//! We run as SYSTEM, in session 0, where nothing we start would be visible and +//! anything we start would have our privileges. Starts `application` as the +//! user of `sessionId` instead, optionally passing `arguments` and inheriting +//! the handles of this process. +//! +//! `process` receives the handle of the started process when given; the caller +//! then owns it. +bool startInUserSession(const std::wstring& application, const std::wstring& arguments, DWORD sessionId, + bool inheritHandles = false, HANDLE* process = nullptr) { - const DWORD sessionId = ::WTSGetActiveConsoleSessionId(); if (sessionId == 0xFFFFFFFF) { return false; } @@ -397,6 +409,10 @@ bool relaunchInUserSession(const std::wstring& application) const BOOL hasEnvironment = ::CreateEnvironmentBlock(&environment, primaryToken, FALSE); std::wstring commandLine = quoted(application); + if (!arguments.empty()) { + commandLine += L" " + arguments; + } + std::vector mutableCommandLine(commandLine.begin(), commandLine.end()); mutableCommandLine.push_back(L'\0'); @@ -409,14 +425,19 @@ bool relaunchInUserSession(const std::wstring& application) PROCESS_INFORMATION processInfo = { }; const BOOL ok = ::CreateProcessAsUserW(primaryToken, application.c_str(), mutableCommandLine.data(), - nullptr, nullptr, FALSE, + nullptr, nullptr, inheritHandles ? TRUE : FALSE, CREATE_UNICODE_ENVIRONMENT | NORMAL_PRIORITY_CLASS, hasEnvironment ? environment : nullptr, workingDir.empty() ? nullptr : workingDir.c_str(), &startupInfo, &processInfo); if (ok) { ::CloseHandle(processInfo.hThread); - ::CloseHandle(processInfo.hProcess); + + if (process) { + *process = processInfo.hProcess; + } else { + ::CloseHandle(processInfo.hProcess); + } } if (hasEnvironment) { @@ -428,6 +449,143 @@ bool relaunchInUserSession(const std::wstring& application) return ok == TRUE; } +// ============================================================================ +// The progress window +// ============================================================================ + +//! The half of the progress window that lives on this side: it starts the +//! window as the user - we could not show one ourselves from session 0 - and +//! drives it over a pipe. +//! +//! Nothing here is allowed to fail the update. An installation nobody can watch +//! is worse than one nobody can watch happening. +class ProgressUi +{ +public: + ~ProgressUi() + { + stop(); + } + + void start(const shared::UpdateUi& ui, DWORD sessionId) + { + if (!ui.isValid()) { + return; + } + + SECURITY_ATTRIBUTES attributes = { }; + attributes.nLength = sizeof(attributes); + attributes.bInheritHandle = TRUE; + + HANDLE readEnd = nullptr; + HANDLE writeEnd = nullptr; + if (!::CreatePipe(&readEnd, &writeEnd, &attributes, 64 * 1024)) { + logLine(L"progress-ui: failed to create the pipe"); + return; + } + + // Only the read end is the window's to inherit; a write end it could + // hold open would keep us from ever ending it by closing ours. + ::SetHandleInformation(writeEnd, HANDLE_FLAG_INHERIT, 0); + + const std::wstring arguments = L"--ui --pipe " + + std::to_wstring(reinterpret_cast(readEnd)); + + HANDLE process = nullptr; + const bool started = startInUserSession(modulePath(), arguments, sessionId, /*inheritHandles*/ true, &process); + + ::CloseHandle(readEnd); + + if (!started) { + logLine(L"progress-ui: failed to start the window in session " + std::to_wstring(sessionId)); + ::CloseHandle(writeEnd); + return; + } + + m_write = writeEnd; + m_process = process; + + // Whatever the application did not send keeps the default of the + // window rather than being sent as an empty value. + auto sendIfSet = [this](const char* command, const std::wstring& value) { + if (!value.empty()) { + send(command, shared::wideToUtf8(value)); + } + }; + + sendIfSet(updateui::command::TITLE, ui.title); + sendIfSet(updateui::command::MESSAGE, ui.message); + sendIfSet(updateui::command::BACKGROUND, ui.backgroundColor); + sendIfSet(updateui::command::ACCENT, ui.accentColor); + sendIfSet(updateui::command::FOREGROUND, ui.foregroundColor); + + send(updateui::command::SHOW, std::string()); + } + + //! Percentages of the installation itself; a negative value shows that + //! something is going on without saying how far along it is. + void setPercent(int percent) + { + if (percent > 100) { + percent = 100; + } + + if (percent == m_percent) { + return; + } + + m_percent = percent; + send(updateui::command::PROGRESS, std::to_string(percent)); + } + + void stop() + { + //! NOTE: The pipe may already be closed - the window can be closed by + //! hand - which is no reason to leave the process itself behind. + if (m_write != INVALID_HANDLE_VALUE) { + send(updateui::command::CLOSE, std::string()); + + ::CloseHandle(m_write); + m_write = INVALID_HANDLE_VALUE; + } + + if (m_process) { + // The window must not outlive the installation it is reporting on. + if (::WaitForSingleObject(m_process, 5000) != WAIT_OBJECT_0) { + ::TerminateProcess(m_process, 0); + } + + ::CloseHandle(m_process); + m_process = nullptr; + } + } + +private: + void send(const char* command, const std::string& argument) + { + if (m_write == INVALID_HANDLE_VALUE) { + return; + } + + std::string line = command; + if (!argument.empty()) { + line += " " + argument; + } + line += "\n"; + + DWORD written = 0; + if (!::WriteFile(m_write, line.data(), static_cast(line.size()), &written, nullptr)) { + // The window is gone; carry on without it. + ::CloseHandle(m_write); + m_write = INVALID_HANDLE_VALUE; + } + } + + HANDLE m_write = INVALID_HANDLE_VALUE; + HANDLE m_process = nullptr; + int m_percent = -1; +}; + // ============================================================================ // Task Scheduler // ============================================================================ @@ -759,6 +917,151 @@ int unregisterTask(const std::wstring& appId) return 0; } +// ============================================================================ +// Installing an MSI +// ============================================================================ + +//! Where the installation has got to, as the Windows Installer reports it. +//! +//! Progress arrives as ticks rather than as a percentage: the engine says how +//! many it expects in total, then counts them off, and an action that turns out +//! to be longer than costed adds to the total as it goes. +struct MsiProgress { + ProgressUi* ui = nullptr; + + int total = 0; + int done = 0; + + //! Some actions (removing the previous version, mostly) count down from the + //! total instead of up from zero. + bool forward = true; + + //! Actions that report each item they process have a tick value attached + //! once, before the items themselves start arriving. + bool actionDataEnabled = false; + int ticksPerActionData = 0; + + int lastPercent = 0; +}; + +//! A field the engine did not fill in reads as MSI_NULL_INTEGER, which as a +//! tick count would be a very large negative number. +int recordInteger(MSIHANDLE record, unsigned int field) +{ + const int value = ::MsiRecordGetInteger(record, field); + return value == MSI_NULL_INTEGER ? 0 : value; +} + +void reportProgress(MsiProgress& progress) +{ + if (!progress.ui || progress.total <= 0) { + return; + } + + const int done = progress.forward ? progress.done : progress.total - progress.done; + + int percent = static_cast(static_cast(done) * 100 / progress.total); + percent = percent < 0 ? 0 : (percent > 99 ? 99 : percent); + + //! NOTE: A major upgrade is several installations in a row, each with a + //! progress of its own that starts again from nothing. Only ever moving + //! forward is a better picture of what is happening than a bar that starts + //! over twice. + if (percent < progress.lastPercent) { + return; + } + + progress.lastPercent = percent; + progress.ui->setPercent(percent); +} + +INT WINAPI msiUiHandler(LPVOID context, UINT messageType, MSIHANDLE record) +{ + MsiProgress* progress = static_cast(context); + if (!progress) { + return 0; + } + + const INSTALLMESSAGE message = static_cast(0xFF000000 & messageType); + + switch (message) { + case INSTALLMESSAGE_ACTIONSTART: + // Whether the action about to run reports its items is said by the + // action itself, if at all. + progress->actionDataEnabled = false; + return IDOK; + + case INSTALLMESSAGE_ACTIONDATA: + if (progress->actionDataEnabled && progress->ticksPerActionData > 0) { + progress->done += progress->ticksPerActionData; + reportProgress(*progress); + } + return IDOK; + + case INSTALLMESSAGE_PROGRESS: { + if (!record) { + return IDOK; + } + + switch (recordInteger(record, 1)) { + case 0: // reset: a new sequence with a total of its own + progress->total = recordInteger(record, 2); + progress->forward = recordInteger(record, 3) == 0; + progress->done = progress->forward ? 0 : progress->total; + progress->actionDataEnabled = false; + break; + + case 1: // what one item of the current action is worth + progress->ticksPerActionData = recordInteger(record, 2); + progress->actionDataEnabled = recordInteger(record, 3) != 0; + break; + + case 2: // ticks completed + progress->done += progress->forward ? recordInteger(record, 2) : -recordInteger(record, 2); + reportProgress(*progress); + break; + + case 3: // the action turned out to be bigger than costed + progress->total += recordInteger(record, 2); + break; + + default: + break; + } + + return IDOK; + } + + default: + break; + } + + return 0; +} + +//! Installs `package` through the Windows Installer, reporting progress to +//! `ui`. Returns the ERROR_* code msiexec would have exited with - it is the +//! same engine, driven directly so that its progress can be watched. +UINT installMsi(const std::wstring& package, const std::wstring& properties, ProgressUi& ui) +{ + MsiProgress progress; + progress.ui = &ui; + + ::MsiSetInternalUI(INSTALLUILEVEL_NONE, nullptr); + + INSTALLUI_HANDLER_RECORD previousHandler = nullptr; + const DWORD messageFilter = INSTALLLOGMODE_PROGRESS | INSTALLLOGMODE_ACTIONSTART | INSTALLLOGMODE_ACTIONDATA + | INSTALLLOGMODE_FATALEXIT | INSTALLLOGMODE_ERROR; + + ::MsiSetExternalUIRecord(msiUiHandler, messageFilter, &progress, &previousHandler); + + const UINT result = ::MsiInstallProductW(package.c_str(), properties.c_str()); + + ::MsiSetExternalUIRecord(previousHandler, 0, nullptr, nullptr); + + return result; +} + // ============================================================================ // Applying an update // ============================================================================ @@ -816,12 +1119,23 @@ int applyRun(const std::wstring& appId) logLine(L"apply-run: request package=" + request.packagePath + L" pid=" + std::to_wstring(request.pid)); - // 1. Let the application finish quitting before touching its files. + //! NOTE: Asked while the application is still running, and so still has a + //! session to be asked about. + const DWORD sessionId = userSessionId(request.pid); + + // 1. Tell the user what is going on. Everything up to the point where the + // engine starts costing the package takes an unknown amount of time, so + // the window shows that something is happening rather than how far along + // it is. + ProgressUi ui; + ui.start(request.ui, sessionId); + + // 2. Let the application finish quitting before touching its files. if (request.pid > 0) { platform::waitForProcessExit(static_cast(request.pid), /*timeoutMs*/ 60000); } - // 2. Copy the package somewhere an unprivileged user cannot reach, so that + // 3. Copy the package somewhere an unprivileged user cannot reach, so that // it cannot be swapped after we have verified it. const std::wstring staged = shared::stagedPackagePath(appId, packageType); @@ -837,7 +1151,7 @@ int applyRun(const std::wstring& appId) return 1; } - // 3. Only now decide whether to trust it. + // 4. Only now decide whether to trust it. auto reject = [&](const std::wstring& reason) { logLine(L"apply-run: " + reason); ::DeleteFileW(staged.c_str()); @@ -864,7 +1178,7 @@ int applyRun(const std::wstring& appId) return 1; } - // 4. Install silently, the way the installer registered. + // 5. Install silently, the way the installer registered. // // The extra arguments come from the registration rather than from here: // an MSI needs its install directory passed as a property, an Inno Setup @@ -872,27 +1186,40 @@ int applyRun(const std::wstring& appId) // relocate an installation the user had put somewhere else. const std::wstring extraArgs = shared::expandInstallArgs(installArgs, trimTrailingSeparators(installDir)); - std::wstring application; - std::wstring commandLine; + DWORD exitCode = 0; if (packageType == shared::PACKAGE_TYPE_MSI) { - application = systemDirPath() + L"\\msiexec.exe"; - commandLine = quoted(application) + L" /i " + quoted(staged) + L" /qn /norestart"; - } else { - application = staged; - commandLine = quoted(staged); - } + //! NOTE: The engine is driven in-process rather than by starting + //! msiexec.exe, which is the same installation either way - but only + //! this way does it report what it is doing, which is what the progress + //! bar shows. `REBOOT=ReallySuppress` is what `/norestart` sets, and + //! the user interface level takes the place of `/qn`. + std::wstring properties = L"REBOOT=ReallySuppress"; + if (!extraArgs.empty()) { + properties += L" " + extraArgs; + } - if (!extraArgs.empty()) { - commandLine += L" " + extraArgs; - } + logLine(L"apply-run: installing " + staged + L" with " + properties); - logLine(L"apply-run: running " + commandLine); + //! NOTE: The window keeps its marquee until the engine has costed the + //! package and says something; an empty bar sitting at nothing for the + //! first few seconds would look stuck. + exitCode = installMsi(staged, properties, ui); + } else { + //! NOTE: A self-contained installer reports nothing we could read, so + //! the window keeps saying only that the installation is under way. + std::wstring commandLine = quoted(staged); + if (!extraArgs.empty()) { + commandLine += L" " + extraArgs; + } - DWORD exitCode = 0; - if (!runProcessAndWait(application, commandLine, exitCode)) { - logLine(L"apply-run: failed to start the installer"); - return 1; + logLine(L"apply-run: running " + commandLine); + + if (!runProcessAndWait(staged, commandLine, exitCode)) { + logLine(L"apply-run: failed to start the installer"); + ui.stop(); + return 1; + } } // ERROR_SUCCESS_REBOOT_INITIATED (1641) and ERROR_SUCCESS_REBOOT_REQUIRED (3010) @@ -901,19 +1228,26 @@ int applyRun(const std::wstring& appId) if (!installed) { logLine(L"apply-run: the installer failed with exit code " + std::to_wstring(exitCode)); ::DeleteFileW(shared::requestFilePath(appId).c_str()); + ui.stop(); return 1; } + ui.setPercent(100); + logLine(L"apply-run: installed successfully, exit code " + std::to_wstring(exitCode)); - // 5. Clean up before relaunching; the request must not survive to be + // 6. Clean up before relaunching; the request must not survive to be // replayed on the next run. ::DeleteFileW(shared::requestFilePath(appId).c_str()); ::DeleteFileW(staged.c_str()); ::DeleteFileW(request.packagePath.c_str()); - // 6. Bring the application back, as the interactive user rather than as us. - if (!relaunchInUserSession(appPath)) { + // 7. Take the window down before the application it was standing in for + // comes back up. + ui.stop(); + + // 8. Bring the application back, as the user rather than as us. + if (!startInUserSession(appPath, std::wstring(), sessionId)) { logLine(L"apply-run: failed to relaunch " + appPath); // The update itself succeeded; the user can start the application manually. } @@ -965,6 +1299,13 @@ int runCommandLine() const std::map parsed = parseArguments(arguments); + //! NOTE: Handled before everything else: this is the one command that does + //! not run privileged, has no working directory to secure and no business + //! writing to a log only SYSTEM can open. + if (parsed.count(L"--ui") > 0) { + return updateui::run(valueOf(parsed, L"--pipe")); + } + const bool isRegister = parsed.count(L"--register-task") > 0; const bool isUnregister = parsed.count(L"--unregister-task") > 0; const bool isApply = parsed.count(L"--apply") > 0; diff --git a/framework/update/helper/updateui_win.cpp b/framework/update/helper/updateui_win.cpp new file mode 100644 index 0000000000..68a7c8a0f5 --- /dev/null +++ b/framework/update/helper/updateui_win.cpp @@ -0,0 +1,642 @@ +/* + * 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 "updateui_win.h" + +#ifndef UNICODE +#define UNICODE +#endif +#ifndef _UNICODE +#define _UNICODE +#endif + +#include + +#include +#include + +#include "../internal/platform/win/winupdateshared.h" + +//! Sent to a window whose monitor, or the scaling of it, has changed. Declared +//! here rather than relied on: it only appeared in the Windows 8.1 SDK. +#ifndef WM_DPICHANGED +#define WM_DPICHANGED 0x02E0 +#endif + +namespace shared = muse::update::win; + +namespace { +const wchar_t* WINDOW_CLASS_NAME = L"MuseUpdateProgressWindow"; + +const UINT WM_UI_COMMAND = WM_APP + 1; + +const UINT_PTR MARQUEE_TIMER_ID = 1; +const UINT MARQUEE_INTERVAL_MS = 30; + +//! Sizes in logical pixels at 96 DPI; scaled to the DPI of the window. +const int WINDOW_WIDTH = 460; +const int WINDOW_HEIGHT = 108; +const int MARGIN = 28; +const int MESSAGE_TOP = 30; +const int MESSAGE_HEIGHT = 24; +const int BAR_TOP = 70; +const int BAR_HEIGHT = 6; +const int MESSAGE_POINT_SIZE = 10; + +enum class Command { + Title, + Message, + Background, + Accent, + Foreground, + Show, + Progress, + Close +}; + +struct Window { + HWND handle = nullptr; + UINT dpi = 96; + HFONT font = nullptr; + + std::wstring message; + + COLORREF background = RGB(0x2E, 0x2E, 0x2E); + COLORREF accent = RGB(0x0F, 0x9A, 0xF7); + COLORREF foreground = RGB(0xFF, 0xFF, 0xFF); + + int percent = -1; + int marqueeOffset = 0; +}; + +int scaled(const Window& window, int value) +{ + return ::MulDiv(value, static_cast(window.dpi), 96); +} + +COLORREF blend(COLORREF from, COLORREF to, int percentOfTo) +{ + auto mix = [percentOfTo](int a, int b) { + return (a * (100 - percentOfTo) + b * percentOfTo) / 100; + }; + + return RGB(mix(GetRValue(from), GetRValue(to)), + mix(GetGValue(from), GetGValue(to)), + mix(GetBValue(from), GetBValue(to))); +} + +bool parseColor(const std::string& text, COLORREF& color) +{ + if (text.size() != 7 || text[0] != '#') { + return false; + } + + int components[3] = { 0, 0, 0 }; + + for (int i = 0; i < 3; ++i) { + int value = 0; + for (int j = 0; j < 2; ++j) { + const char c = text[1 + i * 2 + j]; + int digit = 0; + if (c >= '0' && c <= '9') { + digit = c - '0'; + } else if (c >= 'a' && c <= 'f') { + digit = c - 'a' + 10; + } else if (c >= 'A' && c <= 'F') { + digit = c - 'A' + 10; + } else { + return false; + } + value = value * 16 + digit; + } + components[i] = value; + } + + color = RGB(components[0], components[1], components[2]); + return true; +} + +HFONT createFont(UINT dpi) +{ + LOGFONTW logFont = { }; + logFont.lfHeight = -::MulDiv(MESSAGE_POINT_SIZE, static_cast(dpi), 72); + logFont.lfWeight = FW_NORMAL; + logFont.lfCharSet = DEFAULT_CHARSET; + logFont.lfQuality = CLEARTYPE_QUALITY; + ::wcscpy_s(logFont.lfFaceName, L"Segoe UI"); + + return ::CreateFontIndirectW(&logFont); +} + +UINT windowDpi(HWND hwnd) +{ + using GetDpiForWindowFn = UINT (WINAPI*)(HWND); + + if (const HMODULE user32 = ::GetModuleHandleW(L"user32.dll")) { + const auto getDpiForWindow = reinterpret_cast( + reinterpret_cast(::GetProcAddress(user32, "GetDpiForWindow"))); + if (getDpiForWindow) { + const UINT dpi = getDpiForWindow(hwnd); + if (dpi != 0) { + return dpi; + } + } + } + + const HDC screen = ::GetDC(nullptr); + const UINT dpi = screen ? static_cast(::GetDeviceCaps(screen, LOGPIXELSX)) : 96; + if (screen) { + ::ReleaseDC(nullptr, screen); + } + + return dpi != 0 ? dpi : 96; +} + +//! Per-monitor aware, so that the window is drawn at the resolution of the +//! screen it is on rather than stretched by the system. The helper has no +//! manifest of its own to declare this in. +void makeProcessDpiAware() +{ + //! NOTE: The context is passed as a plain handle and the awareness level as + //! its documented value, so that the helper builds against SDKs older than + //! the one that introduced `DPI_AWARENESS_CONTEXT`. + using SetContextFn = BOOL (WINAPI*)(HANDLE); + const HANDLE perMonitorAwareV2 = reinterpret_cast(static_cast(-4)); + + const HMODULE user32 = ::GetModuleHandleW(L"user32.dll"); + if (!user32) { + return; + } + + const auto setContext = reinterpret_cast( + reinterpret_cast(::GetProcAddress(user32, "SetProcessDpiAwarenessContext"))); + if (setContext && setContext(perMonitorAwareV2)) { + return; + } + + ::SetProcessDPIAware(); +} + +void centerOnPrimaryScreen(Window& window) +{ + const int width = scaled(window, WINDOW_WIDTH); + const int height = scaled(window, WINDOW_HEIGHT); + + RECT workArea = { }; + if (!::SystemParametersInfoW(SPI_GETWORKAREA, 0, &workArea, 0)) { + workArea.left = 0; + workArea.top = 0; + workArea.right = ::GetSystemMetrics(SM_CXSCREEN); + workArea.bottom = ::GetSystemMetrics(SM_CYSCREEN); + } + + const int x = workArea.left + ((workArea.right - workArea.left) - width) / 2; + const int y = workArea.top + ((workArea.bottom - workArea.top) - height) / 2; + + ::SetWindowPos(window.handle, HWND_TOPMOST, x, y, width, height, SWP_NOACTIVATE); +} + +void fillRoundedRect(HDC dc, const RECT& rect, COLORREF color) +{ + const int radius = rect.bottom - rect.top; + if (radius <= 0 || rect.right <= rect.left) { + return; + } + + const HBRUSH brush = ::CreateSolidBrush(color); + const HPEN pen = ::CreatePen(PS_SOLID, 1, color); + + const HGDIOBJ oldBrush = ::SelectObject(dc, brush); + const HGDIOBJ oldPen = ::SelectObject(dc, pen); + + ::RoundRect(dc, rect.left, rect.top, rect.right + 1, rect.bottom + 1, radius, radius); + + ::SelectObject(dc, oldBrush); + ::SelectObject(dc, oldPen); + ::DeleteObject(brush); + ::DeleteObject(pen); +} + +void paintProgressBar(HDC dc, Window& window, const RECT& bar) +{ + const int trackWidth = bar.right - bar.left; + if (trackWidth <= 0) { + return; + } + + fillRoundedRect(dc, bar, blend(window.background, window.foreground, 20)); + + RECT fill = bar; + + if (window.percent >= 0) { + const int percent = window.percent > 100 ? 100 : window.percent; + int width = trackWidth * percent / 100; + + // Below the height of the bar the rounded ends would draw as a dot of + // the wrong shape; nothing is a more honest picture of "just started" + // than nothing at all. + const int minimum = bar.bottom - bar.top; + if (width > 0 && width < minimum) { + width = minimum; + } + + if (width <= 0) { + return; + } + + fill.right = bar.left + width; + } else { + const int chunkWidth = trackWidth * 30 / 100; + const int period = trackWidth + chunkWidth; + const int offset = period > 0 ? window.marqueeOffset % period : 0; + + fill.left = bar.left - chunkWidth + offset; + fill.right = fill.left + chunkWidth; + + // The chunk slides in and out of the track rather than being clipped + // into a shrinking block at either end. + ::IntersectClipRect(dc, bar.left, bar.top, bar.right + 1, bar.bottom + 1); + } + + fillRoundedRect(dc, fill, window.accent); + + if (window.percent < 0) { + ::SelectClipRgn(dc, nullptr); + } +} + +void paint(Window& window) +{ + PAINTSTRUCT paintStruct = { }; + const HDC dc = ::BeginPaint(window.handle, &paintStruct); + if (!dc) { + return; + } + + RECT client = { }; + ::GetClientRect(window.handle, &client); + + // Everything is drawn into a bitmap first: the marquee repaints tens of + // times a second and would otherwise flicker. + const HDC memoryDc = ::CreateCompatibleDC(dc); + const HBITMAP bitmap = ::CreateCompatibleBitmap(dc, client.right, client.bottom); + const HGDIOBJ oldBitmap = ::SelectObject(memoryDc, bitmap); + + const HBRUSH backgroundBrush = ::CreateSolidBrush(window.background); + ::FillRect(memoryDc, &client, backgroundBrush); + ::DeleteObject(backgroundBrush); + + // A hairline border, so that the window is still a window against a + // background of the same colour. + const HBRUSH borderBrush = ::CreateSolidBrush(blend(window.background, window.foreground, 18)); + ::FrameRect(memoryDc, &client, borderBrush); + ::DeleteObject(borderBrush); + + if (!window.message.empty()) { + RECT text = { + scaled(window, MARGIN), + scaled(window, MESSAGE_TOP), + client.right - scaled(window, MARGIN), + scaled(window, MESSAGE_TOP) + scaled(window, MESSAGE_HEIGHT) + }; + + const HGDIOBJ oldFont = ::SelectObject(memoryDc, window.font); + ::SetBkMode(memoryDc, TRANSPARENT); + ::SetTextColor(memoryDc, window.foreground); + ::DrawTextW(memoryDc, window.message.c_str(), static_cast(window.message.size()), &text, + DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS | DT_NOPREFIX); + ::SelectObject(memoryDc, oldFont); + } + + const RECT bar = { + scaled(window, MARGIN), + scaled(window, BAR_TOP), + client.right - scaled(window, MARGIN), + scaled(window, BAR_TOP) + scaled(window, BAR_HEIGHT) + }; + paintProgressBar(memoryDc, window, bar); + + ::BitBlt(dc, 0, 0, client.right, client.bottom, memoryDc, 0, 0, SRCCOPY); + + ::SelectObject(memoryDc, oldBitmap); + ::DeleteObject(bitmap); + ::DeleteDC(memoryDc); + + ::EndPaint(window.handle, &paintStruct); +} + +void setPercent(Window& window, int percent) +{ + if (window.percent == percent) { + return; + } + + const bool wasIndeterminate = window.percent < 0; + window.percent = percent; + + if (percent < 0 && !wasIndeterminate) { + ::SetTimer(window.handle, MARQUEE_TIMER_ID, MARQUEE_INTERVAL_MS, nullptr); + } else if (percent >= 0 && wasIndeterminate) { + ::KillTimer(window.handle, MARQUEE_TIMER_ID); + } + + ::InvalidateRect(window.handle, nullptr, FALSE); +} + +void handleCommand(Window& window, Command command, LPARAM payload) +{ + switch (command) { + case Command::Title: { + std::wstring* title = reinterpret_cast(payload); + ::SetWindowTextW(window.handle, title->c_str()); + delete title; + } break; + + case Command::Message: { + std::wstring* message = reinterpret_cast(payload); + window.message = *message; + delete message; + ::InvalidateRect(window.handle, nullptr, FALSE); + } break; + + case Command::Background: + window.background = static_cast(payload); + ::InvalidateRect(window.handle, nullptr, FALSE); + break; + + case Command::Accent: + window.accent = static_cast(payload); + ::InvalidateRect(window.handle, nullptr, FALSE); + break; + + case Command::Foreground: + window.foreground = static_cast(payload); + ::InvalidateRect(window.handle, nullptr, FALSE); + break; + + case Command::Show: + //! NOTE: Shown without activation: the application is quitting at this + //! point and stealing focus from whatever the user turned to instead + //! would be worse than being one window down in the z-order. + ::ShowWindow(window.handle, SW_SHOWNOACTIVATE); + break; + + case Command::Progress: + setPercent(window, static_cast(payload)); + break; + + case Command::Close: + ::DestroyWindow(window.handle); + break; + } +} + +LRESULT CALLBACK windowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) +{ + Window* window = reinterpret_cast(::GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + + switch (message) { + case WM_NCCREATE: { + const CREATESTRUCTW* create = reinterpret_cast(lParam); + ::SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(create->lpCreateParams)); + } break; + + case WM_UI_COMMAND: + if (window) { + handleCommand(*window, static_cast(wParam), lParam); + } + return 0; + + case WM_TIMER: + if (window && wParam == MARQUEE_TIMER_ID) { + // Wrapped well short of overflowing; the position is taken modulo + // the width of the track anyway. + window->marqueeOffset = (window->marqueeOffset + scaled(*window, 12)) % (1 << 20); + ::InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + + case WM_ERASEBKGND: + // Painted in full in WM_PAINT. + return 1; + + case WM_PAINT: + if (window) { + paint(*window); + return 0; + } + break; + + case WM_NCHITTEST: + // The window has no title bar, but should still be possible to drag + // out of the way of whatever it is covering. + return HTCAPTION; + + case WM_DPICHANGED: + if (window) { + window->dpi = HIWORD(wParam); + + if (window->font) { + ::DeleteObject(window->font); + } + window->font = createFont(window->dpi); + + const RECT* suggested = reinterpret_cast(lParam); + ::SetWindowPos(hwnd, nullptr, suggested->left, suggested->top, + suggested->right - suggested->left, suggested->bottom - suggested->top, + SWP_NOZORDER | SWP_NOACTIVATE); + ::InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + + case WM_DESTROY: + ::PostQuitMessage(0); + return 0; + } + + return ::DefWindowProcW(hwnd, message, wParam, lParam); +} + +void postCommand(HWND hwnd, Command command, LPARAM payload) +{ + ::PostMessageW(hwnd, WM_UI_COMMAND, static_cast(command), payload); +} + +void postText(HWND hwnd, Command command, const std::string& value) +{ + postCommand(hwnd, command, reinterpret_cast(new std::wstring(shared::utf8ToWide(value)))); +} + +void postColor(HWND hwnd, Command command, const std::string& value) +{ + COLORREF color = 0; + if (parseColor(value, color)) { + postCommand(hwnd, command, static_cast(color)); + } +} + +void handleLine(HWND hwnd, const std::string& line) +{ + const size_t space = line.find(' '); + const std::string name = line.substr(0, space); + const std::string value = space != std::string::npos ? line.substr(space + 1) : std::string(); + + if (name == updateui::command::TITLE) { + postText(hwnd, Command::Title, value); + } else if (name == updateui::command::MESSAGE) { + postText(hwnd, Command::Message, value); + } else if (name == updateui::command::BACKGROUND) { + postColor(hwnd, Command::Background, value); + } else if (name == updateui::command::ACCENT) { + postColor(hwnd, Command::Accent, value); + } else if (name == updateui::command::FOREGROUND) { + postColor(hwnd, Command::Foreground, value); + } else if (name == updateui::command::SHOW) { + postCommand(hwnd, Command::Show, 0); + } else if (name == updateui::command::PROGRESS) { + int percent = -1; + try { + percent = std::stoi(value); + } catch (...) { + percent = -1; + } + postCommand(hwnd, Command::Progress, static_cast(percent)); + } else if (name == updateui::command::CLOSE) { + postCommand(hwnd, Command::Close, 0); + } +} + +struct ReaderContext { + HWND hwnd = nullptr; + HANDLE pipe = INVALID_HANDLE_VALUE; +}; + +DWORD WINAPI readerThread(LPVOID parameter) +{ + ReaderContext* context = static_cast(parameter); + + std::string buffer; + char chunk[512] = { 0 }; + + for (;;) { + DWORD read = 0; + if (!::ReadFile(context->pipe, chunk, sizeof(chunk), &read, nullptr) || read == 0) { + break; + } + + buffer.append(chunk, read); + + for (size_t end = buffer.find('\n'); end != std::string::npos; end = buffer.find('\n')) { + std::string line = buffer.substr(0, end); + buffer.erase(0, end + 1); + + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + + if (!line.empty()) { + handleLine(context->hwnd, line); + } + } + } + + //! NOTE: The end of the pipe means the privileged side is gone, whether it + //! finished or died; either way there is nothing left to report. + postCommand(context->hwnd, Command::Close, 0); + + delete context; + return 0; +} +} + +namespace updateui { +int run(const std::wstring& pipeHandleValue) +{ + HANDLE pipe = INVALID_HANDLE_VALUE; + try { + pipe = reinterpret_cast(static_cast(std::stoull(pipeHandleValue))); + } catch (...) { + return 1; + } + + makeProcessDpiAware(); + + const HINSTANCE instance = ::GetModuleHandleW(nullptr); + + WNDCLASSEXW windowClass = { }; + windowClass.cbSize = sizeof(windowClass); + windowClass.lpfnWndProc = windowProc; + windowClass.hInstance = instance; + windowClass.hCursor = ::LoadCursorW(nullptr, IDC_ARROW); + windowClass.lpszClassName = WINDOW_CLASS_NAME; + + if (::RegisterClassExW(&windowClass) == 0) { + return 1; + } + + Window window; + + window.handle = ::CreateWindowExW(WS_EX_TOPMOST | WS_EX_APPWINDOW, WINDOW_CLASS_NAME, L"", + WS_POPUP, 0, 0, WINDOW_WIDTH, WINDOW_HEIGHT, + nullptr, nullptr, instance, &window); + if (!window.handle) { + return 1; + } + + window.dpi = windowDpi(window.handle); + window.font = createFont(window.dpi); + centerOnPrimaryScreen(window); + + ::SetTimer(window.handle, MARQUEE_TIMER_ID, MARQUEE_INTERVAL_MS, nullptr); + + ReaderContext* context = new ReaderContext(); + context->hwnd = window.handle; + context->pipe = pipe; + + const HANDLE thread = ::CreateThread(nullptr, 0, readerThread, context, 0, nullptr); + if (!thread) { + delete context; + ::DestroyWindow(window.handle); + return 1; + } + + MSG message = { }; + while (::GetMessageW(&message, nullptr, 0, 0) > 0) { + ::TranslateMessage(&message); + ::DispatchMessageW(&message); + } + + //! NOTE: Normally the reader has already finished - it is what asked the + //! window to close. It is still sitting in ReadFile if the window was + //! closed by hand, and cancelling that is what lets it end. + ::CancelSynchronousIo(thread); + ::CloseHandle(pipe); + ::WaitForSingleObject(thread, 2000); + ::CloseHandle(thread); + + if (window.font) { + ::DeleteObject(window.font); + } + + return 0; +} +} diff --git a/framework/update/helper/updateui_win.h b/framework/update/helper/updateui_win.h new file mode 100644 index 0000000000..e81bf02aeb --- /dev/null +++ b/framework/update/helper/updateui_win.h @@ -0,0 +1,50 @@ +/* + * 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 + +//! NOTE: The progress window shown while the package is installing. +//! +//! It is a separate process rather than a window of the privileged side: the +//! installation runs as SYSTEM from a scheduled task, in session 0, where +//! nothing it draws would ever reach a screen. This half is started by that +//! privileged side in the session of the user who asked for the update, with no +//! privileges of its own, and is driven over a pipe. +namespace updateui { +namespace command { +inline const char* TITLE = "title"; +inline const char* MESSAGE = "message"; +inline const char* BACKGROUND = "background"; +inline const char* ACCENT = "accent"; +inline const char* FOREGROUND = "foreground"; + +inline const char* SHOW = "show"; + +inline const char* PROGRESS = "progress"; + +inline const char* CLOSE = "close"; +} + +int run(const std::wstring& pipeHandleValue); +} diff --git a/framework/update/internal/appupdateservice.cpp b/framework/update/internal/appupdateservice.cpp index 4e709bce30..302c13875a 100644 --- a/framework/update/internal/appupdateservice.cpp +++ b/framework/update/internal/appupdateservice.cpp @@ -23,6 +23,7 @@ #include "appupdateservice.h" #include +#include #include #include #include @@ -462,7 +463,33 @@ bool AppUpdateService::canAutoInstall() const Ret AppUpdateService::applyUpdate(const muse::io::path_t& packagePath) { - return updateInstaller()->applyUpdate(packagePath); + return updateInstaller()->applyUpdate(packagePath, makeInstallProgressUi()); +} + +InstallProgressUi AppUpdateService::makeInstallProgressUi() const +{ + const QString appName = application()->title().toQString(); + const QString version = QString::fromStdString(m_lastCheckResult.val.version); + + InstallProgressUi progressUi; + progressUi.title = appName.toStdString(); + + progressUi.message = version.isEmpty() + ? muse::qtrc("update", "Installing %1").arg(appName).toStdString() + : muse::qtrc("update", "Installing %1 %2").arg(appName, version).toStdString(); + + const muse::ui::ThemeInfo& theme = uiConfiguration()->currentTheme(); + + auto themeColor = [&theme](muse::ui::ThemeStyleKey key) { + const QColor color = theme.values.value(key).value(); + return color.isValid() ? color.name(QColor::HexRgb).toStdString() : std::string(); + }; + + progressUi.backgroundColor = themeColor(muse::ui::BACKGROUND_PRIMARY_COLOR); + progressUi.accentColor = themeColor(muse::ui::ACCENT_COLOR); + progressUi.textColor = themeColor(muse::ui::FONT_PRIMARY_COLOR); + + return progressUi; } void AppUpdateService::downloadPreviousReleasesNotes(const Version& updateVersion, const PrevReleaseNotesCallback& finished) diff --git a/framework/update/internal/appupdateservice.h b/framework/update/internal/appupdateservice.h index 4904360ce5..9b8021df55 100644 --- a/framework/update/internal/appupdateservice.h +++ b/framework/update/internal/appupdateservice.h @@ -31,6 +31,7 @@ #include "global/isysteminfo.h" #include "network/inetworkmanagercreator.h" +#include "ui/iuiconfiguration.h" #include "update/iupdateconfiguration.h" #include "update/iupdaterequestparamsprovider.h" #include "update/iupdateinstaller.h" @@ -45,6 +46,7 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as GlobalInject networkManagerCreator; GlobalInject application; GlobalInject updateInstaller; + GlobalInject uiConfiguration; public: AppUpdateService(const modularity::ContextPtr& iocCtx) @@ -77,6 +79,8 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as RetVal parseRelease(const QByteArray& json) const; + InstallProgressUi makeInstallProgressUi() const; + //! Ordered list of acceptable asset suffixes for this platform, most //! preferred first (e.g. "zip" before "dmg" on macOS when auto-install is //! available). diff --git a/framework/update/internal/platform/linux/linuxupdateinstaller.cpp b/framework/update/internal/platform/linux/linuxupdateinstaller.cpp index a32c473bb9..df8ff0ab51 100644 --- a/framework/update/internal/platform/linux/linuxupdateinstaller.cpp +++ b/framework/update/internal/platform/linux/linuxupdateinstaller.cpp @@ -124,7 +124,7 @@ bool LinuxUpdateInstaller::isInPlaceUpdateSupported() const return true; } -Ret LinuxUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath) +Ret LinuxUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi&) { const QString package = packagePath.toQString(); if (!QFileInfo::exists(package)) { diff --git a/framework/update/internal/platform/linux/linuxupdateinstaller.h b/framework/update/internal/platform/linux/linuxupdateinstaller.h index 6367fb7948..5f62e84252 100644 --- a/framework/update/internal/platform/linux/linuxupdateinstaller.h +++ b/framework/update/internal/platform/linux/linuxupdateinstaller.h @@ -45,7 +45,7 @@ class LinuxUpdateInstaller : public IUpdateInstaller, public Contextable : Contextable(iocCtx) {} bool isInPlaceUpdateSupported() const override; - Ret applyUpdate(const muse::io::path_t& packagePath) override; + Ret applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi& ui) override; private: //! Path to the running AppImage file (the install location to replace), or diff --git a/framework/update/internal/platform/mac/macupdateinstaller.cpp b/framework/update/internal/platform/mac/macupdateinstaller.cpp index 62866b62ff..780f489782 100644 --- a/framework/update/internal/platform/mac/macupdateinstaller.cpp +++ b/framework/update/internal/platform/mac/macupdateinstaller.cpp @@ -74,7 +74,7 @@ bool MacUpdateInstaller::isInPlaceUpdateSupported() const return true; } -Ret MacUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath) +Ret MacUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi&) { const QString package = packagePath.toQString(); if (!QFileInfo::exists(package)) { diff --git a/framework/update/internal/platform/mac/macupdateinstaller.h b/framework/update/internal/platform/mac/macupdateinstaller.h index 08e1370388..fc56cb1caf 100644 --- a/framework/update/internal/platform/mac/macupdateinstaller.h +++ b/framework/update/internal/platform/mac/macupdateinstaller.h @@ -39,7 +39,7 @@ class MacUpdateInstaller : public IUpdateInstaller, public Contextable : Contextable(iocCtx) {} bool isInPlaceUpdateSupported() const override; - Ret applyUpdate(const muse::io::path_t& packagePath) override; + Ret applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi& ui) override; private: //! Path to the running `*.app` bundle (the install location to replace). diff --git a/framework/update/internal/platform/stub/updateinstallerstub.cpp b/framework/update/internal/platform/stub/updateinstallerstub.cpp index 10554de492..de5b2f9746 100644 --- a/framework/update/internal/platform/stub/updateinstallerstub.cpp +++ b/framework/update/internal/platform/stub/updateinstallerstub.cpp @@ -29,7 +29,7 @@ bool UpdateInstallerStub::isInPlaceUpdateSupported() const return false; } -Ret UpdateInstallerStub::applyUpdate(const muse::io::path_t&) +Ret UpdateInstallerStub::applyUpdate(const muse::io::path_t&, const InstallProgressUi&) { return make_ret(Ret::Code::NotSupported); } diff --git a/framework/update/internal/platform/stub/updateinstallerstub.h b/framework/update/internal/platform/stub/updateinstallerstub.h index 7abbb0f080..f71033eb10 100644 --- a/framework/update/internal/platform/stub/updateinstallerstub.h +++ b/framework/update/internal/platform/stub/updateinstallerstub.h @@ -31,7 +31,7 @@ class UpdateInstallerStub : public IUpdateInstaller { public: bool isInPlaceUpdateSupported() const override; - Ret applyUpdate(const muse::io::path_t& packagePath) override; + Ret applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi& ui) override; }; } diff --git a/framework/update/internal/platform/win/winupdateinstaller.cpp b/framework/update/internal/platform/win/winupdateinstaller.cpp index c45e6722d7..64a89abbcd 100644 --- a/framework/update/internal/platform/win/winupdateinstaller.cpp +++ b/framework/update/internal/platform/win/winupdateinstaller.cpp @@ -195,7 +195,7 @@ bool WinUpdateInstaller::isInPlaceUpdateSupported() const return enabled; } -Ret WinUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath) +Ret WinUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi& ui) { if (!fileSystem()->exists(packagePath)) { LOGE() << "update package does not exist: " << packagePath; @@ -235,6 +235,12 @@ Ret WinUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath) request.packagePath = nativePath.toStdWString(); request.pid = static_cast(QCoreApplication::applicationPid()); + request.ui.title = QString::fromStdString(ui.title).toStdWString(); + request.ui.message = QString::fromStdString(ui.message).toStdWString(); + request.ui.backgroundColor = QString::fromStdString(ui.backgroundColor).toStdWString(); + request.ui.accentColor = QString::fromStdString(ui.accentColor).toStdWString(); + request.ui.foregroundColor = QString::fromStdString(ui.textColor).toStdWString(); + if (!win::writeRequest(id, request)) { LOGE() << "failed to write update request to " << QString::fromStdWString(win::requestFilePath(id)); task->Release(); diff --git a/framework/update/internal/platform/win/winupdateinstaller.h b/framework/update/internal/platform/win/winupdateinstaller.h index d0170d0a95..81b1de8f06 100644 --- a/framework/update/internal/platform/win/winupdateinstaller.h +++ b/framework/update/internal/platform/win/winupdateinstaller.h @@ -47,7 +47,7 @@ class WinUpdateInstaller : public IUpdateInstaller, public Contextable : Contextable(iocCtx) {} bool isInPlaceUpdateSupported() const override; - Ret applyUpdate(const muse::io::path_t& packagePath) override; + Ret applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi& ui) override; private: //! Identifier shared with the installer-registered task and the HKLM key; diff --git a/framework/update/internal/platform/win/winupdateshared.h b/framework/update/internal/platform/win/winupdateshared.h index e5217c860b..f6b99f8b85 100644 --- a/framework/update/internal/platform/win/winupdateshared.h +++ b/framework/update/internal/platform/win/winupdateshared.h @@ -215,9 +215,59 @@ inline std::wstring expandInstallArgs(const std::wstring& args, const std::wstri return result; } +struct UpdateUi { + std::wstring title; + std::wstring message; + std::wstring backgroundColor; + std::wstring accentColor; + std::wstring foregroundColor; + + bool isValid() const { return !message.empty(); } +}; + +//! Control characters would break the line-oriented formats this travels +//! through; the length is bounded because the window shows a single line. +inline std::wstring sanitizedUiText(const std::wstring& text) +{ + const size_t maxLength = 200; + + std::wstring result; + result.reserve(text.size() < maxLength ? text.size() : maxLength); + + for (const wchar_t c : text) { + if (result.size() >= maxLength) { + break; + } + + if (c == L'\t' || (c >= L' ' && c != 0x7F)) { + result.push_back(c); + } + } + + return result; +} + +inline bool isUiColor(const std::wstring& color) +{ + if (color.size() != 7 || color[0] != L'#') { + return false; + } + + for (size_t i = 1; i < color.size(); ++i) { + const wchar_t c = color[i]; + const bool isHexDigit = (c >= L'0' && c <= L'9') || (c >= L'a' && c <= L'f') || (c >= L'A' && c <= L'F'); + if (!isHexDigit) { + return false; + } + } + + return true; +} + struct UpdateRequest { std::wstring packagePath; unsigned long long pid = 0; + UpdateUi ui; }; inline bool writeFileContent(const std::wstring& path, const std::string& content) @@ -268,6 +318,25 @@ inline bool writeRequest(const std::wstring& appId, const UpdateRequest& request content += "package=" + wideToUtf8(request.packagePath) + "\n"; content += "pid=" + std::to_string(request.pid) + "\n"; + auto appendUiValue = [&content](const char* key, const std::wstring& value) { + if (!value.empty()) { + content += std::string(key) + "=" + wideToUtf8(value) + "\n"; + } + }; + + appendUiValue("ui.title", sanitizedUiText(request.ui.title)); + appendUiValue("ui.message", sanitizedUiText(request.ui.message)); + + if (isUiColor(request.ui.backgroundColor)) { + appendUiValue("ui.background", request.ui.backgroundColor); + } + if (isUiColor(request.ui.accentColor)) { + appendUiValue("ui.accent", request.ui.accentColor); + } + if (isUiColor(request.ui.foregroundColor)) { + appendUiValue("ui.foreground", request.ui.foregroundColor); + } + return writeFileContent(requestFilePath(appId), content); } @@ -315,6 +384,19 @@ inline bool readRequest(const std::wstring& appId, UpdateRequest& request) } catch (...) { request.pid = 0; } + } else if (key == "ui.title") { + request.ui.title = sanitizedUiText(utf8ToWide(value)); + } else if (key == "ui.message") { + request.ui.message = sanitizedUiText(utf8ToWide(value)); + } else if (key == "ui.background") { + const std::wstring color = utf8ToWide(value); + request.ui.backgroundColor = isUiColor(color) ? color : std::wstring(); + } else if (key == "ui.accent") { + const std::wstring color = utf8ToWide(value); + request.ui.accentColor = isUiColor(color) ? color : std::wstring(); + } else if (key == "ui.foreground") { + const std::wstring color = utf8ToWide(value); + request.ui.foregroundColor = isUiColor(color) ? color : std::wstring(); } } diff --git a/framework/update/iupdateinstaller.h b/framework/update/iupdateinstaller.h index 5cddf3a359..09ba28ea4c 100644 --- a/framework/update/iupdateinstaller.h +++ b/framework/update/iupdateinstaller.h @@ -27,6 +27,8 @@ #include "modularity/imoduleinterface.h" +#include "updatetypes.h" + namespace muse::update { class IUpdateInstaller : MODULE_GLOBAL_INTERFACE { @@ -45,7 +47,9 @@ class IUpdateInstaller : MODULE_GLOBAL_INTERFACE //! current install location once this process exits, and relaunches the //! application. Returns OK if the helper was successfully spawned; the //! caller must then quit the application. - virtual Ret applyUpdate(const muse::io::path_t& packagePath) = 0; + //! + //! `ui` is what the helper should show while it installs. + virtual Ret applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi& ui) = 0; }; } diff --git a/framework/update/updatetypes.h b/framework/update/updatetypes.h index c0e0afc945..deba11027f 100644 --- a/framework/update/updatetypes.h +++ b/framework/update/updatetypes.h @@ -42,6 +42,14 @@ struct PrevReleaseNotes { }; using PrevReleasesNotesList = std::vector; +struct InstallProgressUi { + std::string title; //!< window caption, e.g. "MuseScore Studio" + std::string message; //!< e.g. "Installing MuseScore Studio 4.6.1" + std::string backgroundColor; //!< "#RRGGBB" + std::string accentColor; //!< "#RRGGBB" + std::string textColor; //!< "#RRGGBB" +}; + struct ReleaseInfo { std::string version; std::string fileName; From cb8776d525b7cc27284db55a565b6e08dd058eb8 Mon Sep 17 00:00:00 2001 From: Eism Date: Mon, 17 Aug 2026 17:27:49 +0300 Subject: [PATCH 09/13] updated stub for module --- framework/cmake/MuseSetupConfiguration.cmake | 4 --- framework/stubs/update/CMakeLists.txt | 4 ++- .../stubs/update/appupdatescenariostub.cpp | 10 ------- .../stubs/update/appupdatescenariostub.h | 3 -- .../update/qml/Muse/Update/CMakeLists.txt | 30 +++++++++++++++++++ .../update/qml/Muse/Update/UpdateBanner.qml | 29 ++++++++++++++++++ .../stubs/update/updateconfigurationstub.cpp | 9 ++++++ .../stubs/update/updateconfigurationstub.h | 3 ++ 8 files changed, 74 insertions(+), 18 deletions(-) create mode 100644 framework/stubs/update/qml/Muse/Update/CMakeLists.txt create mode 100644 framework/stubs/update/qml/Muse/Update/UpdateBanner.qml diff --git a/framework/cmake/MuseSetupConfiguration.cmake b/framework/cmake/MuseSetupConfiguration.cmake index 72d1be9c72..4bea75db2e 100644 --- a/framework/cmake/MuseSetupConfiguration.cmake +++ b/framework/cmake/MuseSetupConfiguration.cmake @@ -59,10 +59,6 @@ if (NOT MUSE_MODULE_MULTIWINDOWS) set(MUSE_MODULE_MULTIWINDOWS_QML OFF) # Stub does not have QML endif() -if (NOT MUSE_MODULE_UPDATE) - set(MUSE_MODULE_UPDATE_QML OFF) # Stub does not have QML -endif() - if (NOT MUSE_MODULE_VST) set(MUSE_MODULE_VST_QML OFF) # Stub does not have QML endif() diff --git a/framework/stubs/update/CMakeLists.txt b/framework/stubs/update/CMakeLists.txt index 1f594b2da9..341613809a 100644 --- a/framework/stubs/update/CMakeLists.txt +++ b/framework/stubs/update/CMakeLists.txt @@ -29,4 +29,6 @@ target_sources(muse_update PRIVATE appupdatescenariostub.h appupdateservicestub.cpp appupdateservicestub.h -) \ No newline at end of file +) + +add_subdirectory(qml/Muse/Update) diff --git a/framework/stubs/update/appupdatescenariostub.cpp b/framework/stubs/update/appupdatescenariostub.cpp index 48853ff2cd..285dc2359a 100644 --- a/framework/stubs/update/appupdatescenariostub.cpp +++ b/framework/stubs/update/appupdatescenariostub.cpp @@ -32,16 +32,6 @@ void AppUpdateScenarioStub::checkForUpdate(bool) { } -bool AppUpdateScenarioStub::checkInProgress() const -{ - return false; -} - -muse::async::Notification AppUpdateScenarioStub::checkInProgressChanged() const -{ - return {}; -} - bool AppUpdateScenarioStub::hasUpdate() const { return false; diff --git a/framework/stubs/update/appupdatescenariostub.h b/framework/stubs/update/appupdatescenariostub.h index 77af819d81..a89dc12a7d 100644 --- a/framework/stubs/update/appupdatescenariostub.h +++ b/framework/stubs/update/appupdatescenariostub.h @@ -31,9 +31,6 @@ class AppUpdateScenarioStub : public IAppUpdateScenario bool needCheckForUpdate() const override; void checkForUpdate(bool manual) override; - bool checkInProgress() const override; - async::Notification checkInProgressChanged() const override; - bool hasUpdate() const override; bool hasReadyUpdate() const override; diff --git a/framework/stubs/update/qml/Muse/Update/CMakeLists.txt b/framework/stubs/update/qml/Muse/Update/CMakeLists.txt new file mode 100644 index 0000000000..18ae009aad --- /dev/null +++ b/framework/stubs/update/qml/Muse/Update/CMakeLists.txt @@ -0,0 +1,30 @@ +# 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 new file mode 100644 index 0000000000..d20035ab57 --- /dev/null +++ b/framework/stubs/update/qml/Muse/Update/UpdateBanner.qml @@ -0,0 +1,29 @@ +/* + * 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 1efdc76bd9..b2593a4b69 100644 --- a/framework/stubs/update/updateconfigurationstub.cpp +++ b/framework/stubs/update/updateconfigurationstub.cpp @@ -52,6 +52,15 @@ muse::async::Notification UpdateConfigurationStub::needCheckForUpdateChanged() c return n; } +bool UpdateConfigurationStub::autoInstallEnabled() const +{ + return false; +} + +void UpdateConfigurationStub::setAutoInstallEnabled(bool) +{ +} + std::string UpdateConfigurationStub::skippedReleaseVersion() const { return ""; diff --git a/framework/stubs/update/updateconfigurationstub.h b/framework/stubs/update/updateconfigurationstub.h index f700b6550c..de96f3294b 100644 --- a/framework/stubs/update/updateconfigurationstub.h +++ b/framework/stubs/update/updateconfigurationstub.h @@ -37,6 +37,9 @@ class UpdateConfigurationStub : public IUpdateConfiguration void setNeedCheckForUpdate(bool needCheck) override; muse::async::Notification needCheckForUpdateChanged() const override; + bool autoInstallEnabled() const override; + void setAutoInstallEnabled(bool enabled) override; + std::string skippedReleaseVersion() const override; void setSkippedReleaseVersion(const std::string& version) override; From 2008f28285fc04135bdc09a16a884b1a4714c8f2 Mon Sep 17 00:00:00 2001 From: Eism Date: Tue, 18 Aug 2026 10:21:37 +0300 Subject: [PATCH 10/13] tmp fix --- framework/diagnostics/CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/diagnostics/CMakeLists.txt b/framework/diagnostics/CMakeLists.txt index 1a5e43660c..09c79d259e 100644 --- a/framework/diagnostics/CMakeLists.txt +++ b/framework/diagnostics/CMakeLists.txt @@ -85,7 +85,7 @@ if (MUSE_MODULE_DIAGNOSTICS_CRASHPAD_CLIENT) message(FATAL_ERROR "Crashpad handler not found: ${MUSE_MODULE_DIAGNOSTICS_CRASHPAD_HANDLER_PATH}") endif() if (OS_IS_LIN OR OS_IS_WIN OR OS_IS_MAC) - install(PROGRAMS ${MUSE_MODULE_DIAGNOSTICS_CRASHPAD_HANDLER_PATH} DESTINATION ${INSTALL_BIN_DIR}) + install(PROGRAMS ${MUSE_MODULE_DIAGNOSTICS_CRASHPAD_HANDLER_PATH} DESTINATION ${INSTALL_SUBDIR}) endif() endif() # MUSE_MODULE_DIAGNOSTICS_CRASHPAD_CLIENT # ---------------- From ddcfce56e9ec4457b55eefb92b199aa74ed05721 Mon Sep 17 00:00:00 2001 From: Eism Date: Tue, 18 Aug 2026 15:39:06 +0300 Subject: [PATCH 11/13] use dmg for macOS --- .../update/internal/appupdatescenario.cpp | 46 ++++++++------- framework/update/internal/appupdatescenario.h | 1 + .../update/internal/appupdateservice.cpp | 8 +-- framework/update/internal/appupdateservice.h | 3 +- .../platform/mac/macupdateinstaller.cpp | 59 +++++++++++++++++-- .../platform/mac/macupdateinstaller.h | 2 + .../internal/AppReleaseInfoBottomPanel.qml | 3 + 7 files changed, 87 insertions(+), 35 deletions(-) diff --git a/framework/update/internal/appupdatescenario.cpp b/framework/update/internal/appupdatescenario.cpp index 9ed25a170d..477c859cb9 100644 --- a/framework/update/internal/appupdatescenario.cpp +++ b/framework/update/internal/appupdatescenario.cpp @@ -26,6 +26,9 @@ #include +#include "async/async.h" +#include "global/concurrency/concurrent.h" +#include "runtime.h" #include "types/val.h" #include "translation.h" #include "log.h" @@ -203,23 +206,33 @@ Promise AppUpdateScenario::askToRestartAndInstall(const io::path_t& package return resolve(muse::make_ret(Ret::Code::Cancel)); } - const Ret ret = service()->applyUpdate(packagePath); - if (!ret) { - LOGE() << "failed to apply update in-place, falling back to manual install: " << ret.toString(); - askToCloseAppAndCompleteInstall(packagePath).onResolve(this, [resolve](const Ret& r) { - (void)resolve(r); - }); - return Promise::dummy_result(); - } + applyUpdateAndQuit(packagePath); - //! 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())); return resolve(muse::make_ok()); }); } +void AppUpdateScenario::applyUpdateAndQuit(const io::path_t& packagePath) +{ + //! NOTE: Unpacking and verifying the package takes seconds; run it off + //! the UI thread so the app stays responsive. + Concurrent::run([this, packagePath]() { + const Ret ret = service()->applyUpdate(packagePath); + async::Async::call(this, [this, packagePath, ret]() { + if (!ret) { + LOGE() << "failed to apply update in-place, falling back to manual install: " << ret.toString(); + askToCloseAppAndCompleteInstall(packagePath).onResolve(this, [](const Ret&) {}); + return; + } + + //! 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())); + }, runtime::mainThreadId()); + }); +} + Promise AppUpdateScenario::askToCloseAppAndCompleteInstall(const io::path_t& packagePath) { const std::string info = muse::qtrc("update", "%1 needs to close to complete the installation. " @@ -345,13 +358,6 @@ void AppUpdateScenario::installReadyUpdate() return; } - const Ret ret = service()->applyUpdate(m_readyPackagePath); - if (!ret) { - LOGE() << "failed to apply update in-place, falling back to manual install: " << ret.toString(); - askToCloseAppAndCompleteInstall(m_readyPackagePath).onResolve(this, [](const Ret&) {}); - return; - } - - dispatcher()->dispatch("quit", ActionData::make_arg2(false, std::string())); + applyUpdateAndQuit(m_readyPackagePath); }); } diff --git a/framework/update/internal/appupdatescenario.h b/framework/update/internal/appupdatescenario.h index d2e1de1780..e67be3aade 100644 --- a/framework/update/internal/appupdatescenario.h +++ b/framework/update/internal/appupdatescenario.h @@ -70,6 +70,7 @@ class AppUpdateScenario : public IAppUpdateScenario, public Contextable, public muse::async::Promise downloadRelease(); muse::async::Promise askToCloseAppAndCompleteInstall(const io::path_t& installerPath); muse::async::Promise askToRestartAndInstall(const io::path_t& packagePath); + void applyUpdateAndQuit(const io::path_t& packagePath); bool shouldIgnoreUpdate(const ReleaseInfo& info) const; diff --git a/framework/update/internal/appupdateservice.cpp b/framework/update/internal/appupdateservice.cpp index 302c13875a..0898d9cca6 100644 --- a/framework/update/internal/appupdateservice.cpp +++ b/framework/update/internal/appupdateservice.cpp @@ -403,13 +403,7 @@ std::vector AppUpdateService::platformFileSuffixes() const { switch (systemInfo()->productType()) { case ISystemInfo::ProductType::Windows: return { "msi" }; - case ISystemInfo::ProductType::MacOS: - // In-place auto-install works with the zip bundle only, the manual - // flow hands the user a dmg. - if (canAutoInstall()) { - return { "zip", "dmg" }; - } - return { "dmg" }; + case ISystemInfo::ProductType::MacOS: return { "dmg" }; case ISystemInfo::ProductType::Linux: return { "appimage" }; case ISystemInfo::ProductType::Unknown: break; } diff --git a/framework/update/internal/appupdateservice.h b/framework/update/internal/appupdateservice.h index 9b8021df55..31d4967c8d 100644 --- a/framework/update/internal/appupdateservice.h +++ b/framework/update/internal/appupdateservice.h @@ -82,8 +82,7 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as InstallProgressUi makeInstallProgressUi() const; //! Ordered list of acceptable asset suffixes for this platform, most - //! preferred first (e.g. "zip" before "dmg" on macOS when auto-install is - //! available). + //! preferred first. std::vector platformFileSuffixes() const; QJsonObject resolveReleaseAsset(const QJsonObject& release) const; diff --git a/framework/update/internal/platform/mac/macupdateinstaller.cpp b/framework/update/internal/platform/mac/macupdateinstaller.cpp index 780f489782..f59d7812ac 100644 --- a/framework/update/internal/platform/mac/macupdateinstaller.cpp +++ b/framework/update/internal/platform/mac/macupdateinstaller.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include "../../../updateerrors.h" @@ -90,11 +91,10 @@ Ret MacUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath, const I } QDir().mkpath(stagingDir); - // 1. Unpack the zip preserving extended attributes and code signatures. - int rc = QProcess::execute("/usr/bin/ditto", { "-xk", package, stagingDir }); - if (rc != 0) { - LOGE() << "failed to unpack update package, ditto rc=" << rc; - return make_ret(Err::UnknownError); + // 1. Unpack the dmg preserving extended attributes and code signatures. + Ret unpackRet = unpackDmg(package, stagingDir); + if (!unpackRet) { + return unpackRet; } // 2. Locate the unpacked .app bundle. @@ -112,7 +112,7 @@ Ret MacUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath, const I QProcess::execute("/usr/bin/xattr", { "-dr", "com.apple.quarantine", stagingApp }); // 4. Verify the unpacked bundle is correctly signed before trusting it. - rc = QProcess::execute("/usr/bin/codesign", { "--verify", "--deep", "--strict", stagingApp }); + int rc = QProcess::execute("/usr/bin/codesign", { "--verify", "--deep", "--strict", stagingApp }); if (rc != 0) { LOGE() << "code signature verification failed for unpacked update, rc=" << rc; return make_ret(Err::UnknownError); @@ -149,3 +149,50 @@ Ret MacUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath, const I LOGI() << "update helper started, will replace " << bundlePath << " after quit"; return make_ok(); } + +Ret MacUpdateInstaller::unpackDmg(const QString& package, const QString& stagingDir) const +{ + QString mountPoint; + do { + mountPoint = "/Volumes/" + QUuid::createUuid().toString(QUuid::WithoutBraces); + } while (QFileInfo::exists(mountPoint)); + + QProcess attach; + attach.setProgram("/usr/bin/hdiutil"); + attach.setArguments({ "attach", package, "-mountpoint", mountPoint, + "-noverify", "-nobrowse", "-noautoopen", "-stdinpass" }); + attach.start(); + if (!attach.waitForStarted()) { + LOGE() << "failed to start hdiutil"; + return make_ret(Err::UnknownError); + } + + // Empty null-terminated passphrase for -stdinpass, then a "yes" answer in + // case the image carries a license agreement, so hdiutil never blocks on + // an interactive prompt. + attach.write(QByteArray("\0yes\n", 5)); + attach.closeWriteChannel(); + attach.waitForFinished(-1); + if (attach.exitStatus() != QProcess::NormalExit || attach.exitCode() != 0) { + LOGE() << "failed to mount update dmg, hdiutil rc=" << attach.exitCode(); + return make_ret(Err::UnknownError); + } + + Ret ret = make_ok(); + + const QStringList apps = QDir(mountPoint).entryList({ "*.app" }, QDir::Dirs | QDir::NoDotAndDotDot); + if (apps.isEmpty()) { + LOGE() << "no .app bundle found in mounted dmg"; + ret = make_ret(Err::UnknownError); + } else { + int rc = QProcess::execute("/usr/bin/ditto", { mountPoint + "/" + apps.first(), stagingDir + "/" + apps.first() }); + if (rc != 0) { + LOGE() << "failed to copy app out of dmg, ditto rc=" << rc; + ret = make_ret(Err::UnknownError); + } + } + + QProcess::execute("/usr/bin/hdiutil", { "detach", mountPoint, "-force" }); + + return ret; +} diff --git a/framework/update/internal/platform/mac/macupdateinstaller.h b/framework/update/internal/platform/mac/macupdateinstaller.h index fc56cb1caf..617c2c948f 100644 --- a/framework/update/internal/platform/mac/macupdateinstaller.h +++ b/framework/update/internal/platform/mac/macupdateinstaller.h @@ -47,6 +47,8 @@ class MacUpdateInstaller : public IUpdateInstaller, public Contextable //! Path to the bundled `museupdater` helper (Contents/MacOS/museupdater). muse::io::path_t helperPath() const; + + Ret unpackDmg(const QString& package, const QString& stagingDir) const; }; } diff --git a/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml b/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml index da488bcb85..1f330b3326 100644 --- a/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml +++ b/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml @@ -70,6 +70,8 @@ RowLayout { text: qsTrc("update", "Remind me later") icon: IconCode.CLOCK + orientation: Qt.Horizontal + navigation.name: "RemindMeLaterButton" navigation.panel: root.navigationPanel navigation.column: 3 @@ -88,6 +90,7 @@ RowLayout { icon: IconCode.IMPORT accentButton: true + orientation: Qt.Horizontal navigation.name: "InstallUpdateButton" navigation.panel: root.navigationPanel From baadbedc65f5aa79932b0942bd271b3817b1db8a Mon Sep 17 00:00:00 2001 From: Eism Date: Wed, 19 Aug 2026 08:45:31 +0300 Subject: [PATCH 12/13] split update installation into prepare and finalize phases - prepareUpdate: the heavy part - validate the downloaded package and stage it for the swap. Runs in the background right after the download, while the app is still running, so failures can fall back to the manual flow with the app alive. - finalizeUpdate: the fast part - spawn the swap helper. Runs on the Restart click, which is now instant instead of freezing for seconds. On macOS the package validation is now done on the dmg itself (signature valid + same Team ID as the running bundle, ~0.1s) instead of deep-verifying the unpacked bundle (~2s); the helper still deep-verifies the staged bundle right before the swap, so that check is no longer duplicated. Development builds have no team and accept any validly signed dmg. --- .../stubs/update/appupdateservicestub.cpp | 7 +- framework/stubs/update/appupdateservicestub.h | 3 +- framework/update/iappupdateservice.h | 3 +- .../update/internal/appupdatescenario.cpp | 67 ++++++++------ framework/update/internal/appupdatescenario.h | 4 +- .../update/internal/appupdateservice.cpp | 9 +- framework/update/internal/appupdateservice.h | 3 +- .../platform/linux/linuxupdateinstaller.cpp | 38 +++++--- .../platform/linux/linuxupdateinstaller.h | 3 +- .../platform/mac/macupdateinstaller.cpp | 92 +++++++++++++++---- .../platform/mac/macupdateinstaller.h | 4 +- .../platform/stub/updateinstallerstub.cpp | 7 +- .../platform/stub/updateinstallerstub.h | 3 +- .../platform/win/winupdateinstaller.cpp | 15 ++- .../platform/win/winupdateinstaller.h | 3 +- framework/update/iupdateinstaller.h | 17 +++- 16 files changed, 205 insertions(+), 73 deletions(-) diff --git a/framework/stubs/update/appupdateservicestub.cpp b/framework/stubs/update/appupdateservicestub.cpp index 967e186348..e0eb4b46a7 100644 --- a/framework/stubs/update/appupdateservicestub.cpp +++ b/framework/stubs/update/appupdateservicestub.cpp @@ -48,7 +48,12 @@ bool AppUpdateServiceStub::canAutoInstall() const return false; } -Ret AppUpdateServiceStub::applyUpdate(const muse::io::path_t&) +RetVal AppUpdateServiceStub::prepareUpdate(const muse::io::path_t&) +{ + return RetVal(make_ret(Ret::Code::NotSupported)); +} + +Ret AppUpdateServiceStub::finalizeUpdate(const muse::io::path_t&) { return make_ret(Ret::Code::NotSupported); } diff --git a/framework/stubs/update/appupdateservicestub.h b/framework/stubs/update/appupdateservicestub.h index 8fab50a3dc..baa9bb48fb 100644 --- a/framework/stubs/update/appupdateservicestub.h +++ b/framework/stubs/update/appupdateservicestub.h @@ -33,7 +33,8 @@ class AppUpdateServiceStub : public IAppUpdateService RetVal downloadRelease() override; bool canAutoInstall() const override; - Ret applyUpdate(const muse::io::path_t& packagePath) override; + RetVal prepareUpdate(const muse::io::path_t& packagePath) override; + Ret finalizeUpdate(const muse::io::path_t& preparedPath) override; bool isReleaseDownloaded() const override; muse::io::path_t downloadedReleasePath() const override; diff --git a/framework/update/iappupdateservice.h b/framework/update/iappupdateservice.h index 4a12c6f90b..2ae3efcca2 100644 --- a/framework/update/iappupdateservice.h +++ b/framework/update/iappupdateservice.h @@ -47,6 +47,7 @@ class IAppUpdateService : MODULE_CONTEXT_INTERFACE virtual bool canAutoInstall() const = 0; - virtual Ret applyUpdate(const muse::io::path_t& packagePath) = 0; + virtual RetVal prepareUpdate(const muse::io::path_t& packagePath) = 0; + virtual Ret finalizeUpdate(const muse::io::path_t& preparedPath) = 0; }; } diff --git a/framework/update/internal/appupdatescenario.cpp b/framework/update/internal/appupdatescenario.cpp index 477c859cb9..55a3217838 100644 --- a/framework/update/internal/appupdatescenario.cpp +++ b/framework/update/internal/appupdatescenario.cpp @@ -182,13 +182,38 @@ Promise AppUpdateScenario::downloadRelease() //! 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 askToRestartAndInstall(packagePath); + return prepareAndInstall(packagePath); } return askToCloseAppAndCompleteInstall(packagePath); } -Promise AppUpdateScenario::askToRestartAndInstall(const io::path_t& 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) { + Concurrent::run([this, 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) { + 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()); + }); + + return Promise::dummy_result(); + }); +} + +Promise AppUpdateScenario::askToRestartAndInstall(const io::path_t& packagePath, const io::path_t& preparedPath) { 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. " @@ -201,38 +226,28 @@ Promise AppUpdateScenario::askToRestartAndInstall(const io::path_t& package }; return interactive()->info("", info, buttons, restartBtn) - .then(this, [this, packagePath](const IInteractive::Result& res, auto resolve) { + .then(this, [this, packagePath, preparedPath](const IInteractive::Result& res, auto resolve) { if (res.isButton(IInteractive::Button::Cancel)) { return resolve(muse::make_ret(Ret::Code::Cancel)); } - applyUpdateAndQuit(packagePath); + 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(); + } + //! 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())); return resolve(muse::make_ok()); }); } -void AppUpdateScenario::applyUpdateAndQuit(const io::path_t& packagePath) -{ - //! NOTE: Unpacking and verifying the package takes seconds; run it off - //! the UI thread so the app stays responsive. - Concurrent::run([this, packagePath]() { - const Ret ret = service()->applyUpdate(packagePath); - async::Async::call(this, [this, packagePath, ret]() { - if (!ret) { - LOGE() << "failed to apply update in-place, falling back to manual install: " << ret.toString(); - askToCloseAppAndCompleteInstall(packagePath).onResolve(this, [](const Ret&) {}); - return; - } - - //! 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())); - }, runtime::mainThreadId()); - }); -} - Promise AppUpdateScenario::askToCloseAppAndCompleteInstall(const io::path_t& packagePath) { const std::string info = muse::qtrc("update", "%1 needs to close to complete the installation. " @@ -358,6 +373,6 @@ void AppUpdateScenario::installReadyUpdate() return; } - applyUpdateAndQuit(m_readyPackagePath); + prepareAndInstall(m_readyPackagePath).onResolve(this, [](const Ret&) {}); }); } diff --git a/framework/update/internal/appupdatescenario.h b/framework/update/internal/appupdatescenario.h index e67be3aade..9fae8ce4cf 100644 --- a/framework/update/internal/appupdatescenario.h +++ b/framework/update/internal/appupdatescenario.h @@ -69,8 +69,8 @@ class AppUpdateScenario : public IAppUpdateScenario, public Contextable, public muse::async::Promise downloadRelease(); muse::async::Promise askToCloseAppAndCompleteInstall(const io::path_t& installerPath); - muse::async::Promise askToRestartAndInstall(const io::path_t& packagePath); - void applyUpdateAndQuit(const io::path_t& packagePath); + muse::async::Promise prepareAndInstall(const io::path_t& packagePath); + muse::async::Promise askToRestartAndInstall(const io::path_t& packagePath, const io::path_t& preparedPath); bool shouldIgnoreUpdate(const ReleaseInfo& info) const; diff --git a/framework/update/internal/appupdateservice.cpp b/framework/update/internal/appupdateservice.cpp index 0898d9cca6..50184ff666 100644 --- a/framework/update/internal/appupdateservice.cpp +++ b/framework/update/internal/appupdateservice.cpp @@ -455,9 +455,14 @@ bool AppUpdateService::canAutoInstall() const return updateInstaller()->isInPlaceUpdateSupported(); } -Ret AppUpdateService::applyUpdate(const muse::io::path_t& packagePath) +RetVal AppUpdateService::prepareUpdate(const muse::io::path_t& packagePath) { - return updateInstaller()->applyUpdate(packagePath, makeInstallProgressUi()); + return updateInstaller()->prepareUpdate(packagePath); +} + +Ret AppUpdateService::finalizeUpdate(const muse::io::path_t& preparedPath) +{ + return updateInstaller()->finalizeUpdate(preparedPath, makeInstallProgressUi()); } InstallProgressUi AppUpdateService::makeInstallProgressUi() const diff --git a/framework/update/internal/appupdateservice.h b/framework/update/internal/appupdateservice.h index 31d4967c8d..a62c5902b2 100644 --- a/framework/update/internal/appupdateservice.h +++ b/framework/update/internal/appupdateservice.h @@ -59,7 +59,8 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as RetVal downloadRelease() override; bool canAutoInstall() const override; - Ret applyUpdate(const muse::io::path_t& packagePath) override; + RetVal prepareUpdate(const muse::io::path_t& packagePath) override; + Ret finalizeUpdate(const muse::io::path_t& preparedPath) override; bool isReleaseDownloaded() const override; muse::io::path_t downloadedReleasePath() const override; diff --git a/framework/update/internal/platform/linux/linuxupdateinstaller.cpp b/framework/update/internal/platform/linux/linuxupdateinstaller.cpp index df8ff0ab51..1707318c8b 100644 --- a/framework/update/internal/platform/linux/linuxupdateinstaller.cpp +++ b/framework/update/internal/platform/linux/linuxupdateinstaller.cpp @@ -124,25 +124,19 @@ bool LinuxUpdateInstaller::isInPlaceUpdateSupported() const return true; } -Ret LinuxUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi&) +RetVal LinuxUpdateInstaller::prepareUpdate(const muse::io::path_t& packagePath) { const QString package = packagePath.toQString(); if (!QFileInfo::exists(package)) { LOGE() << "update package does not exist: " << package; - return make_ret(Err::UnknownError); - } - - const QString appImagePath = currentAppImagePath().toQString(); - if (appImagePath.isEmpty()) { - LOGE() << "not running from an AppImage, cannot update in place"; - return make_ret(Ret::Code::NotSupported); + return RetVal(make_ret(Err::UnknownError)); } // 1. The downloaded package replaces the running file as-is, so make sure it // really is an AppImage before letting the helper move it into place. if (!isAppImageFile(package)) { LOGE() << "update package is not a valid AppImage: " << package; - return make_ret(Err::UnknownError); + return RetVal(make_ret(Err::UnknownError)); } // 2. The download has no executable bit; the swapped-in file must be @@ -154,10 +148,32 @@ Ret LinuxUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath, const if (!QFile::setPermissions(package, permissions)) { LOGE() << "failed to make update package executable: " << package; + return RetVal(make_ret(Err::UnknownError)); + } + + return RetVal::make_ok(packagePath); +} + +Ret LinuxUpdateInstaller::finalizeUpdate(const muse::io::path_t& preparedPath, const InstallProgressUi&) +{ + const QString package = preparedPath.toQString(); + if (!QFileInfo::exists(package)) { + LOGE() << "prepared update does not exist: " << package; return make_ret(Err::UnknownError); } - // 3. Copy the helper out of the AppImage. Its mount point disappears as soon + const QString appImagePath = currentAppImagePath().toQString(); + if (appImagePath.isEmpty()) { + LOGE() << "not running from an AppImage, cannot update in place"; + return make_ret(Ret::Code::NotSupported); + } + + const QFile::Permissions permissions + = QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner + | QFile::ReadGroup | QFile::ExeGroup + | QFile::ReadOther | QFile::ExeOther; + + // 1. Copy the helper out of the AppImage. Its mount point disappears as soon // as this process exits, which is exactly when the helper starts working. fileSystem()->makePath(configuration()->updateDataPath()); @@ -169,7 +185,7 @@ Ret LinuxUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath, const } QFile::setPermissions(helperRun, permissions); - // 4. Spawn the detached helper. It waits for us to quit, replaces the + // 2. Spawn the detached helper. It waits for us to quit, replaces the // AppImage and relaunches it. const QString logPath = configuration()->updateDataPath().toQString() + "/museupdater.log"; const QStringList args = { diff --git a/framework/update/internal/platform/linux/linuxupdateinstaller.h b/framework/update/internal/platform/linux/linuxupdateinstaller.h index 5f62e84252..8f49a23200 100644 --- a/framework/update/internal/platform/linux/linuxupdateinstaller.h +++ b/framework/update/internal/platform/linux/linuxupdateinstaller.h @@ -45,7 +45,8 @@ class LinuxUpdateInstaller : public IUpdateInstaller, public Contextable : Contextable(iocCtx) {} bool isInPlaceUpdateSupported() const override; - Ret applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi& ui) override; + RetVal prepareUpdate(const muse::io::path_t& packagePath) override; + Ret finalizeUpdate(const muse::io::path_t& preparedPath, const InstallProgressUi& ui) override; private: //! Path to the running AppImage file (the install location to replace), or diff --git a/framework/update/internal/platform/mac/macupdateinstaller.cpp b/framework/update/internal/platform/mac/macupdateinstaller.cpp index f59d7812ac..c9b7a75a00 100644 --- a/framework/update/internal/platform/mac/macupdateinstaller.cpp +++ b/framework/update/internal/platform/mac/macupdateinstaller.cpp @@ -75,29 +75,36 @@ bool MacUpdateInstaller::isInPlaceUpdateSupported() const return true; } -Ret MacUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi&) +RetVal MacUpdateInstaller::prepareUpdate(const muse::io::path_t& packagePath) { const QString package = packagePath.toQString(); if (!QFileInfo::exists(package)) { LOGE() << "update package does not exist: " << package; - return make_ret(Err::UnknownError); + return RetVal(make_ret(Err::UnknownError)); + } + + // 1. Verify the dmg signature before opening it. The unpacked bundle is + // not deep-verified here: the helper re-verifies the staged bundle + // right before the swap. + Ret ret = verifyPackageSignature(package); + if (!ret) { + return RetVal(ret); } const QString stagingDir = configuration()->updateDataPath().toQString() + "/staging"; - QDir().rmpath(stagingDir); QDir staging(stagingDir); if (staging.exists()) { staging.removeRecursively(); } QDir().mkpath(stagingDir); - // 1. Unpack the dmg preserving extended attributes and code signatures. - Ret unpackRet = unpackDmg(package, stagingDir); - if (!unpackRet) { - return unpackRet; + // 2. Unpack the dmg preserving extended attributes and code signatures. + ret = unpackDmg(package, stagingDir); + if (!ret) { + return RetVal(ret); } - // 2. Locate the unpacked .app bundle. + // 3. Locate the unpacked .app bundle. QString stagingApp; const QStringList apps = staging.entryList({ "*.app" }, QDir::Dirs | QDir::NoDotAndDotDot); if (!apps.isEmpty()) { @@ -105,20 +112,24 @@ Ret MacUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath, const I } if (stagingApp.isEmpty()) { LOGE() << "no .app bundle found in unpacked update"; - return make_ret(Err::UnknownError); + return RetVal(make_ret(Err::UnknownError)); } - // 3. Remove the quarantine attribute set by the download. + // 4. Remove the quarantine attribute set by the download. QProcess::execute("/usr/bin/xattr", { "-dr", "com.apple.quarantine", stagingApp }); - // 4. Verify the unpacked bundle is correctly signed before trusting it. - int rc = QProcess::execute("/usr/bin/codesign", { "--verify", "--deep", "--strict", stagingApp }); - if (rc != 0) { - LOGE() << "code signature verification failed for unpacked update, rc=" << rc; + return RetVal::make_ok(muse::io::path_t(stagingApp)); +} + +Ret MacUpdateInstaller::finalizeUpdate(const muse::io::path_t& preparedPath, const InstallProgressUi&) +{ + const QString stagingApp = preparedPath.toQString(); + if (!QFileInfo::exists(stagingApp)) { + LOGE() << "prepared update does not exist: " << stagingApp; return make_ret(Err::UnknownError); } - // 5. Copy the helper out of the bundle so replacing the bundle never + // 1. Copy the helper out of the bundle so replacing the bundle never // touches the running helper file. const QString helperRun = configuration()->updateDataPath().toQString() + "/" + HELPER_NAME; QFile::remove(helperRun); @@ -129,8 +140,8 @@ Ret MacUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath, const I QFile::setPermissions(helperRun, QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner | QFile::ReadGroup | QFile::ExeGroup | QFile::ReadOther | QFile::ExeOther); - // 6. Spawn the detached helper. It waits for us to quit, swaps the bundle - // and relaunches. + // 2. Spawn the detached helper. It waits for us to quit, verifies the + // staged bundle, swaps it into place and relaunches. const QString bundlePath = currentBundlePath().toQString(); const QString logPath = configuration()->updateDataPath().toQString() + "/museupdater.log"; const QStringList args = { @@ -150,6 +161,53 @@ Ret MacUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath, const I return make_ok(); } +//! Team ID from the code signature of `path`, empty for ad-hoc or unsigned. +static QString teamIdentifier(const QString& path) +{ + QProcess codesign; + codesign.start("/usr/bin/codesign", { "-dv", "--verbose=4", path }); + codesign.waitForFinished(-1); + if (codesign.exitStatus() != QProcess::NormalExit || codesign.exitCode() != 0) { + return QString(); + } + + const QString details = QString::fromUtf8(codesign.readAllStandardError()); + for (const QString& line : details.split('\n')) { + if (line.startsWith("TeamIdentifier=")) { + const QString team = line.mid(QString("TeamIdentifier=").length()).trimmed(); + return team == "not set" ? QString() : team; + } + } + + return QString(); +} + +Ret MacUpdateInstaller::verifyPackageSignature(const QString& package) const +{ + int rc = QProcess::execute("/usr/bin/codesign", { "--verify", package }); + if (rc != 0) { + LOGE() << "update package signature verification failed, rc=" << rc; + return make_ret(Err::UnknownError); + } + + //! NOTE: The package must be signed by the same team as the running + //! bundle, so that a validly signed package from someone else is not + //! accepted. Development builds are ad-hoc signed and have no team; for + //! them any validly signed package is accepted. + const QString ownTeam = teamIdentifier(currentBundlePath().toQString()); + if (ownTeam.isEmpty()) { + return make_ok(); + } + + const QString packageTeam = teamIdentifier(package); + if (packageTeam != ownTeam) { + LOGE() << "update package team \"" << packageTeam << "\" does not match app team \"" << ownTeam << "\""; + return make_ret(Err::UnknownError); + } + + return make_ok(); +} + Ret MacUpdateInstaller::unpackDmg(const QString& package, const QString& stagingDir) const { QString mountPoint; diff --git a/framework/update/internal/platform/mac/macupdateinstaller.h b/framework/update/internal/platform/mac/macupdateinstaller.h index 617c2c948f..ab0129538c 100644 --- a/framework/update/internal/platform/mac/macupdateinstaller.h +++ b/framework/update/internal/platform/mac/macupdateinstaller.h @@ -39,7 +39,8 @@ class MacUpdateInstaller : public IUpdateInstaller, public Contextable : Contextable(iocCtx) {} bool isInPlaceUpdateSupported() const override; - Ret applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi& ui) override; + RetVal prepareUpdate(const muse::io::path_t& packagePath) override; + Ret finalizeUpdate(const muse::io::path_t& preparedPath, const InstallProgressUi& ui) override; private: //! Path to the running `*.app` bundle (the install location to replace). @@ -48,6 +49,7 @@ class MacUpdateInstaller : public IUpdateInstaller, public Contextable //! Path to the bundled `museupdater` helper (Contents/MacOS/museupdater). muse::io::path_t helperPath() const; + Ret verifyPackageSignature(const QString& package) const; Ret unpackDmg(const QString& package, const QString& stagingDir) const; }; } diff --git a/framework/update/internal/platform/stub/updateinstallerstub.cpp b/framework/update/internal/platform/stub/updateinstallerstub.cpp index de5b2f9746..6ca8b58501 100644 --- a/framework/update/internal/platform/stub/updateinstallerstub.cpp +++ b/framework/update/internal/platform/stub/updateinstallerstub.cpp @@ -29,7 +29,12 @@ bool UpdateInstallerStub::isInPlaceUpdateSupported() const return false; } -Ret UpdateInstallerStub::applyUpdate(const muse::io::path_t&, const InstallProgressUi&) +RetVal UpdateInstallerStub::prepareUpdate(const muse::io::path_t&) +{ + return RetVal(make_ret(Ret::Code::NotSupported)); +} + +Ret UpdateInstallerStub::finalizeUpdate(const muse::io::path_t&, const InstallProgressUi&) { return make_ret(Ret::Code::NotSupported); } diff --git a/framework/update/internal/platform/stub/updateinstallerstub.h b/framework/update/internal/platform/stub/updateinstallerstub.h index f71033eb10..7f6bd56bef 100644 --- a/framework/update/internal/platform/stub/updateinstallerstub.h +++ b/framework/update/internal/platform/stub/updateinstallerstub.h @@ -31,7 +31,8 @@ class UpdateInstallerStub : public IUpdateInstaller { public: bool isInPlaceUpdateSupported() const override; - Ret applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi& ui) override; + RetVal prepareUpdate(const muse::io::path_t& packagePath) override; + Ret finalizeUpdate(const muse::io::path_t& preparedPath, const InstallProgressUi& ui) override; }; } diff --git a/framework/update/internal/platform/win/winupdateinstaller.cpp b/framework/update/internal/platform/win/winupdateinstaller.cpp index 64a89abbcd..9bd48e425a 100644 --- a/framework/update/internal/platform/win/winupdateinstaller.cpp +++ b/framework/update/internal/platform/win/winupdateinstaller.cpp @@ -195,7 +195,20 @@ bool WinUpdateInstaller::isInPlaceUpdateSupported() const return enabled; } -Ret WinUpdateInstaller::applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi& ui) +RetVal WinUpdateInstaller::prepareUpdate(const muse::io::path_t& packagePath) +{ + //! NOTE: The scheduled task treats the package as untrusted input and + //! verifies its signature after copying it out of reach, so there is + //! nothing to stage here. + if (!fileSystem()->exists(packagePath)) { + LOGE() << "update package does not exist: " << packagePath; + return RetVal(make_ret(Err::UnknownError)); + } + + return RetVal::make_ok(packagePath); +} + +Ret WinUpdateInstaller::finalizeUpdate(const muse::io::path_t& packagePath, const InstallProgressUi& ui) { if (!fileSystem()->exists(packagePath)) { LOGE() << "update package does not exist: " << packagePath; diff --git a/framework/update/internal/platform/win/winupdateinstaller.h b/framework/update/internal/platform/win/winupdateinstaller.h index 81b1de8f06..35b10e5f55 100644 --- a/framework/update/internal/platform/win/winupdateinstaller.h +++ b/framework/update/internal/platform/win/winupdateinstaller.h @@ -47,7 +47,8 @@ class WinUpdateInstaller : public IUpdateInstaller, public Contextable : Contextable(iocCtx) {} bool isInPlaceUpdateSupported() const override; - Ret applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi& ui) override; + RetVal prepareUpdate(const muse::io::path_t& packagePath) override; + Ret finalizeUpdate(const muse::io::path_t& preparedPath, const InstallProgressUi& ui) override; private: //! Identifier shared with the installer-registered task and the HKLM key; diff --git a/framework/update/iupdateinstaller.h b/framework/update/iupdateinstaller.h index 09ba28ea4c..424f5249aa 100644 --- a/framework/update/iupdateinstaller.h +++ b/framework/update/iupdateinstaller.h @@ -23,6 +23,7 @@ #define MUSE_UPDATE_IUPDATEINSTALLER_H #include "types/ret.h" +#include "types/retval.h" #include "io/path.h" #include "modularity/imoduleinterface.h" @@ -43,13 +44,19 @@ class IUpdateInstaller : MODULE_GLOBAL_INTERFACE //! downloaded installer for the user to run manually. virtual bool isInPlaceUpdateSupported() const = 0; - //! Unpack `packagePath`, spawn the standalone helper that replaces the - //! current install location once this process exits, and relaunches the - //! application. Returns OK if the helper was successfully spawned; the - //! caller must then quit the application. + //! Heavy phase: validate the downloaded package and stage everything the + //! swap needs. Safe to run off the UI thread while the app keeps running, + //! so failures can still be surfaced to the user. Returns the prepared + //! payload to pass to finalizeUpdate(). + virtual RetVal prepareUpdate(const muse::io::path_t& packagePath) = 0; + + //! Fast phase, run on user confirmation: spawn the standalone helper that + //! replaces the current install location once this process exits and + //! relaunches the application. Returns OK if the helper was successfully + //! spawned; the caller must then quit the application. //! //! `ui` is what the helper should show while it installs. - virtual Ret applyUpdate(const muse::io::path_t& packagePath, const InstallProgressUi& ui) = 0; + virtual Ret finalizeUpdate(const muse::io::path_t& preparedPath, const InstallProgressUi& ui) = 0; }; } From 2c37fe32a7fd54d77b48ace832f0c598d5cbce94 Mon Sep 17 00:00:00 2001 From: Eism Date: Thu, 20 Aug 2026 15:54:14 +0300 Subject: [PATCH 13/13] fixed sign check on windows --- framework/update/helper/updatetask_win.cpp | 42 +++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/framework/update/helper/updatetask_win.cpp b/framework/update/helper/updatetask_win.cpp index 43a7e76e91..0457c32406 100644 --- a/framework/update/helper/updatetask_win.cpp +++ b/framework/update/helper/updatetask_win.cpp @@ -672,9 +672,40 @@ struct Registration { std::wstring installDir; std::wstring packageType; // "msi" or "exe" std::wstring installArgs; - std::wstring certSubject; + std::wstring certSubject; //!< expected signer(s), "|"-separated; usually derived from `certFrom` + std::wstring certFrom; //!< signed file - the package being installed - to take the signer from }; +//! An explicit name wins - it is the only way to accept two while a certificate +//! is being rotated; otherwise the signer of the package being installed, so +//! that only whoever signed the application can update it. +std::wstring expectedSigner(const Registration& registration) +{ + if (!registration.certSubject.empty()) { + return registration.certSubject; + } + + if (!registration.certFrom.empty()) { + std::wstring signer; + if (verifySignature(registration.certFrom, signer) && !signer.empty()) { + logLine(L"register-task: expected signer taken from " + registration.certFrom + L": " + signer); + return signer; + } + + logLine(L"register-task: could not read the signer of " + registration.certFrom); + } + + //! NOTE: A repair installs from the cached copy of the package, which carries + //! no signature; keep what an earlier run worked out rather than refuse everything. + const std::wstring registered = regReadString(shared::registryKeyPath(registration.appId), + shared::REG_VALUE_CERT_SUBJECT); + if (!registered.empty()) { + logLine(L"register-task: keeping the expected signer already registered: " + registered); + } + + return registered; +} + int registerTask(const Registration& registration) { const std::wstring& appId = registration.appId; @@ -725,10 +756,12 @@ int registerTask(const Registration& registration) } regWriteString(key, shared::REG_VALUE_INSTALL_ARGS, registration.installArgs); - regWriteString(key, shared::REG_VALUE_CERT_SUBJECT, registration.certSubject); - if (registration.certSubject.empty()) { - logLine(L"register-task: warning - no --cert-subject given, updates will be refused"); + const std::wstring certSubject = expectedSigner(registration); + regWriteString(key, shared::REG_VALUE_CERT_SUBJECT, certSubject); + + if (certSubject.empty()) { + logLine(L"register-task: warning - no expected signer could be established, updates will be refused"); } ComScope com; @@ -1336,6 +1369,7 @@ int runCommandLine() : std::wstring(shared::PACKAGE_TYPE_MSI); registration.installArgs = valueOf(parsed, L"--install-args"); registration.certSubject = valueOf(parsed, L"--cert-subject"); + registration.certFrom = valueOf(parsed, L"--cert-from"); returnCode = registerTask(registration); } else if (isUnregister) {