diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..ca58000
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1 @@
+* @johnqherman
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 ade2688..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:
@@ -94,8 +97,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 +129,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 +175,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 +215,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 +243,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 +265,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 +330,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..3ca968f 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(); }
@@ -427,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()));
});
}
@@ -1554,39 +1551,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; }