#include "dlg_update.h" #include "../../../client/settings/cache_settings.h" #include "../client/network/update/client/client_update_checker.h" #include "../client/network/update/client/release_channel.h" #include "../interface/window_main.h" #include "update/client/update_downloader.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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) { // Handle layout statusLabel = new QLabel(this); statusLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); statusLabel->setWordWrap(true); descriptionLabel = new QLabel(tr("Current release channel") + QString(": %1").arg(tr(SettingsCache::instance().getUpdateReleaseChannel()->getName().toUtf8())), this); progress = new QProgressBar(this); buttonBox = new QDialogButtonBox(this); buttonBox->setFixedWidth(350); ok = new QPushButton("Close", this); manualDownload = new QPushButton(tr("Reinstall"), this); stopDownload = new QPushButton(tr("Cancel Download"), this); gotoDownload = new QPushButton(tr("Open Download Page"), this); addStopDownloadAndRemoveOthers(false); // Add all buttons to box enableUpdateButton(false); // Unless we know there's an update available, you can't install buttonBox->addButton(ok, QDialogButtonBox::AcceptRole); connect(gotoDownload, &QPushButton::clicked, this, &DlgUpdate::gotoDownloadPage); //! \todo Make reinstall button actually do something when clicked. // connect(manualDownload, &QPushButton::clicked, this, &DlgUpdate::downloadUpdate); connect(stopDownload, &QPushButton::clicked, this, &DlgUpdate::cancelDownload); connect(ok, &QPushButton::clicked, this, &DlgUpdate::closeDialog); auto *parentLayout = new QVBoxLayout(this); parentLayout->addWidget(descriptionLabel); parentLayout->addWidget(statusLabel); parentLayout->addWidget(progress); parentLayout->addWidget(buttonBox); setLayout(parentLayout); setWindowTitle(tr("Check for Client Updates")); setFixedHeight(this->sizeHint().height()); setFixedWidth(this->sizeHint().width()); // Check for SSL (this probably isn't necessary) if (!QSslSocket::supportsSsl()) { enableUpdateButton(false); QMessageBox::critical(this, tr("Error"), tr("Cockatrice was not built with SSL support, therefore you cannot download updates " "automatically! \nPlease visit the download page to update manually.")); } // Initialize the checker and downloader class uDownloader = new UpdateDownloader(this); connect(uDownloader, &UpdateDownloader::downloadSuccessful, this, &DlgUpdate::downloadSuccessful); connect(uDownloader, &UpdateDownloader::progressMade, this, &DlgUpdate::downloadProgressMade); connect(uDownloader, &UpdateDownloader::error, this, &DlgUpdate::downloadError); // Check for updates beginUpdateCheck(); } void DlgUpdate::closeDialog() { accept(); } void DlgUpdate::gotoDownloadPage() { QDesktopServices::openUrl(SettingsCache::instance().getUpdateReleaseChannel()->getManualDownloadUrl()); } void DlgUpdate::downloadUpdate(const QString &releaseName) { setLabel(tr("Downloading update: %1").arg(releaseName)); addStopDownloadAndRemoveOthers(true); // Will remove all other buttons uDownloader->beginDownload(updateUrl); } void DlgUpdate::cancelDownload() { emit uDownloader->stopDownload(); setLabel("Download canceled"); addStopDownloadAndRemoveOthers(false); downloadProgressMade(0, 1); } void DlgUpdate::beginUpdateCheck() { progress->setMinimum(0); progress->setMaximum(0); setLabel(tr("Checking for updates...")); auto checker = new ClientUpdateChecker(this); connect(checker, &ClientUpdateChecker::finishedCheck, this, &DlgUpdate::finishedUpdateCheck); connect(checker, &ClientUpdateChecker::error, this, &DlgUpdate::updateCheckError); checker->check(); } void DlgUpdate::finishedUpdateCheck(bool needToUpdate, bool isCompatible, Release *release) { QString publishDate, versionName; // Update the UI to say we've finished progress->setMaximum(100); setLabel(tr("Finished checking for updates")); // If there are no available builds, then they can't auto update. enableUpdateButton(isCompatible); if (isCompatible) { // If there is an update, save its URL and work out its name updateUrl = release->getDownloadUrl(); } // Give the user the appropriate message if (!needToUpdate) { // If there's no need to update, tell them that. However we still allow them to run the // downloader themselves if there's a compatible build QMessageBox::information( window(), tr("No Update Available"), tr("Cockatrice is up to date!") + "

" + tr("You are already running the latest version available in the chosen release channel.") + "
" + "" + tr("Current version") + QString(": %1
").arg(VERSION_STRING) + "" + tr("Selected release channel") + QString(": %1").arg(tr(SettingsCache::instance().getUpdateReleaseChannel()->getName().toUtf8()))); return; } publishDate = release->getPublishDate().toString(QLocale().dateFormat(QLocale::LongFormat)); if (isCompatible) { if (QtUtils::confirm(window(), tr("Update Available"), tr("A new version of Cockatrice is available!") + "

" + "" + tr("New version") + QString(": %1
").arg(release->getName()) + "" + tr("Released") + QString(": %1 (").arg(publishDate, release->getDescriptionUrl()) + tr("Changelog") + ")

" + tr("Do you want to update now?"))) { downloadUpdate(release->getName()); } else { closeDialog(); } } else { QMessageBox::information( window(), tr("Update Available"), tr("A new version of Cockatrice is available!") + "

" + "" + tr("New version") + QString(": %1
").arg(release->getName()) + "" + tr("Released") + QString(": %1 (").arg(publishDate, release->getDescriptionUrl()) + tr("Changelog") + ")

" + tr("Unfortunately, the automatic updater failed to find a compatible download. \nYou may have to " "manually download the new version.") + "

" + tr("Please check the releases page on our Github and download the build for your " "system.") .arg(release->getDescriptionUrl())); } } void DlgUpdate::enableUpdateButton(bool enable) { manualDownload->setEnabled(enable); } void DlgUpdate::addStopDownloadAndRemoveOthers(bool enable) { if (enable) { buttonBox->addButton(stopDownload, QDialogButtonBox::ActionRole); buttonBox->removeButton(manualDownload); buttonBox->removeButton(gotoDownload); } else { buttonBox->removeButton(stopDownload); buttonBox->addButton(manualDownload, QDialogButtonBox::ActionRole); buttonBox->addButton(gotoDownload, QDialogButtonBox::ActionRole); } } void DlgUpdate::enableOkButton(bool enable) { ok->setEnabled(enable); } 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")); QMessageBox::critical(this, tr("Update Error"), tr("An error occurred while checking for updates:") + QString(" ") + errorString); } void DlgUpdate::downloadError(const QString &errorString) { setLabel(tr("Error")); enableUpdateButton(true); QMessageBox::critical(this, tr("Update Error"), tr("An error occurred while downloading an update:") + QString(" ") + errorString); } void DlgUpdate::downloadSuccessful(const QUrl &filepath) { setLabel(tr("Installing...")); 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); // NSIS needs the /D= argument to be an UNQUOTED string, even if it contains spaces. Qt likes to quote arguments if // they contain spaces, so we use the windows exclusive QProcess::setNativeArguments in the only case where this is // relevant, which preserves the argument unquoted. // // /PID= tells the installer which process to wait for before it replaces anything. We launch the installer first // and only quit afterwards, so it starts while this process is still alive and still has every Qt runtime DLL // mapped, and a mapped DLL cannot be deleted. Without the handle the installer can only poll for an image name and // guess, and a wrong guess is what leaves the update writing new executables next to runtime files of the previous // version, which fails on the next start with "The procedure entry point ... could not be located in the dynamic // link library // ...Qt6QuickWidgets.dll". /D= has to stay the last parameter, which is why it is last here too. #ifdef Q_OS_WIN process.setNativeArguments(QString("/R /PID=%1 /D=%2").arg(QCoreApplication::applicationPid()).arg(appDir)); #else // Linux/macOS: normal argument passing (not relevant since they update differently.) process.setArguments({"/R", QString("/D=%1").arg(appDir)}); #endif // Try to open the installer. If it opens, quit Cockatrice if (process.startDetached()) { 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")); QMessageBox::critical(this, tr("Update Error"), tr("Cockatrice is unable to open the installer.") + "

" + tr("Try to update manually by closing Cockatrice and running the installer.") + "
" + tr("Download location") + QString(": %1").arg(filepath.toLocalFile())); } } void DlgUpdate::downloadProgressMade(qint64 bytesRead, qint64 totalBytes) { progress->setMaximum(totalBytes); progress->setValue(bytesRead); }