From 34c272459d495989f7c4686d087f45d19262ddf2 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Fri, 3 Jul 2026 15:05:51 -0400 Subject: [PATCH 1/4] feat: replace WinSparkle with app-styled in-app updater WinSparkle's stock dialog looked alien next to the Qt theme, never closed the app before running the installer (its shutdown callback was never registered), and the still-running exe stayed locked so NSIS silently kept the old binary: updates appeared to work but did not. Windows/Linux now use UpdateChecker + a themed UpdateDialog: daily background check against latest.json, in-dialog download with sha256 verification, then quit, silent NSIS install (/S), and relaunch. macOS keeps stock Sparkle. appcast-windows.xml still publishes so existing WinSparkle installs can reach this build. Also bump version to 1.0.1. --- .github/workflows/build.yml | 36 ++--- CMakeLists.txt | 41 +---- docs/AUTOUPDATE.md | 93 ----------- packaging/Info.plist.in | 2 - src/app/AutoUpdater.cpp | 159 +++++++++++++++---- src/app/AutoUpdater.h | 38 +++-- src/app/AutoUpdaterSparkle.mm | 4 - src/app/UpdateChecker.cpp | 23 ++- src/app/UpdateChecker.h | 19 ++- src/main.cpp | 2 - src/ui/MainWindow.cpp | 42 +---- src/ui/MainWindow.h | 1 - src/ui/UpdateDialog.cpp | 290 ++++++++++++++++++++++++++++++++++ src/ui/UpdateDialog.h | 63 ++++++++ src/ui/theme/Theme.cpp | 2 - 15 files changed, 555 insertions(+), 260 deletions(-) delete mode 100644 docs/AUTOUPDATE.md create mode 100644 src/ui/UpdateDialog.cpp create mode 100644 src/ui/UpdateDialog.h diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ade2688..5d78650 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -94,8 +94,6 @@ jobs: run: choco install nsis -y - name: Set up MSVC - # still node20 upstream (no node24 release as of v1.13.0); the runner - # force-runs it on node24, which works but logs a deprecation warning uses: ilammy/msvc-dev-cmd@v1 with: arch: x64 @@ -128,20 +126,14 @@ jobs: uses: actions/upload-artifact@v7 with: name: OpenMix-windows - # explicit patterns so the raw build/OpenMix.exe binary isn't shipped path: | build/OpenMix-*-win64.exe build/OpenMix-*-win64.zip build-macos: - # pinned: macos-latest is migrating to macOS 26 (June 2026); keep the - # known-good image for the universal Qt build until 26 is validated runs-on: macos-15 steps: - uses: actions/checkout@v7 - - # runner image ships untrusted third-party taps (aws/tap) that make - # every brew call (incl. ccache-action's) log a tap-trust warning - name: Remove untrusted brew taps run: brew untap aws/tap 2>/dev/null || true @@ -180,8 +172,6 @@ jobs: -DCMAKE_OSX_DEPLOYMENT_TARGET=12.0 - name: Build - # cap parallelism: full -j exhausts the 7GB arm64 runner on Qt PCH - # (cc1plus), starving/killing the runner ("lost communication") run: cmake --build build --config Release --parallel 2 - name: Test @@ -222,10 +212,6 @@ jobs: OpenMix-windows/* OpenMix-macos/* - # generate the Sparkle/WinSparkle appcasts + installer tree and rsync them to - # the openmix.dev VPS. Requires a Sparkle EdDSA private key in - # OPENMIX_SPARKLE_ED_PRIVATE_KEY and OPENMIX_DEPLOY_* SSH secrets - # (see docs/AUTOUPDATE.md). appcast: if: startsWith(github.ref, 'refs/tags/v') needs: [release] @@ -254,7 +240,6 @@ jobs: mkdir -p site/download/${TAG} cp OpenMix-macos/* OpenMix-windows/* OpenMix-linux/* site/download/${TAG}/ 2>/dev/null || true - # macOS: sign the DMG with Sparkle's EdDSA key and emit a full appcast SIGN=$(find /opt/homebrew /usr/local -name sign_update -type f 2>/dev/null | head -1) DMG=$(ls OpenMix-macos/*.dmg | head -1) if [ -n "$SPARKLE_KEY" ] && [ -n "$SIGN" ] && [ -n "$DMG" ]; then @@ -277,7 +262,8 @@ jobs: echo "::warning::No Sparkle key/tool/DMG; skipping macOS appcast (updates will not verify)" fi - # Windows: WinSparkle RSS appcast + # legacy feed: pre-1.0.1 clients still run WinSparkle and need this + # appcast to reach the first WinSparkle-free build EXE=$(ls OpenMix-windows/*.exe | head -1) if [ -n "$EXE" ]; then cat > site/appcast-windows.xml < site/latest.json < site/latest.json < site/latest.json <' echo '' @@ -335,10 +327,6 @@ jobs: ssh -i ~/.ssh/deploy "${SSH_USER}@${SSH_HOST}" \ "cd '${DEPLOY_PATH}/download' && ls -1d v*/ 2>/dev/null | sed 's:/*\$::' | sort -V | head -n -5 | xargs -r rm -rf --" - # the site bakes release data (downloads table, past releases) at build - # time, so rebuild it whenever a release ships. Needs a fine-grained PAT - # with Actions write on openmix.dev in OPENMIX_SITE_DISPATCH_TOKEN; - # non-fatal so a missing token never blocks a release. - name: Rebuild openmix.dev env: GH_TOKEN: ${{ secrets.OPENMIX_SITE_DISPATCH_TOKEN }} diff --git a/CMakeLists.txt b/CMakeLists.txt index 74c6f95..b4ffeee 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.16) project(OpenMix - VERSION 1.0.0 + VERSION 1.0.1 DESCRIPTION "Stage mixing software for live theatre that lets engineers program, automate, and run cues seamlessly across 30+ digital mixing consoles" LANGUAGES CXX ) @@ -157,6 +157,7 @@ set(SOURCES src/ui/ActiveCueInfoPanel.cpp src/ui/FxSetupDialog.cpp src/ui/WelcomeDialog.cpp + src/ui/UpdateDialog.cpp src/ui/HelpDialog.cpp src/ui/MarkerNotesDialog.cpp src/ui/PopOutWindow.cpp @@ -370,40 +371,10 @@ target_link_libraries(${PROJECT_NAME} PRIVATE openmix_liblo) target_link_libraries(${PROJECT_NAME} PRIVATE RtMidi) -# --- auto-update frameworks (Windows = WinSparkle, macOS = Sparkle) ---------- -# Linux has no silent-update framework; AutoUpdater falls back to the notify-and -# -link checker there, so nothing extra is linked. -if(WIN32) - FetchContent_Declare(winsparkle - URL https://github.com/vslavik/winsparkle/releases/download/v0.8.1/WinSparkle-0.8.1.zip) - FetchContent_MakeAvailable(winsparkle) - # the 0.8.1 zip layout (nesting, x64/Release/ subdir) is fragile; glob for the - # header, x64 lib, and x64 dll so the exact structure can't break the build - file(GLOB_RECURSE _ws_hdrs "${winsparkle_SOURCE_DIR}/*winsparkle.h") - file(GLOB_RECURSE _ws_libs "${winsparkle_SOURCE_DIR}/*WinSparkle.lib") - file(GLOB_RECURSE _ws_dlls "${winsparkle_SOURCE_DIR}/*WinSparkle.dll") - list(GET _ws_hdrs 0 _ws_hdr) - get_filename_component(WINSPARKLE_INCLUDE ${_ws_hdr} DIRECTORY) - foreach(f ${_ws_libs}) - if(f MATCHES "x64") - set(WINSPARKLE_LIB ${f}) - endif() - endforeach() - foreach(f ${_ws_dlls}) - if(f MATCHES "x64") - set(WINSPARKLE_DLL ${f}) - endif() - endforeach() - if(NOT WINSPARKLE_INCLUDE OR NOT WINSPARKLE_LIB OR NOT WINSPARKLE_DLL) - message(FATAL_ERROR "WinSparkle header/x64 lib/dll not found under ${winsparkle_SOURCE_DIR}") - endif() - target_include_directories(${PROJECT_NAME} PRIVATE ${WINSPARKLE_INCLUDE}) - target_link_libraries(${PROJECT_NAME} PRIVATE ${WINSPARKLE_LIB}) - add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - ${WINSPARKLE_DLL} $) - install(FILES ${WINSPARKLE_DLL} DESTINATION ${CMAKE_INSTALL_BINDIR}) -elseif(APPLE) +# --- auto-update framework (macOS = Sparkle) --------------------------------- +# Windows and Linux use the in-app updater (UpdateChecker + UpdateDialog), so +# nothing extra is linked there. +if(APPLE) FetchContent_Declare(sparkle URL https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz) FetchContent_MakeAvailable(sparkle) diff --git a/docs/AUTOUPDATE.md b/docs/AUTOUPDATE.md deleted file mode 100644 index 5ce9494..0000000 --- a/docs/AUTOUPDATE.md +++ /dev/null @@ -1,93 +0,0 @@ -# Auto-update setup - -OpenMix ships silent auto-updates via Sparkle (macOS) and WinSparkle (Windows). -Linux builds fall back to a notify-and-link check against GitHub Releases. - -At runtime the app polls a signed appcast, downloads the new installer in the -background, verifies its EdDSA signature, and installs on next launch. The whole -pipeline hinges on one signing key and a place to host the appcasts. - -## One-time key setup - -1. Generate an EdDSA key pair with Sparkle's tool (ships in the Sparkle release, - `bin/generate_keys`): - - ``` - ./bin/generate_keys - ``` - - It prints a public key and stores the private key in the login keychain. To - export the private key for CI: - - ``` - ./bin/generate_keys -x sparkle_private_key.pem - ``` - -2. Put the **public** key in `packaging/Info.plist.in`, replacing the - `REPLACE_WITH_SPARKLE_ED_PUBLIC_KEY` placeholder in the `SUPublicEDKey` entry. - This is compiled into the macOS bundle and is what Sparkle checks signatures - against. - -3. Add the **private** key to the GitHub repo as an Actions secret named - `OPENMIX_SPARKLE_ED_PRIVATE_KEY`. CI uses it to sign the release DMG and emit - the `sparkle:edSignature` in the macOS appcast. Without it, the appcast job - logs a warning and macOS clients will not accept the update. - -## Appcast + installer hosting (openmix.dev VPS) - -Everything is served from the VPS. The apps poll: - -- macOS: `https://openmix.dev/appcast-macos.xml` (`Info.plist.in` `SUFeedURL`) -- Windows: `https://openmix.dev/appcast-windows.xml` (`AutoUpdater.cpp`) -- Linux notify-fallback: `https://openmix.dev/latest.json` (`UpdateChecker.cpp`) - -Installers live under `https://openmix.dev/download//`. The `appcast` job in -`.github/workflows/build.yml` builds the appcasts + `latest.json`, lays out the -installer tree, and `rsync`s the whole `site/` over SSH to the VPS webroot on -every `v*` tag. After upload it prunes `download/` to the newest 5 release dirs -(version-sorted); the appcasts and `latest.json` at the root are overwritten each -release. - -### Deploy secrets (GitHub Actions) - -| Secret | Value | -|--------|-------| -| `OPENMIX_DEPLOY_SSH_KEY` | private SSH key whose public half is in the deploy user's `authorized_keys` | -| `OPENMIX_DEPLOY_HOST` | `openmix.dev` (or the VPS IP) | -| `OPENMIX_DEPLOY_USER` | deploy user (e.g. `deploy`) | -| `OPENMIX_DEPLOY_PATH` | webroot rsync target (e.g. `/var/www/openmix.dev`) | - -A GitHub Release is still created as a mirror, but the app updater points only at -openmix.dev. - -## Release flow - -1. Bump `VERSION` / `project(... VERSION x.y.z)` in `CMakeLists.txt`. -2. Tag and push: `git tag vx.y.z && git push origin vx.y.z`. -3. CI: `build-*` jobs make installers, `release` attaches them to the GitHub - Release, `appcast` signs + rsyncs installers and appcasts to openmix.dev. -4. Installed clients pick up the update on their next scheduled check. - -## Code signing (required for a clean silent update) - -Silent install only works without a Gatekeeper / SmartScreen prompt if the -installer is signed by a trusted authority: - -- **macOS**: Developer ID sign the `.app` and notarize the `.dmg`. Unsigned - builds will refuse to auto-install or warn the user. -- **Windows**: Authenticode sign the NSIS `.exe`. Unsigned builds trip - SmartScreen and break the silent flow. - -Sparkle's EdDSA signature protects the download's integrity; OS code signing is -what suppresses the security prompt on install. Both are needed for a truly -hands-off update. - -## Files - -- `src/app/AutoUpdater.{h,cpp}` — platform abstraction; picks WinSparkle / - Sparkle / notify-fallback at build time. -- `src/app/AutoUpdaterSparkle.mm` — macOS Sparkle bridge. -- `src/app/UpdateChecker.{h,cpp}` — Linux GitHub Releases notify-and-link check. -- `packaging/Info.plist.in` — macOS bundle plist with `SUFeedURL` / - `SUPublicEDKey`. -- `.github/workflows/build.yml` — `appcast` job that signs + publishes appcasts. diff --git a/packaging/Info.plist.in b/packaging/Info.plist.in index c69d955..45690b1 100644 --- a/packaging/Info.plist.in +++ b/packaging/Info.plist.in @@ -30,8 +30,6 @@ SUAutomaticallyUpdate - SUPublicEDKey Th9esMBuKy6nRfgTeOA7eeUr5Ms3fBaGtLZFI5hyH7c= diff --git a/src/app/AutoUpdater.cpp b/src/app/AutoUpdater.cpp index 6d7d673..49b0271 100644 --- a/src/app/AutoUpdater.cpp +++ b/src/app/AutoUpdater.cpp @@ -1,67 +1,158 @@ #include "AutoUpdater.h" -#include - -#if defined(Q_OS_WIN) -#include -#elif defined(Q_OS_MACOS) +#if defined(Q_OS_MACOS) // implemented in AutoUpdaterSparkle.mm extern "C" void openmix_sparkle_init(); extern "C" void openmix_sparkle_check_with_ui(); extern "C" void openmix_sparkle_cleanup(); +#else +#include "UpdateChecker.h" +#include "ui/UpdateDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +#if defined(Q_OS_WIN) +#include #endif namespace OpenMix { +#if !defined(Q_OS_MACOS) namespace { -// Per-OS appcast feeds published by CI to GitHub Pages. Sparkle/WinSparkle poll -// these for new versions + signatures. -constexpr const char* kWindowsAppcast = - "https://openmix.dev/appcast-windows.xml"; +constexpr const char* kLastCheckKey = "Updates/lastCheckTime"; +constexpr const char* kSkippedVersionKey = "Updates/skippedVersion"; +constexpr int kCheckIntervalHours = 24; } // namespace +#endif AutoUpdater::AutoUpdater(QObject* parent) : QObject(parent) {} AutoUpdater::~AutoUpdater() { -#if defined(Q_OS_WIN) - win_sparkle_cleanup(); -#elif defined(Q_OS_MACOS) +#if defined(Q_OS_MACOS) openmix_sparkle_cleanup(); #endif } -bool AutoUpdater::hasSilentUpdates() { -#if defined(Q_OS_WIN) || defined(Q_OS_MACOS) - return true; -#else - return false; -#endif -} - -void AutoUpdater::initialize() { -#if defined(Q_OS_WIN) - win_sparkle_set_appcast_url(kWindowsAppcast); - const std::wstring version = QCoreApplication::applicationVersion().toStdWString(); - win_sparkle_set_app_details(L"OpenMix", L"OpenMix", version.c_str()); - win_sparkle_set_automatic_check_for_updates(1); - win_sparkle_set_update_check_interval(24 * 60 * 60); // daily - win_sparkle_init(); -#elif defined(Q_OS_MACOS) +void AutoUpdater::initialize(QWidget* dialogParent) { +#if defined(Q_OS_MACOS) // Sparkle reads SUFeedURL / SUPublicEDKey / SUEnableAutomaticChecks from the // app's Info.plist; the controller starts the background updater here. + Q_UNUSED(dialogParent); openmix_sparkle_init(); +#else + m_dialogParent = dialogParent; + + // check shortly after startup, then hourly against the daily deadline (a + // catch-up timer survives sleep/suspend better than one long 24h timer) + QTimer::singleShot(10 * 1000, this, &AutoUpdater::maybeScheduledCheck); + auto* timer = new QTimer(this); + connect(timer, &QTimer::timeout, this, &AutoUpdater::maybeScheduledCheck); + timer->start(60 * 60 * 1000); #endif } void AutoUpdater::checkForUpdatesNow() { -#if defined(Q_OS_WIN) - win_sparkle_check_update_with_ui(); -#elif defined(Q_OS_MACOS) +#if defined(Q_OS_MACOS) openmix_sparkle_check_with_ui(); #else - // no silent updater on this platform: let the UI run the notify-and-link check - emit manualCheckRequested(); + runCheck(/*manual=*/true); #endif } +#if !defined(Q_OS_MACOS) + +void AutoUpdater::maybeScheduledCheck() { + const QDateTime lastCheck = QSettings().value(kLastCheckKey).toDateTime(); + if (lastCheck.isValid() && + lastCheck.secsTo(QDateTime::currentDateTimeUtc()) < kCheckIntervalHours * 60 * 60) + return; + runCheck(/*manual=*/false); +} + +void AutoUpdater::runCheck(bool manual) { + if (m_dialog) { // a prompt is already up; don't stack another check on it + m_dialog->raise(); + m_dialog->activateWindow(); + return; + } + + auto* checker = new UpdateChecker(this); + const auto stamp = []() { + QSettings().setValue(kLastCheckKey, QDateTime::currentDateTimeUtc()); + }; + + connect(checker, &UpdateChecker::updateAvailable, this, + [this, checker, manual, stamp](const UpdateInfo& info) { + checker->deleteLater(); + stamp(); + if (!manual && info.version == QSettings().value(kSkippedVersionKey).toString()) + return; + showUpdateDialog(info); + }); + connect(checker, &UpdateChecker::upToDate, this, [this, checker, manual, stamp]() { + checker->deleteLater(); + stamp(); + if (manual) + QMessageBox::information(m_dialogParent, tr("Up to Date"), + tr("You are running the latest version (%1).") + .arg(QCoreApplication::applicationVersion())); + }); + connect(checker, &UpdateChecker::checkFailed, this, + [this, checker, manual, stamp](const QString& error) { + checker->deleteLater(); + stamp(); + if (manual) + QMessageBox::warning(m_dialogParent, tr("Update Check Failed"), + tr("Could not check for updates:\n%1").arg(error)); + else + qWarning() << "scheduled update check failed:" << error; + }); + checker->checkForUpdates(); +} + +void AutoUpdater::showUpdateDialog(const UpdateInfo& info) { + m_dialog = new UpdateDialog(info, m_dialogParent); + connect(m_dialog, &UpdateDialog::skipVersionRequested, this, [](const QString& version) { + QSettings().setValue(kSkippedVersionKey, version); + }); + connect(m_dialog, &UpdateDialog::readyToInstall, this, &AutoUpdater::launchInstallerAndQuit); + m_dialog->show(); // non-modal: never block a running show +} + +void AutoUpdater::launchInstallerAndQuit(const QString& installerPath) { + // close the main window first so the unsaved-show prompt runs before the + // installer launches; on cancel the dialog stays ready for another attempt + if (m_dialogParent && !m_dialogParent->close()) + return; + +#if defined(Q_OS_WIN) + // the running exe is locked by Windows, so the app must fully exit before + // NSIS copies files (the 2s delay); "start" is required so the elevation + // prompt works, and the app relaunches even if the install fails + const QString installer = QDir::toNativeSeparators(installerPath); + const QString app = QDir::toNativeSeparators(QCoreApplication::applicationFilePath()); + QProcess proc; + proc.setProgram("cmd.exe"); + proc.setNativeArguments(QString("/c timeout /t 2 /nobreak >nul & " + "start \"\" /wait \"%1\" /S & start \"\" \"%2\"") + .arg(installer, app)); + proc.setCreateProcessArgumentsModifier( + [](QProcess::CreateProcessArguments* args) { args->flags |= CREATE_NO_WINDOW; }); + proc.startDetached(); +#else + qInfo() << "test mode: would quit and run installer" << installerPath; +#endif + QCoreApplication::quit(); +} + +#endif // !Q_OS_MACOS + } // namespace OpenMix diff --git a/src/app/AutoUpdater.h b/src/app/AutoUpdater.h index 5420ab3..49a810c 100644 --- a/src/app/AutoUpdater.h +++ b/src/app/AutoUpdater.h @@ -1,17 +1,24 @@ #pragma once #include +#include + +class QWidget; namespace OpenMix { +class UpdateDialog; +struct UpdateInfo; + // Cross-platform automatic updates. -// - Windows: WinSparkle (silent background check, download, install, relaunch) -// - macOS: Sparkle (same, via an Objective-C++ bridge) -// - Linux/other: falls back to a GitHub-release check that notifies and links -// to the download (no silent self-install; handled by MainWindow's checker) +// - Windows/Linux: checks the release feed daily and shows the app-styled +// UpdateDialog; on Windows it downloads the installer, quits the app, runs +// a silent install, and relaunches +// - macOS: Sparkle (via an Objective-C++ bridge), which is native there // -// initialize() wires the framework and enables periodic background checks; call -// it once at startup. checkForUpdatesNow() runs a user-triggered check with UI. +// initialize() enables the periodic background checks; call it once at startup +// with the main window (dialog parent, closed before installing). +// checkForUpdatesNow() runs a user-triggered check with UI. class AutoUpdater : public QObject { Q_OBJECT @@ -19,16 +26,19 @@ class AutoUpdater : public QObject { explicit AutoUpdater(QObject* parent = nullptr); ~AutoUpdater() override; - // true if a real self-updating framework is compiled in for this platform - [[nodiscard]] static bool hasSilentUpdates(); - - void initialize(); + void initialize(QWidget* dialogParent); void checkForUpdatesNow(); - signals: - // emitted only on platforms without a silent updater, so the UI can fall - // back to the notify-and-link checker - void manualCheckRequested(); + private: +#if !defined(Q_OS_MACOS) + void maybeScheduledCheck(); + void runCheck(bool manual); + void showUpdateDialog(const UpdateInfo& info); + void launchInstallerAndQuit(const QString& installerPath); + + QWidget* m_dialogParent = nullptr; + QPointer m_dialog; +#endif }; } // namespace OpenMix diff --git a/src/app/AutoUpdaterSparkle.mm b/src/app/AutoUpdaterSparkle.mm index 2f0a516..697cc32 100644 --- a/src/app/AutoUpdaterSparkle.mm +++ b/src/app/AutoUpdaterSparkle.mm @@ -1,7 +1,3 @@ -// macOS Sparkle bridge for AutoUpdater. Compiled only on macOS. -// Sparkle reads SUFeedURL / SUPublicEDKey / SUEnableAutomaticChecks from the -// app bundle's Info.plist; this just owns the standard updater controller. - #import static SPUStandardUpdaterController* gUpdaterController = nil; diff --git a/src/app/UpdateChecker.cpp b/src/app/UpdateChecker.cpp index 2b6143a..fb0fc72 100644 --- a/src/app/UpdateChecker.cpp +++ b/src/app/UpdateChecker.cpp @@ -10,9 +10,16 @@ namespace OpenMix { namespace { -// small JSON published by CI: {"tag_name":"vX.Y.Z","html_url":"..."} +// small JSON published by CI: {"tag_name":"vX.Y.Z","html_url":"...", +// "windows_installer_url":"...","windows_installer_sha256":"..."} constexpr const char* kLatestReleaseApi = "https://openmix.dev/latest.json"; + +QString feedUrl() { + // override for local testing against a fake feed + const QString env = qEnvironmentVariable("OPENMIX_UPDATE_FEED_URL"); + return env.isEmpty() ? QString::fromLatin1(kLatestReleaseApi) : env; } +} // namespace UpdateChecker::UpdateChecker(QObject* parent) : QObject(parent), m_net(new QNetworkAccessManager(this)) {} @@ -37,7 +44,7 @@ bool UpdateChecker::isNewer(const QString& latest, const QString& current) { } void UpdateChecker::checkForUpdates() { - QNetworkRequest req{QUrl(kLatestReleaseApi)}; + QNetworkRequest req{QUrl(feedUrl())}; req.setHeader(QNetworkRequest::UserAgentHeader, "OpenMix"); req.setRawHeader("Accept", "application/vnd.github+json"); @@ -56,12 +63,16 @@ void UpdateChecker::checkForUpdates() { return; } - QString url = obj.value("html_url").toString(); - if (url.isEmpty()) - url = "https://openmix.dev/download"; + UpdateInfo info; + info.version = tag; + info.pageUrl = obj.value("html_url").toString(); + if (info.pageUrl.isEmpty()) + info.pageUrl = "https://openmix.dev/download"; + info.installerUrl = obj.value("windows_installer_url").toString(); + info.sha256 = obj.value("windows_installer_sha256").toString().toLower(); if (isNewer(tag, QCoreApplication::applicationVersion())) - emit updateAvailable(tag, url); + emit updateAvailable(info); else emit upToDate(); }); diff --git a/src/app/UpdateChecker.h b/src/app/UpdateChecker.h index da7e77b..2468cbc 100644 --- a/src/app/UpdateChecker.h +++ b/src/app/UpdateChecker.h @@ -7,22 +7,33 @@ class QNetworkAccessManager; namespace OpenMix { -// Checks GitHub Releases for a newer version than the running app. Compares the -// latest release tag (vMAJOR.MINOR.PATCH) against QCoreApplication version. +// Everything the update UI needs about the newest release. installerUrl/sha256 +// are only present when CI published a Windows installer for the release; when +// absent the UI degrades to linking the download page. +struct UpdateInfo { + QString version; // release tag, e.g. "v1.2.0" + QString pageUrl; // human download page + QString installerUrl; // direct Windows installer download (may be empty) + QString sha256; // hex sha256 of the installer (may be empty) +}; + +// Checks the published release feed for a newer version than the running app. +// Compares the latest release tag (vMAJOR.MINOR.PATCH) against QCoreApplication +// version. class UpdateChecker : public QObject { Q_OBJECT public: explicit UpdateChecker(QObject* parent = nullptr); - // GET the latest release from the GitHub API and emit one of the signals + // GET the latest release from the feed and emit one of the signals void checkForUpdates(); // true if 'latest' is a higher version than 'current' (leading 'v' ignored) [[nodiscard]] static bool isNewer(const QString& latest, const QString& current); signals: - void updateAvailable(const QString& version, const QString& url); + void updateAvailable(const OpenMix::UpdateInfo& info); void upToDate(); void checkFailed(const QString& error); diff --git a/src/main.cpp b/src/main.cpp index 22dacb8..272ad7d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,8 +14,6 @@ namespace OpenMix { -// Show tooltips almost immediately and keep them up longer than the platform -// default, so hovering an icon reveals its label without a long wait. class FastTooltipStyle : public QProxyStyle { public: using QProxyStyle::QProxyStyle; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 94c318f..817ab3d 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -27,7 +27,6 @@ #include "SettingsDialog.h" #include "app/Application.h" #include "app/AutoUpdater.h" -#include "app/UpdateChecker.h" #include "app/QLabClient.h" #include "core/AppLogger.h" #include "core/CueList.h" @@ -62,7 +61,6 @@ #include #include #include -#include #include #include #include @@ -99,11 +97,9 @@ MainWindow::MainWindow(Application* app, QWidget* parent) : QMainWindow(parent), updateActions(); updateTitle(); - // silent auto-updates; on Linux this falls back to the notify-and-link check + // silent auto-updates (daily background check + app-styled update dialog) m_autoUpdater = new AutoUpdater(this); - m_autoUpdater->initialize(); - connect(m_autoUpdater, &AutoUpdater::manualCheckRequested, this, - &MainWindow::runGithubUpdateCheck); + m_autoUpdater->initialize(this); } MainWindow::~MainWindow() { saveSettings(); } @@ -1554,39 +1550,7 @@ void MainWindow::showQuickStart() { dialog.exec(); } -void MainWindow::checkForUpdates() { - // hand off to the silent updater (WinSparkle/Sparkle); on Linux it signals - // back via manualCheckRequested to run the notify-and-link check below - m_autoUpdater->checkForUpdatesNow(); -} - -void MainWindow::runGithubUpdateCheck() { - statusBar()->showMessage(tr("Checking for updates..."), 3000); - auto* checker = new UpdateChecker(this); - connect(checker, &UpdateChecker::updateAvailable, this, - [this, checker](const QString& version, const QString& url) { - checker->deleteLater(); - const auto choice = QMessageBox::question( - this, tr("Update Available"), - tr("OpenMix %1 is available (you have %2).\n\nOpen the download page?") - .arg(version, QCoreApplication::applicationVersion()), - QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - if (choice == QMessageBox::Yes) - QDesktopServices::openUrl(QUrl(url)); - }); - connect(checker, &UpdateChecker::upToDate, this, [this, checker]() { - checker->deleteLater(); - QMessageBox::information(this, tr("Up to Date"), - tr("You are running the latest version (%1).") - .arg(QCoreApplication::applicationVersion())); - }); - connect(checker, &UpdateChecker::checkFailed, this, [this, checker](const QString& error) { - checker->deleteLater(); - QMessageBox::warning(this, tr("Update Check Failed"), - tr("Could not check for updates:\n%1").arg(error)); - }); - checker->checkForUpdates(); -} +void MainWindow::checkForUpdates() { m_autoUpdater->checkForUpdatesNow(); } void MainWindow::showFeatureGuide() { const QString html = tr( diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 55c0771..744740f 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -107,7 +107,6 @@ class MainWindow : public QMainWindow { void showQuickStart(); void showFeatureGuide(); void checkForUpdates(); - void runGithubUpdateCheck(); // notify-and-link fallback (no silent updater) void showEditHistoryDialog(); void exportCuesToCsv(); void showChannelUtilizationDialog(); diff --git a/src/ui/UpdateDialog.cpp b/src/ui/UpdateDialog.cpp new file mode 100644 index 0000000..b350875 --- /dev/null +++ b/src/ui/UpdateDialog.cpp @@ -0,0 +1,290 @@ +#include "UpdateDialog.h" +#include "WindowSizing.h" +#include "theme/Theme.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OpenMix { + +namespace { +QString displayVersion(QString v) { + if (v.startsWith('v')) + v.remove(0, 1); + return v; +} + +QString megabytes(qint64 bytes) { + return QString::number(bytes / (1024.0 * 1024.0), 'f', 1); +} +} // namespace + +UpdateDialog::UpdateDialog(const UpdateInfo& info, QWidget* parent) + : QDialog(parent), m_info(info), m_net(new QNetworkAccessManager(this)) { + setWindowTitle(tr("Software Update")); + setAttribute(Qt::WA_DeleteOnClose); + setMinimumWidth(460); + WindowSizing::widenOnShow(this); + setupUi(); + setState(State::Available); +} + +UpdateDialog::~UpdateDialog() { + if (m_reply) { + m_reply->disconnect(this); + m_reply->abort(); + } + if (m_state != State::ReadyToInstall) + removePartialFile(); +} + +bool UpdateDialog::installMode() const { +#if defined(Q_OS_WIN) + return !m_info.installerUrl.isEmpty(); +#else + // no silent self-install off Windows; the env var lets the download path be + // exercised on a dev box + return !m_info.installerUrl.isEmpty() && + qEnvironmentVariableIsSet("OPENMIX_UPDATE_TEST_INSTALL"); +#endif +} + +void UpdateDialog::setupUi() { + QVBoxLayout* mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(28, 24, 28, 20); + mainLayout->setSpacing(6); + + QLabel* title = new QLabel(tr("A new version of OpenMix is available"), this); + QFont titleFont = title->font(); + titleFont.setPointSize(titleFont.pointSize() + 6); + titleFont.setBold(true); + title->setFont(titleFont); + mainLayout->addWidget(title); + + QLabel* subtitle = new QLabel(tr("OpenMix %1 is now available. You have %2.") + .arg(displayVersion(m_info.version), + QCoreApplication::applicationVersion()), + this); + subtitle->setEnabled(false); // muted + mainLayout->addWidget(subtitle); + + QLabel* notesLink = new QLabel( + QString("%2").arg(m_info.pageUrl, tr("Release notes")), this); + notesLink->setOpenExternalLinks(true); + mainLayout->addWidget(notesLink); + mainLayout->addSpacing(16); + + m_statusLabel = new QLabel(this); + m_statusLabel->setWordWrap(true); + mainLayout->addWidget(m_statusLabel); + + m_progressBar = new QProgressBar(this); + m_progressBar->setTextVisible(false); + mainLayout->addWidget(m_progressBar); + mainLayout->addSpacing(10); + + QHBoxLayout* buttons = new QHBoxLayout(); + m_skipButton = new QPushButton(tr("Skip This Version"), this); + connect(m_skipButton, &QPushButton::clicked, this, [this]() { + emit skipVersionRequested(m_info.version); + close(); + }); + buttons->addWidget(m_skipButton); + buttons->addStretch(); + + m_pageButton = new QPushButton(tr("Open Download Page"), this); + connect(m_pageButton, &QPushButton::clicked, this, [this]() { + QDesktopServices::openUrl(QUrl(m_info.pageUrl)); + close(); + }); + buttons->addWidget(m_pageButton); + + m_cancelButton = new QPushButton(tr("Cancel"), this); + connect(m_cancelButton, &QPushButton::clicked, this, &UpdateDialog::cancelDownload); + buttons->addWidget(m_cancelButton); + + m_laterButton = new QPushButton(tr("Remind Me Later"), this); + connect(m_laterButton, &QPushButton::clicked, this, &QDialog::close); + buttons->addWidget(m_laterButton); + + m_installButton = new QPushButton(this); + m_installButton->setDefault(true); + m_installButton->setMinimumHeight(32); + connect(m_installButton, &QPushButton::clicked, this, &UpdateDialog::primaryAction); + buttons->addWidget(m_installButton); + mainLayout->addLayout(buttons); +} + +void UpdateDialog::setState(State state) { + m_state = state; + + m_statusLabel->setVisible(state != State::Available); + m_statusLabel->setStyleSheet( + state == State::Error ? QString("color: %1;").arg(Theme::Colors::AccentRed) : QString()); + m_progressBar->setVisible(state == State::Downloading); + m_cancelButton->setVisible(state == State::Downloading); + m_skipButton->setVisible(state == State::Available); + m_pageButton->setVisible(state == State::Error); + m_laterButton->setVisible(state != State::Downloading); + m_installButton->setVisible(state != State::Downloading); + + switch (state) { + case State::Available: + m_installButton->setText(installMode() ? tr("Install and Relaunch") + : tr("Open Download Page")); + break; + case State::Downloading: + m_statusLabel->setText(tr("Downloading OpenMix %1...").arg(displayVersion(m_info.version))); + break; + case State::ReadyToInstall: + m_statusLabel->setText(tr("Download complete. OpenMix will close and restart to install.")); + m_installButton->setText(tr("Install and Relaunch")); + break; + case State::Error: + m_installButton->setText(tr("Retry")); + m_laterButton->setText(tr("Close")); + break; + } +} + +void UpdateDialog::primaryAction() { + switch (m_state) { + case State::Available: + case State::Error: + if (installMode()) { + startDownload(); + } else { + QDesktopServices::openUrl(QUrl(m_info.pageUrl)); + close(); + } + break; + case State::ReadyToInstall: + emit readyToInstall(m_downloadPath); + break; + case State::Downloading: + break; + } +} + +void UpdateDialog::startDownload() { + QString name = QUrl(m_info.installerUrl).fileName(); + if (name.isEmpty()) + name = "OpenMix-update.exe"; + m_downloadPath = + QStandardPaths::writableLocation(QStandardPaths::TempLocation) + QDir::separator() + name; + + m_file = new QFile(m_downloadPath, this); + if (!m_file->open(QIODevice::WriteOnly | QIODevice::Truncate)) { + showError(tr("Could not write to %1: %2").arg(m_downloadPath, m_file->errorString())); + return; + } + + QNetworkRequest req{QUrl(m_info.installerUrl)}; + req.setHeader(QNetworkRequest::UserAgentHeader, "OpenMix"); + m_reply = m_net->get(req); + + connect(m_reply, &QNetworkReply::readyRead, this, [this]() { + if (m_file && m_file->write(m_reply->readAll()) < 0) { + showError(tr("Could not write to %1: %2").arg(m_downloadPath, m_file->errorString())); + m_reply->abort(); + } + }); + connect(m_reply, &QNetworkReply::downloadProgress, this, + [this](qint64 received, qint64 total) { + if (total > 0) { + m_progressBar->setRange(0, static_cast(total / 1024)); + m_progressBar->setValue(static_cast(received / 1024)); + m_statusLabel->setText(tr("Downloading OpenMix %1... %2 MB of %3 MB") + .arg(displayVersion(m_info.version), + megabytes(received), megabytes(total))); + } else { + m_progressBar->setRange(0, 0); // indeterminate + m_statusLabel->setText(tr("Downloading OpenMix %1... %2 MB") + .arg(displayVersion(m_info.version), + megabytes(received))); + } + }); + connect(m_reply, &QNetworkReply::finished, this, &UpdateDialog::finishDownload); + + setState(State::Downloading); +} + +void UpdateDialog::cancelDownload() { + if (m_reply) + m_reply->abort(); // finishDownload handles cleanup and the state change +} + +void UpdateDialog::finishDownload() { + QNetworkReply* reply = m_reply; + m_reply = nullptr; + reply->deleteLater(); + + if (m_file) { + m_file->close(); + } + + if (reply->error() == QNetworkReply::OperationCanceledError) { + removePartialFile(); + // an abort triggered by a write failure has already shown the error + if (m_state == State::Downloading) + setState(State::Available); + return; + } + if (reply->error() != QNetworkReply::NoError) { + removePartialFile(); + showError(tr("Download failed: %1").arg(reply->errorString())); + return; + } + + if (!m_info.sha256.isEmpty()) { + QCryptographicHash hash(QCryptographicHash::Sha256); + if (!m_file->open(QIODevice::ReadOnly) || !hash.addData(m_file)) { + removePartialFile(); + showError(tr("Could not verify the downloaded installer.")); + return; + } + m_file->close(); + if (QString::fromLatin1(hash.result().toHex()) != m_info.sha256) { + removePartialFile(); + showError(tr("The downloaded installer failed verification. Please try again.")); + return; + } + } + + m_file->deleteLater(); + m_file = nullptr; + // the user already chose to install; the ReadyToInstall state (with its + // button) is only revisited if the pre-install window close is cancelled + setState(State::ReadyToInstall); + emit readyToInstall(m_downloadPath); +} + +void UpdateDialog::showError(const QString& message) { + setState(State::Error); + m_statusLabel->setText(message); +} + +void UpdateDialog::removePartialFile() { + if (m_file) { + m_file->close(); + m_file->remove(); + m_file->deleteLater(); + m_file = nullptr; + } +} + +} // namespace OpenMix diff --git a/src/ui/UpdateDialog.h b/src/ui/UpdateDialog.h new file mode 100644 index 0000000..369ce1f --- /dev/null +++ b/src/ui/UpdateDialog.h @@ -0,0 +1,63 @@ +#pragma once + +#include "app/UpdateChecker.h" + +#include + +class QFile; +class QLabel; +class QNetworkAccessManager; +class QNetworkReply; +class QProgressBar; +class QPushButton; + +namespace OpenMix { + +// App-styled update prompt (replaces the stock WinSparkle window). Non-modal so +// a scheduled check can never block a live show. Two modes: +// - install: downloads the installer with in-dialog progress, verifies its +// sha256, then signals readyToInstall so AutoUpdater can relaunch +// - link: no installer published for this platform; opens the download page +// The dialog deletes itself on close. +class UpdateDialog : public QDialog { + Q_OBJECT + + public: + explicit UpdateDialog(const UpdateInfo& info, QWidget* parent = nullptr); + ~UpdateDialog() override; + + signals: + void skipVersionRequested(const QString& version); + void readyToInstall(const QString& installerPath); + + private: + enum class State { Available, Downloading, ReadyToInstall, Error }; + + [[nodiscard]] bool installMode() const; + void setupUi(); + void setState(State state); + void primaryAction(); + void startDownload(); + void cancelDownload(); + void finishDownload(); + void showError(const QString& message); + void removePartialFile(); + + UpdateInfo m_info; + State m_state = State::Available; + QString m_downloadPath; + + QNetworkAccessManager* m_net; + QNetworkReply* m_reply = nullptr; + QFile* m_file = nullptr; + + QLabel* m_statusLabel; + QProgressBar* m_progressBar; + QPushButton* m_installButton; + QPushButton* m_laterButton; + QPushButton* m_skipButton; + QPushButton* m_cancelButton; + QPushButton* m_pageButton; +}; + +} // namespace OpenMix diff --git a/src/ui/theme/Theme.cpp b/src/ui/theme/Theme.cpp index 979cc75..1a4adf6 100644 --- a/src/ui/theme/Theme.cpp +++ b/src/ui/theme/Theme.cpp @@ -6,8 +6,6 @@ namespace OpenMix { namespace Theme { -// Booth-friendly overrides: pure-white text, brighter borders and gridlines so -// the UI stays legible from a distance in a darkened room. static QString highContrastOverrides() { return QString(R"QSS( QWidget { color: #ffffff; } From 929dc2af93058b7774057b6954857c6e522e197a Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Fri, 3 Jul 2026 15:32:30 -0400 Subject: [PATCH 2/4] fix: About dialog showed hardcoded 0.1.0 instead of app version --- src/ui/MainWindow.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 817ab3d..3ca968f 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -423,9 +423,10 @@ void MainWindow::createActions() { QMessageBox::about( this, tr("About OpenMix"), tr("

OpenMix

" - "

Version 0.1.0

" + "

Version %1

" "

Stage mixing software for live theatre that lets engineers program, " - "automate, and run cues seamlessly across 30+ digital mixing consoles.

")); + "automate, and run cues seamlessly across 30+ digital mixing consoles.

") + .arg(QCoreApplication::applicationVersion())); }); } From 651679b072a528f452c7c0bd59d21cb15262eeff Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Fri, 3 Jul 2026 15:55:24 -0400 Subject: [PATCH 3/4] chore: add CODEOWNERS requiring owner review --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..ca58000 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @johnqherman From de7a9156cbdf3d7b61a3344f48673f20c4a1a2a1 Mon Sep 17 00:00:00 2001 From: "John Q. Herman" Date: Fri, 3 Jul 2026 15:59:42 -0400 Subject: [PATCH 4/4] ci: auto-tag and release when a vX.Y.Z branch merges to master --- .github/workflows/auto-release.yml | 64 ++++++++++++++++++++++++++++++ .github/workflows/build.yml | 3 ++ 2 files changed, 67 insertions(+) create mode 100644 .github/workflows/auto-release.yml diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..831da93 --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,64 @@ +name: Auto Release + +# Merging a branch named vX.Y.Z into master tags the merge commit and kicks +# off the tag-gated release pipeline in build.yml. The Build run is dispatched +# explicitly because tags pushed with GITHUB_TOKEN never trigger on-push +# workflows. + +on: + pull_request: + types: [closed] + branches: [master] + +jobs: + tag-and-release: + if: github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'v') + runs-on: ubuntu-24.04 + permissions: + contents: write + actions: write + env: + TAG: ${{ github.event.pull_request.head.ref }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + steps: + - name: Validate release branch name + id: check + run: | + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "release=true" >> "$GITHUB_OUTPUT" + else + echo "Branch '$TAG' is not vX.Y.Z; skipping release" + fi + + - uses: actions/checkout@v7 + if: steps.check.outputs.release == 'true' + with: + ref: ${{ env.MERGE_SHA }} + + # same guard as build.yml: a tag that disagrees with project(VERSION) + # would bake the wrong version into every artifact + - name: Check branch matches project version + if: steps.check.outputs.release == 'true' + run: | + VER=$(sed -n 's/^ *VERSION \([0-9.]*\)$/\1/p' CMakeLists.txt | head -1) + if [ "${TAG#v}" != "$VER" ]; then + echo "::error::Branch ${TAG} != project VERSION ${VER} in CMakeLists.txt" + exit 1 + fi + + - name: Create and push tag + if: steps.check.outputs.release == 'true' + run: | + if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then + echo "::error::Tag ${TAG} already exists" + exit 1 + fi + git tag "$TAG" "$MERGE_SHA" + git push origin "refs/tags/${TAG}" + + - name: Dispatch release build + if: steps.check.outputs.release == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: gh workflow run build.yml --ref "refs/tags/${TAG}" -R "$GITHUB_REPOSITORY" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5d78650..b8fb10e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,9 @@ on: tags: ["v*"] pull_request: branches: [master] + # auto-release.yml dispatches this on the tag it creates (GITHUB_TOKEN tag + # pushes never fire the on-push trigger) + workflow_dispatch: jobs: build-linux: