diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index 7cf58d3e0..c84a46528 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -5,15 +5,25 @@ #include "../client/network/update/client/release_channel.h" #include "../interface/window_main.h" +#include #include +#include +#include #include #include #include #include +#include #include #include #include +// Executable that, when it sits next to the downloaded update installer, is installed instead of +// it. A packager shipping a custom build - or someone testing one - can drop the file there and +// have Cockatrice run it rather than the official installer, without the release channel having to +// host an installer for that build. It is a drop-in and is run with the same arguments. +static const QString UPDATE_INSTALLER_OVERRIDE = "Cockatrice-Update-Override.exe"; + DlgUpdate::DlgUpdate(QWidget *parent) : QDialog(parent) { @@ -203,6 +213,24 @@ void DlgUpdate::setLabel(const QString &newText) statusLabel->setText(newText); } +void DlgUpdate::warnInstallerIsWaiting() +{ + // Modeless on purpose: the installer asks the application to close exactly once (see + // CloseMatchingApps in NSIS.template.in) and only polls afterwards, while Qt drops spontaneous + // close events - a WM_CLOSE from the installer - for windows that are blocked by a modal + // widget (QGuiApplicationPrivate::processCloseEvent). An application modal message box here + // would therefore swallow the installer's one and only request for the rest of its wait. + auto *notification = new QMessageBox(QMessageBox::Warning, tr("Update"), + tr("The update installer is already running and waits about a " + "minute for Cockatrice to close. Cockatrice is still busy, so " + "save your work and close it before then. Otherwise the " + "installer gives up and the update is cancelled."), + QMessageBox::Ok, parentWidget()); + notification->setWindowModality(Qt::NonModal); + notification->setAttribute(Qt::WA_DeleteOnClose); + notification->show(); +} + void DlgUpdate::updateCheckError(const QString &errorString) { setLabel(tr("Error")); @@ -224,6 +252,12 @@ void DlgUpdate::downloadSuccessful(const QUrl &filepath) QString installerPath = filepath.toLocalFile(); + const QString overridePath = QDir(QFileInfo(installerPath).absolutePath()).filePath(UPDATE_INSTALLER_OVERRIDE); + if (QFileInfo::exists(overridePath)) { + qCInfo(DlgUpdateLog) << "Installing the update installer override instead of the download:" << overridePath; + installerPath = overridePath; + } + QString appDir = QDir::toNativeSeparators(QCoreApplication::applicationDirPath()); QProcess process; process.setProgram(installerPath); @@ -240,8 +274,31 @@ void DlgUpdate::downloadSuccessful(const QUrl &filepath) // Try to open the installer. If it opens, quit Cockatrice if (process.startDetached()) { - QMetaObject::invokeMethod(static_cast(parent()), "close", Qt::QueuedConnection); - qCInfo(DlgUpdateLog) << "Opened downloaded update file successfully - closing Cockatrice"; + qCInfo(DlgUpdateLog) << "Opened update installer successfully - closing Cockatrice"; + // Close the main window synchronously so file locks are released before the NSIS installer + // (already launched) starts replacing files. This also flushes settings and shuts down the + // tabs, but only when the close is actually accepted: MainWindow may veto it for a running + // card DB update, an open game, or an unsaved deck, and closeForUpdate() also reports a + // close already in progress (reached from a nested event loop while a shutdown prompt is + // up). Only quit when the shutdown really ran - otherwise keep running so the user can + // resolve the blocker, and warn them that the installer only waits about a minute + // before it gives up and cancels the update. + if (auto *window = qobject_cast(parent())) { + if (window->closeForUpdate()) { + QTimer::singleShot(0, qApp, [] { QCoreApplication::exit(0); }); + } else { + warnInstallerIsWaiting(); + } + } else { + // Not a MainWindow, so no faithful close can be requested - but the parent still gets + // its close() call, which is what the code before this change did and lets it flush + // settings and shut down its tabs. Leaving is then unconditional: the installer is + // already running against a process that still holds locks on the files it replaces. + if (auto *widget = parentWidget()) { + widget->close(); + } + QTimer::singleShot(0, qApp, [] { QCoreApplication::exit(0); }); + } close(); } else { setLabel(tr("Error")); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.h b/cockatrice/src/interface/widgets/dialogs/dlg_update.h index 7bd0020d5..2cef46e70 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.h @@ -42,6 +42,7 @@ private: void addStopDownloadAndRemoveOthers(bool enable); void beginUpdateCheck(); void setLabel(const QString &text); + void warnInstallerIsWaiting(); QLabel *statusLabel, *descriptionLabel; QProgressBar *progress; QPushButton *manualDownload, *gotoDownload, *ok, *stopDownload; diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index ae967312d..77cc85130 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -871,7 +871,6 @@ void MainWindow::actShow() void MainWindow::closeEvent(QCloseEvent *event) { // workaround Qt bug where closeEvent gets called twice - static bool bClosingDown = false; if (bClosingDown) { return; } @@ -900,6 +899,18 @@ void MainWindow::closeEvent(QCloseEvent *event) tabSupervisor->deleteLater(); } +bool MainWindow::closeForUpdate() +{ + // A shutdown is already being handled (e.g. this is reached from a nested event loop while + // closeEvent() is blocked on a user prompt). close() would hit the re-entrancy guard and return + // true without any shutdown having happened, so report faithfully instead: the caller must keep + // the process alive until the user resolves whatever is blocking the close. + if (bClosingDown) { + return false; + } + return close(); +} + void MainWindow::changeEvent(QEvent *event) { if (event->type() == QEvent::LanguageChange) { diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index fc7980d16..1c4dde300 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -167,6 +167,7 @@ private: LagMonitor lagMonitor; ///< watches the main thread for event loop stalls LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph bool bHasActivated, askedForDbUpdater; + bool bClosingDown = false; ///< guards closeEvent() against re-entrancy bool skipStartupAutoConnect = false; bool startupAutoConnectAttempted = false; bool firstRunWizardActive = false; @@ -205,6 +206,13 @@ public: return tabSupervisor; } + /** + * @brief Closes the window so an update installer can replace the running binaries. + * Returns true only if the shutdown actually ran (settings flushed, tabs shut down); + * false if the close was vetoed by the user or is already in progress. + */ + bool closeForUpdate(); + protected: void closeEvent(QCloseEvent *event) override; void changeEvent(QEvent *event) override;