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/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 # ---------------- 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 b917adf530..285dc2359a 100644 --- a/framework/stubs/update/appupdatescenariostub.cpp +++ b/framework/stubs/update/appupdatescenariostub.cpp @@ -32,24 +32,26 @@ void AppUpdateScenarioStub::checkForUpdate(bool) { } -bool AppUpdateScenarioStub::checkInProgress() const +bool AppUpdateScenarioStub::hasUpdate() const +{ + return false; +} + +bool AppUpdateScenarioStub::hasReadyUpdate() const { return false; } -muse::async::Notification AppUpdateScenarioStub::checkInProgressChanged() const +muse::async::Notification AppUpdateScenarioStub::hasReadyUpdateChanged() const { return {}; } -bool AppUpdateScenarioStub::hasUpdate() const +std::string AppUpdateScenarioStub::readyUpdateVersion() const { - return false; + return {}; } -muse::async::Promise AppUpdateScenarioStub::showUpdate() +void AppUpdateScenarioStub::installReadyUpdate() { - return muse::async::Promise([](auto /*resolve*/, auto reject) { - return reject(int(muse::Ret::Code::UnknownError), "stub"); - }); } diff --git a/framework/stubs/update/appupdatescenariostub.h b/framework/stubs/update/appupdatescenariostub.h index 250003f66f..a89dc12a7d 100644 --- a/framework/stubs/update/appupdatescenariostub.h +++ b/framework/stubs/update/appupdatescenariostub.h @@ -31,10 +31,12 @@ 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; - muse::async::Promise showUpdate() 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..e0eb4b46a7 100644 --- a/framework/stubs/update/appupdateservicestub.cpp +++ b/framework/stubs/update/appupdateservicestub.cpp @@ -42,3 +42,28 @@ RetVal AppUpdateServiceStub::downloadRelease() { return RetVal::make_ret(Ret::Code::NotSupported); } + +bool AppUpdateServiceStub::canAutoInstall() const +{ + return false; +} + +RetVal AppUpdateServiceStub::prepareUpdate(const muse::io::path_t&) +{ + return RetVal(make_ret(Ret::Code::NotSupported)); +} + +Ret AppUpdateServiceStub::finalizeUpdate(const muse::io::path_t&) +{ + 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..baa9bb48fb 100644 --- a/framework/stubs/update/appupdateservicestub.h +++ b/framework/stubs/update/appupdateservicestub.h @@ -31,5 +31,12 @@ class AppUpdateServiceStub : public IAppUpdateService async::Promise > checkForUpdate() override; const RetVal& lastCheckResult() const override; RetVal downloadRelease() override; + + bool canAutoInstall() const override; + RetVal prepareUpdate(const muse::io::path_t& packagePath) override; + Ret finalizeUpdate(const muse::io::path_t& preparedPath) override; + + bool isReleaseDownloaded() const override; + muse::io::path_t downloadedReleasePath() 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 289d6422ee..b2593a4b69 100644 --- a/framework/stubs/update/updateconfigurationstub.cpp +++ b/framework/stubs/update/updateconfigurationstub.cpp @@ -52,11 +52,29 @@ muse::async::Notification UpdateConfigurationStub::needCheckForUpdateChanged() c return n; } +bool UpdateConfigurationStub::autoInstallEnabled() const +{ + return false; +} + +void UpdateConfigurationStub::setAutoInstallEnabled(bool) +{ +} + 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 +109,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..de96f3294b 100644 --- a/framework/stubs/update/updateconfigurationstub.h +++ b/framework/stubs/update/updateconfigurationstub.h @@ -37,9 +37,15 @@ 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; + 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 +57,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/CMakeLists.txt b/framework/update/CMakeLists.txt index d2369b024b..de21eff5af 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 @@ -45,12 +46,43 @@ target_sources(muse_update PRIVATE internal/appupdatescenario.h internal/appupdateservice.cpp internal/appupdateservice.h + internal/downloadfiledevice.cpp + internal/downloadfiledevice.h ) +if (OS_IS_MAC) + target_sources(muse_update PRIVATE + 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) +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 + 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..3a25d13a0b --- /dev/null +++ b/framework/update/helper/CMakeLists.txt @@ -0,0 +1,97 @@ +# 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_WIN) + list(APPEND UPDATE_HELPER_SRC + platform_win.cpp + updatetask_win.cpp + updatetask_win.h + updateui_win.cpp + updateui_win.h + ../internal/platform/win/winupdateshared.h + ) +else() + 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}) + +set_target_properties(${UPDATE_HELPER_TARGET} PROPERTIES + CXX_STANDARD 17 + 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 + 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 + msi # MsiInstallProductW and the external UI handler + user32 # the progress window + gdi32 # and what it is drawn with + ) +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 ${UPDATE_HELPER_INSTALL_DIR} + ) +endif() diff --git a/framework/update/helper/main.cpp b/framework/update/helper/main.cpp new file mode 100644 index 0000000000..87c12e05e6 --- /dev/null +++ b/framework/update/helper/main.cpp @@ -0,0 +1,54 @@ +/* + * 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 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) +{ +#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 new file mode 100644 index 0000000000..ae355294eb --- /dev/null +++ b/framework/update/helper/platform.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 + +#include + +namespace platform { +//! Block until the process `pid` has exited, or `timeoutMs` elapses. +//! 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. +//! 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 Linux. +bool relaunch(const std::string& path); +#endif +} diff --git a/framework/update/helper/platform_mac.cpp b/framework/update/helper/platform_mac.cpp new file mode 100644 index 0000000000..75873eb88d --- /dev/null +++ b/framework/update/helper/platform_mac.cpp @@ -0,0 +1,87 @@ +/* + * 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 { +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 true; + } + sleepMs(step); + waited += step; + } + return ::kill(static_cast(pid), 0) != 0; +} + +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..60d2cb5ca5 --- /dev/null +++ b/framework/update/helper/platform_unix.cpp @@ -0,0 +1,116 @@ +/* + * 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 +#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); +} + +//! 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 { +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 true; + } + sleepMs(step); + waited += step; + } + return ::kill(static_cast(pid), 0) != 0; +} + +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; + if (::stat(path.c_str(), &st) != 0 || !S_ISREG(st.st_mode)) { + return false; + } + + return hasAppImageHeader(path); +} + +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..b539752880 --- /dev/null +++ b/framework/update/helper/platform_win.cpp @@ -0,0 +1,40 @@ +/* + * 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 { +bool waitForProcessExit(long long pid, int timeoutMs) +{ + HANDLE hProc = OpenProcess(SYNCHRONIZE, FALSE, static_cast(pid)); + if (!hProc) { + // Already gone, or no access. + return true; + } + const DWORD result = WaitForSingleObject(hProc, static_cast(timeoutMs)); + CloseHandle(hProc); + + return result == WAIT_OBJECT_0; +} +} diff --git a/framework/update/helper/swap.cpp b/framework/update/helper/swap.cpp new file mode 100644 index 0000000000..cc41f0094e --- /dev/null +++ b/framework/update/helper/swap.cpp @@ -0,0 +1,234 @@ +/* + * 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 +#include +#include + +#ifdef __linux__ +#include +#include +#include +#ifndef RENAME_EXCHANGE +#define RENAME_EXCHANGE (1 << 1) +#endif +#endif + +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). +//! 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); + 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; +} + +//! 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) +{ + 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) { + 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; + + if (!fs::exists(src, ec)) { + logLine("error: src does not exist"); + return 1; + } + + // 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 (!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. 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. 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(); + + 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; + } + } + fs::rename(staging, dst, ec); + if (ec) { + logLine("error: failed to move staged install into place: " + ec.message()); + + std::error_code rbEc; + fs::rename(backup, dst, rbEc); + return 1; + } + std::error_code rmEc; + fs::remove_all(backup, rmEc); + } + + // 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); + // 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..9691cdc258 --- /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, 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 ] +//! +//! 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..0457c32406 --- /dev/null +++ b/framework/update/helper/updatetask_win.cpp @@ -0,0 +1,1387 @@ +/* + * 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 +#include + +#include "platform.h" +#include "updateui_win.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); + } +} + +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; +} + +//! 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) +{ + 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); + if (!arguments.empty()) { + commandLine += L" " + arguments; + } + + 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, 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); + + if (process) { + *process = processInfo.hProcess; + } else { + ::CloseHandle(processInfo.hProcess); + } + } + + if (hasEnvironment) { + ::DestroyEnvironmentBlock(environment); + } + ::CloseHandle(primaryToken); + ::CloseHandle(userToken); + + 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 +// ============================================================================ + +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; //!< 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; + 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); + + 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; + 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; +} + +// ============================================================================ +// 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 +// ============================================================================ + +//! 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)); + + //! 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); + } + + // 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); + + 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; + } + + // 4. 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; + } + + // 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 + // 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)); + + DWORD exitCode = 0; + + if (packageType == shared::PACKAGE_TYPE_MSI) { + //! 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; + } + + logLine(L"apply-run: installing " + staged + L" with " + properties); + + //! 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; + } + + 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) + // 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()); + ui.stop(); + return 1; + } + + ui.setPercent(100); + + logLine(L"apply-run: installed successfully, exit code " + std::to_wstring(exitCode)); + + // 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()); + + // 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. + } + + 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); + + //! 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; + 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"); + registration.certFrom = valueOf(parsed, L"--cert-from"); + + 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/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/iappupdatescenario.h b/framework/update/iappupdatescenario.h index a0aca9513f..c02738e71a 100644 --- a/framework/update/iappupdatescenario.h +++ b/framework/update/iappupdatescenario.h @@ -39,10 +39,14 @@ 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; + + //! 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 6122e3208b..2ae3efcca2 100644 --- a/framework/update/iappupdateservice.h +++ b/framework/update/iappupdateservice.h @@ -41,5 +41,13 @@ class IAppUpdateService : MODULE_CONTEXT_INTERFACE virtual async::Promise > checkForUpdate() = 0; virtual const RetVal& lastCheckResult() const = 0; virtual RetVal downloadRelease() = 0; + + virtual bool isReleaseDownloaded() const = 0; + virtual muse::io::path_t downloadedReleasePath() const = 0; + + virtual bool canAutoInstall() const = 0; + + virtual RetVal prepareUpdate(const muse::io::path_t& packagePath) = 0; + virtual Ret finalizeUpdate(const muse::io::path_t& preparedPath) = 0; }; } diff --git a/framework/update/internal/appupdatescenario.cpp b/framework/update/internal/appupdatescenario.cpp index e64af94910..55a3217838 100644 --- a/framework/update/internal/appupdatescenario.cpp +++ b/framework/update/internal/appupdatescenario.cpp @@ -26,9 +26,11 @@ #include +#include "async/async.h" +#include "global/concurrency/concurrent.h" +#include "runtime.h" #include "types/val.h" #include "translation.h" -#include "defer.h" #include "log.h" using namespace muse; @@ -48,7 +50,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 +62,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 +92,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,14 +169,86 @@ 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(); + + 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. + if (service()->canAutoInstall() && multiwindowsProvider()->windowCount() == 1) { + return prepareAndInstall(packagePath); } - return askToCloseAppAndCompleteInstall(rv.val.toString()); + + return askToCloseAppAndCompleteInstall(packagePath); +} + +Promise AppUpdateScenario::prepareAndInstall(const io::path_t& packagePath) +{ + //! NOTE: The heavy phase (unpacking and verification) runs in the + //! background while the app keeps running, so failures can still fall + //! back to the manual flow; the confirmation dialog is shown once + //! everything is staged, making the restart itself instant. + return make_promise([this, packagePath](auto resolve, auto) { + 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. " + "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, preparedPath](const IInteractive::Result& res, auto resolve) { + if (res.isButton(IInteractive::Button::Cancel)) { + return resolve(muse::make_ret(Ret::Code::Cancel)); + } + + const Ret ret = service()->finalizeUpdate(preparedPath); + if (!ret) { + LOGE() << "failed to finalize update, falling back to manual install: " << ret.toString(); + askToCloseAppAndCompleteInstall(packagePath).onResolve(this, [resolve](const Ret& r) { + (void)resolve(r); + }); + return Promise::dummy_result(); + } + + //! 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) +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.") @@ -205,16 +260,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()); }); } @@ -223,3 +278,101 @@ bool AppUpdateScenario::shouldIgnoreUpdate(const ReleaseInfo& info) const { return info.version == configuration()->skippedReleaseVersion() && !configuration()->checkForUpdateTestMode(); } + +void AppUpdateScenario::downloadUpdateInBackground() +{ + if (m_bgDownloadInProgress || hasReadyUpdate()) { + return; + } + + if (!hasUpdate() || !configuration()->autoInstallEnabled()) { + 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, [](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; + } + + 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; + } + + if (!service()->canAutoInstall() || multiwindowsProvider()->windowCount() != 1) { + askToCloseAppAndCompleteInstall(m_readyPackagePath).onResolve(this, [](const Ret&) {}); + return; + } + + prepareAndInstall(m_readyPackagePath).onResolve(this, [](const Ret&) {}); + }); +} diff --git a/framework/update/internal/appupdatescenario.h b/framework/update/internal/appupdatescenario.h index 3ef072e5d7..9fae8ce4cf 100644 --- a/framework/update/internal/appupdatescenario.h +++ b/framework/update/internal/appupdatescenario.h @@ -50,11 +50,13 @@ 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 hasReadyUpdate() const override; + async::Notification hasReadyUpdateChanged() const override; + std::string readyUpdateVersion() const override; + + void installReadyUpdate() override; private: muse::async::Promise processUpdateError(int errorCode); @@ -63,12 +65,20 @@ 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 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; 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 a28ec5fe02..50184ff666 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 @@ -32,6 +33,8 @@ #include "update/updateerrors.h" +#include "downloadfiledevice.h" + #include "defer.h" #include "translation.h" #include "log.h" @@ -45,6 +48,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 +191,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 +206,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); @@ -216,15 +226,41 @@ const RetVal& AppUpdateService::lastCheckResult() const RetVal AppUpdateService::downloadRelease() { + if (m_downloadInProgress) { + return RetVal::make_ok(m_updateProgress); + } + if (!m_networkManager) { m_networkManager = networkManagerCreator()->makeNetworkManager(); } 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 = 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; + 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); } @@ -235,30 +271,44 @@ RetVal AppUpdateService::downloadRelease() Progress mutProgress = downloadProgress.val; mutProgress.cancel(); m_updateProgress.canceled().disconnect(this); - }); + }, Asyncable::Mode::SetReplace); - 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); + }, Asyncable::Mode::SetReplace); + + downloadProgress.val.finished().onReceive(this, [this, finalPath, partialPath, offset](const ProgressResult& res) { + m_downloadInProgress = false; - downloadProgress.val.finished().onReceive(this, [this, info, buff](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); + + //! 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; + } - const Ret ret = fileSystem()->writeFile(installerPath, ByteArray::fromQByteArrayNoCopy(buff->data())); + //! 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))); + }, Asyncable::Mode::SetReplace); + m_downloadInProgress = true; return RetVal::make_ok(m_updateProgress); } @@ -349,21 +399,21 @@ 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: 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 +422,75 @@ 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(); +} + +RetVal AppUpdateService::prepareUpdate(const muse::io::path_t& packagePath) +{ + return updateInstaller()->prepareUpdate(packagePath); +} + +Ret AppUpdateService::finalizeUpdate(const muse::io::path_t& preparedPath) +{ + return updateInstaller()->finalizeUpdate(preparedPath, 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) { QUrl url = QString::fromStdString(configuration()->previousAppReleasesNotesUrl()); @@ -455,8 +554,64 @@ void AppUpdateService::downloadPreviousReleasesNotes(const Version& updateVersio void AppUpdateService::clear() { m_lastCheckResult = RetVal::make_ok(ReleaseInfo()); +} + +void AppUpdateService::cleanupStalePackages(const std::string& keepFileName) +{ + 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()); + } + + const io::path_t dir = configuration()->updateDataPath(); + if (!fileSystem()->exists(dir)) { + return; + } + + RetVal entries = fileSystem()->scanFiles(dir, {}, io::ScanMode::FilesAndFoldersInCurrentDir); + if (!entries.ret) { + return; + } -#if !defined(Q_OS_LINUX) - fileSystem()->remove(configuration()->updateDataPath()); -#endif + //! 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); + } + } +} + +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 = 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 958bb5d7eb..a62c5902b2 100644 --- a/framework/update/internal/appupdateservice.h +++ b/framework/update/internal/appupdateservice.h @@ -31,8 +31,10 @@ #include "global/isysteminfo.h" #include "network/inetworkmanagercreator.h" +#include "ui/iuiconfiguration.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 +45,8 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as GlobalInject requestParamsProvider; GlobalInject networkManagerCreator; GlobalInject application; + GlobalInject updateInstaller; + GlobalInject uiConfiguration; public: AppUpdateService(const modularity::ContextPtr& iocCtx) @@ -54,6 +58,13 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as const RetVal& lastCheckResult() const override; RetVal downloadRelease() override; + bool canAutoInstall() const override; + RetVal prepareUpdate(const muse::io::path_t& packagePath) override; + Ret finalizeUpdate(const muse::io::path_t& preparedPath) override; + + bool isReleaseDownloaded() const override; + muse::io::path_t downloadedReleasePath() const override; + private: friend class AppUpdateServiceTests; @@ -69,9 +80,16 @@ class AppUpdateService : public IAppUpdateService, public Contextable, public as RetVal parseRelease(const QByteArray& json) const; - std::string platformFileSuffix() const; + InstallProgressUi makeInstallProgressUi() const; + + //! Ordered list of acceptable asset suffixes for this platform, most + //! preferred first. + std::vector platformFileSuffixes() const; 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); @@ -80,5 +98,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/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/internal/platform/linux/linuxupdateinstaller.cpp b/framework/update/internal/platform/linux/linuxupdateinstaller.cpp new file mode 100644 index 0000000000..1707318c8b --- /dev/null +++ b/framework/update/internal/platform/linux/linuxupdateinstaller.cpp @@ -0,0 +1,206 @@ +/* + * 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; +} + +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 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 RetVal(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 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); + } + + 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()); + + 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); + + // 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 = { + "--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..8f49a23200 --- /dev/null +++ b/framework/update/internal/platform/linux/linuxupdateinstaller.h @@ -0,0 +1,61 @@ +/* + * 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; + 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 + //! 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/platform/mac/macupdateinstaller.cpp b/framework/update/internal/platform/mac/macupdateinstaller.cpp new file mode 100644 index 0000000000..c9b7a75a00 --- /dev/null +++ b/framework/update/internal/platform/mac/macupdateinstaller.cpp @@ -0,0 +1,256 @@ +/* + * 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 + +#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 + && ::access(io::dirpath(bundlePath.toStdString()).toStdString().c_str(), W_OK) != 0) { + return false; + } + + return true; +} + +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 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 staging(stagingDir); + if (staging.exists()) { + staging.removeRecursively(); + } + QDir().mkpath(stagingDir); + + // 2. Unpack the dmg preserving extended attributes and code signatures. + ret = unpackDmg(package, stagingDir); + if (!ret) { + return RetVal(ret); + } + + // 3. 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 RetVal(make_ret(Err::UnknownError)); + } + + // 4. Remove the quarantine attribute set by the download. + QProcess::execute("/usr/bin/xattr", { "-dr", "com.apple.quarantine", stagingApp }); + + 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); + } + + // 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); + 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); + + // 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 = { + "--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(); +} + +//! 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; + 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 new file mode 100644 index 0000000000..ab0129538c --- /dev/null +++ b/framework/update/internal/platform/mac/macupdateinstaller.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 . + */ +#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; + 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). + muse::io::path_t currentBundlePath() const; + + //! 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; +}; +} + +#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..6ca8b58501 --- /dev/null +++ b/framework/update/internal/platform/stub/updateinstallerstub.cpp @@ -0,0 +1,40 @@ +/* + * 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; +} + +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 new file mode 100644 index 0000000000..7f6bd56bef --- /dev/null +++ b/framework/update/internal/platform/stub/updateinstallerstub.h @@ -0,0 +1,39 @@ +/* + * 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; + RetVal prepareUpdate(const muse::io::path_t& packagePath) override; + Ret finalizeUpdate(const muse::io::path_t& preparedPath, const InstallProgressUi& ui) override; +}; +} + +#endif // MUSE_UPDATE_UPDATEINSTALLERSTUB_H diff --git a/framework/update/internal/platform/win/winupdateinstaller.cpp b/framework/update/internal/platform/win/winupdateinstaller.cpp new file mode 100644 index 0000000000..9bd48e425a --- /dev/null +++ b/framework/update/internal/platform/win/winupdateinstaller.cpp @@ -0,0 +1,281 @@ +/* + * 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; +} + +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; + 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()); + + 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(); + 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..35b10e5f55 --- /dev/null +++ b/framework/update/internal/platform/win/winupdateinstaller.h @@ -0,0 +1,58 @@ +/* + * 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; + 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; + //! 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..f6b99f8b85 --- /dev/null +++ b/framework/update/internal/platform/win/winupdateshared.h @@ -0,0 +1,405 @@ +/* + * 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 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) +{ + 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"; + + 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); +} + +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; + } + } 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(); + } + } + + return !request.packagePath.empty(); +} +} diff --git a/framework/update/internal/updateconfiguration.cpp b/framework/update/internal/updateconfiguration.cpp index 295b23a1eb..cee764fc8a 100644 --- a/framework/update/internal/updateconfiguration.cpp +++ b/framework/update/internal/updateconfiguration.cpp @@ -34,6 +34,8 @@ 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() { @@ -53,6 +55,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 +89,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(); @@ -95,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(); @@ -131,11 +155,12 @@ 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::downloadsPath() const +{ + return globalConfiguration()->downloadsPath(); } muse::io::path_t UpdateConfiguration::updateRequestHistoryJsonPath() const diff --git a/framework/update/internal/updateconfiguration.h b/framework/update/internal/updateconfiguration.h index 7c8f315ae1..d018789000 100644 --- a/framework/update/internal/updateconfiguration.h +++ b/framework/update/internal/updateconfiguration.h @@ -52,9 +52,15 @@ 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; + 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; @@ -66,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 a681de9c02..20ba788848 100644 --- a/framework/update/iupdateconfiguration.h +++ b/framework/update/iupdateconfiguration.h @@ -45,9 +45,17 @@ 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; + 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; @@ -59,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/iupdateinstaller.h b/framework/update/iupdateinstaller.h new file mode 100644 index 0000000000..424f5249aa --- /dev/null +++ b/framework/update/iupdateinstaller.h @@ -0,0 +1,63 @@ +/* + * 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 "types/retval.h" +#include "io/path.h" + +#include "modularity/imoduleinterface.h" + +#include "updatetypes.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; + + //! 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 finalizeUpdate(const muse::io::path_t& preparedPath, const InstallProgressUi& ui) = 0; +}; +} + +#endif // MUSE_UPDATE_IUPDATEINSTALLER_H 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/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..08227cd07d --- /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", "Update") + accentButton: true + + onClicked: { + updateBannerModel.install() + } + } + } +} diff --git a/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml b/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml index 645f10a178..1f330b3326 100644 --- a/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml +++ b/framework/update/qml/Muse/Update/internal/AppReleaseInfoBottomPanel.qml @@ -68,6 +68,9 @@ RowLayout { Layout.alignment: Qt.AlignVCenter text: qsTrc("update", "Remind me later") + icon: IconCode.CLOCK + + orientation: Qt.Horizontal navigation.name: "RemindMeLaterButton" navigation.panel: root.navigationPanel @@ -84,8 +87,10 @@ RowLayout { Layout.alignment: Qt.AlignVCenter text: qsTrc("update", "Install update") + icon: IconCode.IMPORT accentButton: true + orientation: Qt.Horizontal navigation.name: "InstallUpdateButton" navigation.panel: root.navigationPanel 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..91273ae572 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 @@ -89,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\" }" "]," @@ -136,13 +141,35 @@ 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_configuration, downloadsPath()) + .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 +402,130 @@ 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_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) + 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); +} diff --git a/framework/update/tests/mocks/updateconfigurationmock.h b/framework/update/tests/mocks/updateconfigurationmock.h index 8ea2cb5cd7..f8c9e2bb76 100644 --- a/framework/update/tests/mocks/updateconfigurationmock.h +++ b/framework/update/tests/mocks/updateconfigurationmock.h @@ -39,9 +39,15 @@ 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)); + 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)); @@ -53,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)); }; } diff --git a/framework/update/updatemodule.cpp b/framework/update/updatemodule.cpp index 9d8a614dd5..fde8aaed8f 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,17 @@ #include "internal/appupdatescenario.h" #include "internal/appupdateservice.h" +#include "iupdateinstaller.h" +#if defined(Q_OS_MAC) +#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 + using namespace muse::update; using namespace muse::modularity; @@ -51,7 +64,18 @@ void UpdateModule::registerExports() { m_configuration = std::make_shared(globalCtx()); +#if defined(Q_OS_MAC) + 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 + 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 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;