Path: blob/master/src/duckstation-qt/autoupdaterdialog.cpp
7513 views
// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin <[email protected]>1// SPDX-License-Identifier: CC-BY-NC-ND-4.023#include "autoupdaterdialog.h"4#include "mainwindow.h"5#include "qthost.h"6#include "qtprogresscallback.h"7#include "qtutils.h"8#include "scmversion/scmversion.h"9#include "unzip.h"1011#include "core/core.h"1213#include "util/http_downloader.h"14#include "util/translation.h"1516#include "common/assert.h"17#include "common/error.h"18#include "common/file_system.h"19#include "common/log.h"20#include "common/minizip_helpers.h"21#include "common/path.h"22#include "common/string_util.h"2324#include "fmt/format.h"2526#include <QtCore/QCoreApplication>27#include <QtCore/QFileInfo>28#include <QtCore/QJsonArray>29#include <QtCore/QJsonDocument>30#include <QtCore/QJsonObject>31#include <QtCore/QJsonValue>32#include <QtCore/QProcess>33#include <QtCore/QString>34#include <QtCore/QTimer>35#include <QtWidgets/QCheckBox>36#include <QtWidgets/QDialog>37#include <QtWidgets/QMessageBox>38#include <QtWidgets/QProgressDialog>39#include <QtWidgets/QPushButton>4041#include "moc_autoupdaterdialog.cpp"4243using namespace Qt::StringLiterals;4445// Interval at which HTTP requests are polled.46static constexpr u32 HTTP_POLL_INTERVAL = 10;4748#if defined(_WIN32)49#include "common/windows_headers.h"50#include <shellapi.h>51#elif defined(__APPLE__)52#include "common/cocoa_tools.h"53#else54#include <sys/stat.h>55#endif5657// Logic to detect whether we can use the auto updater.58// Requires that the channel be defined by the buildbot.59#if __has_include("scmversion/tag.h")60#include "scmversion/tag.h"61#else62#define UPDATER_RELEASE_CHANNEL "preview"63#endif6465// Updater asset information.66// clang-format off67#if defined(_WIN32)68#if defined(CPU_ARCH_X64) && defined(CPU_ARCH_SSE41)69#define UPDATER_EXPECTED_EXECUTABLE "duckstation-qt-x64-ReleaseLTCG.exe"70#define UPDATER_ASSET_FILENAME "duckstation-windows-x64-release.zip"71#elif defined(CPU_ARCH_X64)72#define UPDATER_EXPECTED_EXECUTABLE "duckstation-qt-x64-ReleaseLTCG-SSE2.exe"73#define UPDATER_ASSET_FILENAME "duckstation-windows-x64-sse2-release.zip"74#elif defined(CPU_ARCH_ARM64)75#define UPDATER_EXPECTED_EXECUTABLE "duckstation-qt-ARM64-ReleaseLTCG.exe"76#define UPDATER_ASSET_FILENAME "duckstation-windows-arm64-release.zip"77#endif78#elif defined(__APPLE__)79#define UPDATER_ASSET_FILENAME "duckstation-mac-release.zip"80#elif defined(__linux__)81#if defined(CPU_ARCH_X64) && defined(CPU_ARCH_SSE41)82#define UPDATER_ASSET_FILENAME "DuckStation-x64.AppImage"83#elif defined(CPU_ARCH_X64)84#define UPDATER_ASSET_FILENAME "DuckStation-x64-SSE2.AppImage"85#elif defined(CPU_ARCH_ARM64)86#define UPDATER_ASSET_FILENAME "DuckStation-arm64.AppImage"87#elif defined(CPU_ARCH_ARM32)88#define UPDATER_ASSET_FILENAME "DuckStation-armhf.AppImage"89#elif defined(CPU_ARCH_RISCV64)90#define UPDATER_ASSET_FILENAME "DuckStation-riscv64.AppImage"91#endif92#endif93#ifndef UPDATER_ASSET_FILENAME94#error Unsupported platform.95#endif96// clang-format on9798// URLs for downloading updates.99#define LATEST_TAG_URL "https://api.github.com/repos/stenzek/duckstation/tags"100#define LATEST_RELEASE_URL "https://api.github.com/repos/stenzek/duckstation/releases/tags/{}"101#define CHANGES_URL "https://api.github.com/repos/stenzek/duckstation/compare/{}...{}"102#define DOWNLOAD_PAGE_URL "https://github.com/stenzek/duckstation/releases/tag/{}"103104// Update channels.105static constexpr const std::pair<const char*, const char*> s_update_channels[] = {106{"latest", QT_TRANSLATE_NOOP("AutoUpdaterWindow", "Stable Releases")},107{"preview", QT_TRANSLATE_NOOP("AutoUpdaterWindow", "Preview Releases")},108};109110LOG_CHANNEL(Host);111112AutoUpdaterDialog::AutoUpdaterDialog(QWidget* const parent, Error* const error) : QDialog(parent)113{114m_ui.setupUi(this);115QFont title_font(m_ui.titleLabel->font());116title_font.setBold(true);117title_font.setPixelSize(20);118m_ui.titleLabel->setFont(title_font);119setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);120setDownloadSectionVisibility(false);121122connect(m_ui.downloadAndInstall, &QPushButton::clicked, this, &AutoUpdaterDialog::downloadUpdateClicked);123connect(m_ui.skipThisUpdate, &QPushButton::clicked, this, &AutoUpdaterDialog::skipThisUpdateClicked);124connect(m_ui.remindMeLater, &QPushButton::clicked, this, &AutoUpdaterDialog::remindMeLaterClicked);125126m_http = HTTPDownloader::Create(Core::GetHTTPUserAgent(), error);127128m_http_poll_timer = new QTimer(this);129m_http_poll_timer->connect(m_http_poll_timer, &QTimer::timeout, this, &AutoUpdaterDialog::httpPollTimerPoll);130}131132AutoUpdaterDialog::~AutoUpdaterDialog() = default;133134AutoUpdaterDialog* AutoUpdaterDialog::create(QWidget* const parent, Error* const error)135{136AutoUpdaterDialog* const win = new AutoUpdaterDialog(parent, error);137if (!win->m_http)138{139delete win;140return nullptr;141}142143return win;144}145146void AutoUpdaterDialog::warnAboutUnofficialBuild()147{148//149// To those distributing their own builds or packages of DuckStation, and seeing this message:150//151// DuckStation is licensed under the CC-BY-NC-ND-4.0 license.152//153// This means that you do NOT have permission to re-distribute your own modified builds of DuckStation.154// Modifying DuckStation for personal use is fine, but you cannot distribute builds with your changes.155// As per the CC-BY-NC-ND conditions, you can re-distribute the official builds from https://www.duckstation.org/ and156// https://github.com/stenzek/duckstation, so long as they are left intact, without modification. I welcome and157// appreciate any pull requests made to the official repository at https://github.com/stenzek/duckstation.158//159// I made the decision to switch to a no-derivatives license because of numerous "forks" that were created purely for160// generating money for the person who knocked it off, and always died, leaving the community with multiple builds to161// choose from, most of which were out of date and broken, and endless confusion. Other forks copy/pasted upstream162// changes without attribution, violating copyright.163//164// Thanks, and I hope you understand.165//166167#if !__has_include("scmversion/tag.h") || !UPDATER_RELEASE_IS_OFFICIAL168constexpr const char* CONFIG_SECTION = "UI";169constexpr const char* CONFIG_KEY = "UnofficialBuildWarningConfirmed";170if (171#if !defined(_WIN32) && !defined(__APPLE__)172EmuFolders::AppRoot.starts_with("/home") && // Devbuilds should be in home directory.173#endif174Core::GetBaseBoolSettingValue(CONFIG_SECTION, CONFIG_KEY, false))175{176return;177}178179constexpr int DELAY_SECONDS = 10;180181const QString message =182QStringLiteral("<h1>You are not using an official release!</h1><h3>DuckStation is licensed under the terms of "183"CC-BY-NC-ND-4.0, which does not allow modified builds to be distributed.</h3>"184"<p>If you are a developer and using a local build, you can check the box below and continue.</p>"185"<p>Otherwise, you should delete this build and download an official release from "186"<a href=\"https://www.duckstation.org/\">duckstation.org</a>.</p><p>Do you want to exit and "187"open this page now?</p>");188189QMessageBox mbox;190mbox.setIcon(QMessageBox::Warning);191mbox.setWindowTitle(QStringLiteral("Unofficial Build Warning"));192mbox.setWindowIcon(QtHost::GetAppIcon());193mbox.setWindowFlag(Qt::CustomizeWindowHint, true);194mbox.setWindowFlag(Qt::WindowCloseButtonHint, false);195mbox.setTextFormat(Qt::RichText);196mbox.setText(message);197198mbox.addButton(QMessageBox::Yes);199QPushButton* no = mbox.addButton(QMessageBox::No);200const QString orig_no_text = no->text();201no->setEnabled(false);202203QCheckBox* cb = new QCheckBox(&mbox);204cb->setText(tr("Do not show again"));205mbox.setCheckBox(cb);206207int remaining_time = DELAY_SECONDS;208no->setText(QStringLiteral("%1 [%2]").arg(orig_no_text).arg(remaining_time));209210QTimer* timer = new QTimer(&mbox);211connect(timer, &QTimer::timeout, &mbox, [no, timer, &remaining_time, &orig_no_text]() {212remaining_time--;213if (remaining_time == 0)214{215no->setText(orig_no_text);216no->setEnabled(true);217timer->stop();218}219else220{221no->setText(QStringLiteral("%1 [%2]").arg(orig_no_text).arg(remaining_time));222}223});224timer->start(1000);225226if (mbox.exec() == QMessageBox::Yes)227{228QtUtils::OpenURL(nullptr, "https://duckstation.org/");229QMetaObject::invokeMethod(qApp, &QApplication::quit, Qt::QueuedConnection);230return;231}232233if (cb->isChecked())234Core::SetBaseBoolSettingValue(CONFIG_SECTION, CONFIG_KEY, true);235#endif236}237238std::vector<std::pair<QString, QString>> AutoUpdaterDialog::getChannelList()239{240std::vector<std::pair<QString, QString>> ret;241ret.reserve(std::size(s_update_channels));242for (const auto& [name, desc] : s_update_channels)243ret.emplace_back(QString::fromUtf8(name), qApp->translate("AutoUpdaterWindow", desc));244return ret;245}246247std::string AutoUpdaterDialog::getDefaultTag()248{249return UPDATER_RELEASE_CHANNEL;250}251252std::string AutoUpdaterDialog::getCurrentUpdateTag()253{254return Core::GetBaseStringSettingValue("AutoUpdater", "UpdateTag", UPDATER_RELEASE_CHANNEL);255}256257void AutoUpdaterDialog::setDownloadSectionVisibility(bool visible)258{259m_ui.downloadProgress->setVisible(visible);260m_ui.downloadStatus->setVisible(visible);261m_ui.downloadButtonBox->setVisible(visible);262m_ui.downloadAndInstall->setVisible(!visible);263m_ui.skipThisUpdate->setVisible(!visible);264m_ui.remindMeLater->setVisible(!visible);265}266267void AutoUpdaterDialog::reportError(const std::string_view msg)268{269emit updateCheckAboutToComplete();270271// if we're visible, use ourselves.272QWidget* const parent_widget = (isVisible() ? static_cast<QWidget*>(this) : parentWidget());273const QString full_msg = tr("Failed to retrieve or download update:\n\n%1\n\nYou can manually update DuckStation by "274"re-downloading the latest release. Do you want to open the download page now?")275.arg(QtUtils::StringViewToQString(msg));276QMessageBox* const msgbox = QtUtils::NewMessageBox(parent_widget, QMessageBox::Critical, tr("Updater Error"),277full_msg, QMessageBox::Yes | QMessageBox::No);278msgbox->connect(msgbox, &QMessageBox::accepted, parent_widget, [parent_widget]() {279QtUtils::OpenURL(parent_widget, fmt::format(DOWNLOAD_PAGE_URL, UPDATER_RELEASE_CHANNEL));280});281msgbox->open();282}283284void AutoUpdaterDialog::ensureHttpPollingActive()285{286if (m_http_poll_timer->isActive())287return;288289m_http_poll_timer->setSingleShot(false);290m_http_poll_timer->setInterval(HTTP_POLL_INTERVAL);291m_http_poll_timer->start();292}293294void AutoUpdaterDialog::httpPollTimerPoll()295{296m_http->PollRequests();297298if (!m_http->HasAnyRequests())299{300VERBOSE_LOG("All HTTP requests done.");301m_http_poll_timer->stop();302}303}304305void AutoUpdaterDialog::cancel()306{307if (m_updates_available)308return;309310m_http->CancelAllRequests();311}312313bool AutoUpdaterDialog::handleCancelledRequest(s32 status_code)314{315if (status_code != HTTPDownloader::HTTP_STATUS_CANCELLED)316return false;317318emit updateCheckAboutToComplete();319emit updateCheckCompleted(false);320return true;321}322323void AutoUpdaterDialog::queueUpdateCheck(bool display_errors, bool ignore_skipped_updates)324{325if (ignore_skipped_updates)326{327// Wipe out the last version, that way it displays the update if we've previously skipped it.328Core::DeleteBaseSettingValue("AutoUpdater", "LastVersion");329Host::CommitBaseSettingChanges();330}331332ensureHttpPollingActive();333m_http->CreateRequest(LATEST_TAG_URL,334[this, display_errors](s32 status_code, const Error& error, const std::string& content_type,335std::vector<u8> response) {336getLatestTagComplete(status_code, error, std::move(response), display_errors);337});338}339340void AutoUpdaterDialog::queueGetLatestRelease()341{342ensureHttpPollingActive();343std::string url = fmt::format(LATEST_RELEASE_URL, getCurrentUpdateTag());344m_http->CreateRequest(std::move(url), std::bind(&AutoUpdaterDialog::getLatestReleaseComplete, this,345std::placeholders::_1, std::placeholders::_2, std::placeholders::_4));346}347348void AutoUpdaterDialog::getLatestTagComplete(s32 status_code, const Error& error, std::vector<u8> response,349bool display_errors)350{351if (handleCancelledRequest(status_code))352return;353354const std::string selected_tag(getCurrentUpdateTag());355const QString selected_tag_qstr = QString::fromStdString(selected_tag);356357if (status_code == HTTPDownloader::HTTP_STATUS_OK)358{359QJsonParseError parse_error;360const QJsonDocument doc = QJsonDocument::fromJson(361QByteArray(reinterpret_cast<const char*>(response.data()), response.size()), &parse_error);362if (doc.isArray())363{364const QJsonArray doc_array(doc.array());365for (const QJsonValue& val : doc_array)366{367if (!val.isObject())368continue;369370if (val["name"].toString() != selected_tag_qstr)371continue;372373m_latest_sha = val["commit"].toObject()["sha"].toString();374if (m_latest_sha.isEmpty())375continue;376377if (updateNeeded())378{379queueGetLatestRelease();380return;381}382else383{384emit updateCheckAboutToComplete();385386if (display_errors)387{388QtUtils::AsyncMessageBox(parentWidget(), QMessageBox::Information, tr("Automatic Updater"),389tr("No updates are currently available. Please try again later."));390}391392emit updateCheckCompleted(false);393return;394}395}396397if (display_errors)398reportError(fmt::format("{} release not found in JSON", selected_tag));399}400else401{402if (display_errors)403reportError("JSON is not an array");404}405}406else407{408if (display_errors)409reportError(fmt::format("Failed to download latest tag info: {}", error.GetDescription()));410}411412emit updateCheckCompleted(false);413}414415void AutoUpdaterDialog::getLatestReleaseComplete(s32 status_code, const Error& error, std::vector<u8> response)416{417if (handleCancelledRequest(status_code))418return;419420if (status_code == HTTPDownloader::HTTP_STATUS_OK)421{422QJsonParseError parse_error;423const QJsonDocument doc = QJsonDocument::fromJson(424QByteArray(reinterpret_cast<const char*>(response.data()), response.size()), &parse_error);425if (doc.isObject())426{427const QJsonObject doc_object(doc.object());428429// search for the correct file430const QJsonArray assets(doc_object["assets"].toArray());431const QLatin1StringView asset_filename = UPDATER_ASSET_FILENAME ""_L1;432bool asset_found = false;433for (const QJsonValue& asset : assets)434{435const QJsonObject asset_obj(asset.toObject());436if (asset_obj["name"] == asset_filename)437{438m_download_url = asset_obj["browser_download_url"].toString();439if (!m_download_url.isEmpty())440m_download_size = asset_obj["size"].toInt();441asset_found = true;442break;443}444}445446if (asset_found)447{448emit updateCheckAboutToComplete();449450const QString current_date = QtHost::FormatNumber(451Host::NumberFormatType::ShortDateTime,452static_cast<s64>(453QDateTime::fromString(QString::fromUtf8(g_scm_date_str), Qt::DateFormat::ISODate).toSecsSinceEpoch()));454const QString release_date = QtHost::FormatNumber(455Host::NumberFormatType::ShortDateTime,456static_cast<s64>(457QDateTime::fromString(doc_object["published_at"].toString(), Qt::DateFormat::ISODate).toSecsSinceEpoch()));458459m_ui.currentVersion->setText(tr("%1 (%2)")460.arg(QtUtils::StringViewToQString(461TinyString::from_format("{}/{}", g_scm_version_str, UPDATER_RELEASE_CHANNEL)))462.arg(current_date));463m_ui.newVersion->setText(tr("%1 (%2)").arg(QString::fromStdString(getCurrentUpdateTag())).arg(release_date));464m_ui.downloadSize->setText(tr("%1 MB").arg(static_cast<double>(m_download_size) / 1000000.0, 0, 'f', 2));465466m_ui.downloadAndInstall->setEnabled(true);467m_ui.updateNotes->setText(tr("Loading..."));468queueGetChanges();469m_updates_available = true;470emit updateCheckCompleted(true);471return;472}473else474{475reportError("Asset not found");476}477}478else479{480reportError("JSON is not an object");481}482}483else484{485reportError(fmt::format("Failed to download latest release info: {}", error.GetDescription()));486}487488emit updateCheckCompleted(false);489}490491void AutoUpdaterDialog::queueGetChanges()492{493ensureHttpPollingActive();494std::string url = fmt::format(CHANGES_URL, g_scm_hash_str, getCurrentUpdateTag());495m_http->CreateRequest(std::move(url), std::bind(&AutoUpdaterDialog::getChangesComplete, this, std::placeholders::_1,496std::placeholders::_2, std::placeholders::_4));497}498499void AutoUpdaterDialog::getChangesComplete(s32 status_code, const Error& error, std::vector<u8> response)500{501std::string_view error_message;502503if (status_code == HTTPDownloader::HTTP_STATUS_OK)504{505QJsonParseError parse_error;506const QJsonDocument doc = QJsonDocument::fromJson(507QByteArray(reinterpret_cast<const char*>(response.data()), response.size()), &parse_error);508if (doc.isObject())509{510const QJsonObject doc_object(doc.object());511512QString changes_html = tr("<h2>Changes:</h2>");513changes_html += "<ul>"_L1;514515const QJsonArray commits(doc_object["commits"].toArray());516bool update_will_break_save_states = false;517518for (const QJsonValue& commit : commits)519{520const QJsonObject commit_obj(commit["commit"].toObject());521522QString message = commit_obj["message"].toString();523QString author = commit_obj["author"].toObject()["name"].toString();524const qsizetype first_line_terminator = message.indexOf('\n');525if (first_line_terminator >= 0)526message.remove(first_line_terminator, message.size() - first_line_terminator);527if (!message.isEmpty())528{529changes_html +=530QStringLiteral("<li>%1 <i>(%2)</i></li>").arg(message.toHtmlEscaped()).arg(author.toHtmlEscaped());531}532533if (message.contains("[SAVEVERSION+]"_L1))534update_will_break_save_states = true;535}536537changes_html += "</ul>";538539if (update_will_break_save_states)540{541changes_html.prepend(tr("<h2>Save State Warning</h2><p>Installing this update will make your save states "542"<b>incompatible</b>. Please ensure you have saved your games to memory card "543"before installing this update or you will lose progress.</p>"));544}545546m_ui.updateNotes->setText(changes_html);547return;548}549else550{551error_message = "Change list JSON is not an object";552}553}554else555{556error_message = error.GetDescription();557}558559m_ui.updateNotes->setText(QString::fromStdString(560fmt::format("<h2>Failed to download change list</h2><p>The error was:<pre>{}</pre></p><p>You may be able to "561"install this update anyway. If the download installation fails, you can download the update "562"from:</p><p><a href=\"" DOWNLOAD_PAGE_URL "\">" DOWNLOAD_PAGE_URL "</a></p>",563error_message, UPDATER_RELEASE_CHANNEL, UPDATER_RELEASE_CHANNEL)));564}565566void AutoUpdaterDialog::downloadUpdateClicked()567{568// Prevent multiple clicks of the button.569if (m_download_progress_callback)570return;571572setDownloadSectionVisibility(true);573574m_download_progress_callback = new QtProgressCallback(this);575m_download_progress_callback->connectWidgets(m_ui.downloadStatus, m_ui.downloadProgress,576m_ui.downloadButtonBox->button(QDialogButtonBox::Cancel));577m_download_progress_callback->SetStatusText(TRANSLATE_SV("AutoUpdaterWindow", "Downloading Update..."));578579ensureHttpPollingActive();580m_http->CreateRequest(581m_download_url.toStdString(),582[this](s32 status_code, const Error& error, const std::string&, std::vector<u8> response) {583m_download_progress_callback->SetStatusText(TRANSLATE_SV("AutoUpdaterWindow", "Processing Update..."));584m_download_progress_callback->SetProgressRange(1);585m_download_progress_callback->SetProgressValue(1);586DebugAssert(m_download_progress_callback);587delete m_download_progress_callback;588m_download_progress_callback = nullptr;589590if (status_code == HTTPDownloader::HTTP_STATUS_CANCELLED)591{592setDownloadSectionVisibility(false);593return;594}595596if (status_code != HTTPDownloader::HTTP_STATUS_OK)597{598reportError(fmt::format("Download failed: {}", error.GetDescription()));599setDownloadSectionVisibility(false);600return;601}602603if (response.empty())604{605reportError("Download failed: Update is empty");606setDownloadSectionVisibility(false);607return;608}609610if (processUpdate(response))611{612// updater started, request exit. can't do it immediately as closing the main window will delete us.613QMetaObject::invokeMethod(g_main_window, &MainWindow::requestExit, Qt::QueuedConnection, false);614}615else616{617// allow user to try again618setDownloadSectionVisibility(false);619}620},621m_download_progress_callback);622}623624bool AutoUpdaterDialog::updateNeeded() const625{626QString last_checked_sha = QString::fromStdString(Core::GetBaseStringSettingValue("AutoUpdater", "LastVersion"));627628INFO_LOG("Current SHA: {}", g_scm_hash_str);629INFO_LOG("Latest SHA: {}", m_latest_sha.toUtf8().constData());630INFO_LOG("Last Checked SHA: {}", last_checked_sha.toUtf8().constData());631if (m_latest_sha == g_scm_hash_str || m_latest_sha == last_checked_sha)632{633INFO_LOG("No update needed.");634return false;635}636637INFO_LOG("Update needed.");638return true;639}640641void AutoUpdaterDialog::skipThisUpdateClicked()642{643Core::SetBaseStringSettingValue("AutoUpdater", "LastVersion", m_latest_sha.toUtf8().constData());644Host::CommitBaseSettingChanges();645close();646}647648void AutoUpdaterDialog::remindMeLaterClicked()649{650close();651}652653void AutoUpdaterDialog::closeEvent(QCloseEvent* event)654{655emit closed();656QWidget::closeEvent(event);657}658659#ifdef _WIN32660661static constexpr char UPDATER_EXECUTABLE[] = "updater.exe";662static constexpr char UPDATER_ARCHIVE_NAME[] = "update.zip";663664bool AutoUpdaterDialog::doesUpdaterNeedElevation(const std::string& application_dir) const665{666// Try to create a dummy text file in the updater directory. If it fails, we probably won't have write permission.667const std::string dummy_path = Path::Combine(application_dir, "update.txt");668auto fp = FileSystem::OpenManagedCFile(dummy_path.c_str(), "wb");669if (!fp)670return true;671672fp.reset();673FileSystem::DeleteFile(dummy_path.c_str());674return false;675}676677bool AutoUpdaterDialog::processUpdate(const std::vector<u8>& update_data)678{679const std::string& application_dir = EmuFolders::AppRoot;680const std::string update_zip_path = Path::Combine(EmuFolders::DataRoot, UPDATER_ARCHIVE_NAME);681const std::string updater_path = Path::Combine(EmuFolders::DataRoot, UPDATER_EXECUTABLE);682683const std::string program_path = QDir::toNativeSeparators(QCoreApplication::applicationFilePath()).toStdString();684if (program_path.empty())685{686reportError("Failed to get current application path");687return false;688}689690Error error;691if ((FileSystem::FileExists(update_zip_path.c_str()) && !FileSystem::DeleteFile(update_zip_path.c_str(), &error)))692{693reportError(fmt::format("Removing existing update zip failed:\n{}", error.GetDescription()));694return false;695}696697if (!FileSystem::WriteAtomicRenamedFile(update_zip_path.c_str(), update_data, &error))698{699reportError(fmt::format("Writing update zip to '{}' failed:\n{}", update_zip_path, error.GetDescription()));700return false;701}702703Error updater_extract_error;704if (!extractUpdater(update_zip_path.c_str(), updater_path.c_str(), Path::GetFileName(program_path),705&updater_extract_error))706{707reportError(fmt::format("Extracting updater failed: {}", updater_extract_error.GetDescription()));708return false;709}710711return doUpdate(application_dir, update_zip_path, updater_path, program_path);712}713714bool AutoUpdaterDialog::extractUpdater(const std::string& zip_path, const std::string& destination_path,715const std::string_view check_for_file, Error* error)716{717unzFile zf = MinizipHelpers::OpenUnzFile(zip_path.c_str());718if (!zf)719{720reportError("Failed to open update zip");721return false;722}723724// check the the expected program name is inside the updater zip. if it's not, we're going to launch the old binary725// with a partially updated installation726if (unzLocateFile(zf, std::string(check_for_file).c_str(), 0) != UNZ_OK)727{728if (QtUtils::MessageBoxIcon(729this, QMessageBox::Warning, tr("Updater Warning"),730tr("<h1>Inconsistent Application State</h1><h3>The update zip is missing the current executable:</h3><div "731"align=\"center\"><pre>%1</pre></div><p><strong>This is usually a result of manually renaming the "732"file.</strong> Continuing to install this update may result in a broken installation if the renamed "733"executable is used. The DuckStation executable should be named:<div "734"align=\"center\"><pre>%2</pre></div><p>Do you want to continue anyway?</p>")735.arg(QString::fromStdString(std::string(check_for_file)))736.arg(QStringLiteral(UPDATER_EXPECTED_EXECUTABLE)),737QMessageBox::Yes | QMessageBox::No, QMessageBox::NoButton) == QMessageBox::No)738{739Error::SetStringFmt(error, "Update zip is missing expected file: {}", check_for_file);740unzClose(zf);741return false;742}743}744745if (unzLocateFile(zf, UPDATER_EXECUTABLE, 0) != UNZ_OK || unzOpenCurrentFile(zf) != UNZ_OK)746{747Error::SetString(error, "Failed to locate updater.exe");748unzClose(zf);749return false;750}751752auto fp = FileSystem::OpenManagedCFile(destination_path.c_str(), "wb", error);753if (!fp)754{755Error::SetString(error, "Failed to open updater.exe for writing");756unzClose(zf);757return false;758}759760static constexpr size_t CHUNK_SIZE = 4096;761char chunk[CHUNK_SIZE];762for (;;)763{764int size = unzReadCurrentFile(zf, chunk, CHUNK_SIZE);765if (size < 0)766{767Error::SetString(error, "Failed to decompress updater exe");768unzClose(zf);769fp.reset();770FileSystem::DeleteFile(destination_path.c_str());771return false;772}773else if (size == 0)774{775break;776}777778if (std::fwrite(chunk, size, 1, fp.get()) != 1)779{780Error::SetErrno(error, "Failed to write updater exe: fwrite() failed: ", errno);781unzClose(zf);782fp.reset();783FileSystem::DeleteFile(destination_path.c_str());784return false;785}786}787788unzClose(zf);789return true;790}791792bool AutoUpdaterDialog::doUpdate(const std::string& application_dir, const std::string& zip_path,793const std::string& updater_path, const std::string& program_path)794{795const std::wstring wupdater_path = StringUtil::UTF8StringToWideString(updater_path);796const std::wstring wapplication_dir = StringUtil::UTF8StringToWideString(application_dir);797const std::wstring arguments = StringUtil::UTF8StringToWideString(fmt::format(798"{} \"{}\" \"{}\" \"{}\"", QCoreApplication::applicationPid(), application_dir, zip_path, program_path));799800const bool needs_elevation = doesUpdaterNeedElevation(application_dir);801802SHELLEXECUTEINFOW sei = {};803sei.cbSize = sizeof(sei);804sei.lpVerb = needs_elevation ? L"runas" : nullptr; // needed to trigger elevation805sei.lpFile = wupdater_path.c_str();806sei.lpParameters = arguments.c_str();807sei.lpDirectory = wapplication_dir.c_str();808sei.nShow = SW_SHOWNORMAL;809if (!ShellExecuteExW(&sei))810{811reportError(fmt::format("Failed to start {}: {}", needs_elevation ? "elevated updater" : "updater",812Error::CreateWin32(GetLastError()).GetDescription()));813return false;814}815816return true;817}818819void AutoUpdaterDialog::cleanupAfterUpdate()820{821// If we weren't portable, then updater executable gets left in the application directory.822if (EmuFolders::AppRoot == EmuFolders::DataRoot)823return;824825const std::string updater_path = Path::Combine(EmuFolders::DataRoot, UPDATER_EXECUTABLE);826if (!FileSystem::FileExists(updater_path.c_str()))827return;828829Error error;830if (!FileSystem::DeleteFile(updater_path.c_str(), &error))831{832QtUtils::MessageBoxCritical(833nullptr, tr("Updater Error"),834tr("Failed to remove updater exe after update:\n%1").arg(QString::fromStdString(error.GetDescription())));835return;836}837}838839#elif defined(__APPLE__)840841bool AutoUpdaterDialog::processUpdate(const std::vector<u8>& update_data)842{843std::optional<std::string> bundle_path = CocoaTools::GetNonTranslocatedBundlePath();844if (!bundle_path.has_value())845{846reportError("Couldn't obtain non-translocated bundle path.");847return false;848}849850QFileInfo info(QString::fromStdString(bundle_path.value()));851if (!info.isBundle())852{853reportError(fmt::format("Application {} isn't a bundle.", bundle_path.value()));854return false;855}856if (info.suffix() != "app"_L1)857{858reportError(859fmt::format("Unexpected application suffix {} on {}.", info.suffix().toStdString(), bundle_path.value()));860return false;861}862863// Use the updater from this version to unpack the new version.864const std::string updater_app = Path::Combine(bundle_path.value(), "Contents/Resources/Updater.app");865if (!FileSystem::DirectoryExists(updater_app.c_str()))866{867reportError(fmt::format("Failed to find updater at {}.", updater_app));868return false;869}870871// We use the user data directory to temporarily store the update zip.872const std::string zip_path = Path::Combine(EmuFolders::DataRoot, "update.zip");873const std::string staging_directory = Path::Combine(EmuFolders::DataRoot, "UPDATE_STAGING");874Error error;875if (FileSystem::FileExists(zip_path.c_str()) && !FileSystem::DeleteFile(zip_path.c_str(), &error))876{877reportError(fmt::format("Failed to remove old update zip:\n{}", error.GetDescription()));878return false;879}880881// Save update.882if (!FileSystem::WriteAtomicRenamedFile(zip_path.c_str(), update_data, &error))883{884reportError(fmt::format("Writing update zip to '{}' failed:\n{}", zip_path, error.GetDescription()));885return false;886}887888INFO_LOG("Beginning update:\nUpdater path: {}\nZip path: {}\nStaging directory: {}\nOutput directory: {}",889updater_app, zip_path, staging_directory, bundle_path.value());890891const std::string_view args[] = {892zip_path,893staging_directory,894bundle_path.value(),895};896897// Kick off updater!898CocoaTools::DelayedLaunch(updater_app, args);899return true;900}901902void AutoUpdaterDialog::cleanupAfterUpdate()903{904}905906#elif defined(__linux__)907908bool AutoUpdaterDialog::processUpdate(const std::vector<u8>& update_data)909{910const char* appimage_path = std::getenv("APPIMAGE");911if (!appimage_path || !FileSystem::FileExists(appimage_path))912{913reportError("Missing APPIMAGE.");914return false;915}916917if (!FileSystem::FileExists(appimage_path))918{919reportError(fmt::format("Current AppImage does not exist: {}", appimage_path));920return false;921}922923const std::string new_appimage_path = fmt::format("{}.new", appimage_path);924const std::string backup_appimage_path = fmt::format("{}.backup", appimage_path);925INFO_LOG("APPIMAGE = {}", appimage_path);926INFO_LOG("Backup AppImage path = {}", backup_appimage_path);927INFO_LOG("New AppImage path = {}", new_appimage_path);928929// Remove old "new" appimage and existing backup appimage.930Error error;931if (FileSystem::FileExists(new_appimage_path.c_str()) && !FileSystem::DeleteFile(new_appimage_path.c_str(), &error))932{933reportError(934fmt::format("Failed to remove old destination AppImage: {}:\n{}", new_appimage_path, error.GetDescription()));935return false;936}937if (FileSystem::FileExists(backup_appimage_path.c_str()) &&938!FileSystem::DeleteFile(backup_appimage_path.c_str(), &error))939{940reportError(941fmt::format("Failed to remove old backup AppImage: {}:\n{}", backup_appimage_path, error.GetDescription()));942return false;943}944945// Write "new" appimage.946{947// We want to copy the permissions from the old appimage to the new one.948static constexpr int permission_mask = S_IRWXU | S_IRWXG | S_IRWXO;949struct stat old_stat;950if (!FileSystem::StatFile(appimage_path, &old_stat, &error))951{952reportError(fmt::format("Failed to get old AppImage {} permissions:\n{}", appimage_path, error.GetDescription()));953return false;954}955956// We do this as a manual write here, rather than using WriteAtomicUpdatedFile(), because we want to write the957// file and set the permissions as one atomic operation.958FileSystem::ManagedCFilePtr fp = FileSystem::OpenManagedCFile(new_appimage_path.c_str(), "wb", &error);959bool success = static_cast<bool>(fp);960if (fp)961{962if (std::fwrite(update_data.data(), update_data.size(), 1, fp.get()) == 1 && std::fflush(fp.get()) == 0)963{964const int fd = fileno(fp.get());965if (fd >= 0)966{967if (fchmod(fd, old_stat.st_mode & permission_mask) != 0)968{969error.SetErrno("fchmod() failed: ", errno);970success = false;971}972}973else974{975error.SetErrno("fileno() failed: ", errno);976success = false;977}978}979else980{981error.SetErrno("fwrite() failed: ", errno);982success = false;983}984985fp.reset();986if (!success)987FileSystem::DeleteFile(new_appimage_path.c_str());988}989990if (!success)991{992reportError(993fmt::format("Failed to write new destination AppImage: {}:\n{}", new_appimage_path, error.GetDescription()));994return false;995}996}997998// Rename "old" appimage.999if (!FileSystem::RenamePath(appimage_path, backup_appimage_path.c_str(), &error))1000{1001reportError(fmt::format("Failed to rename old AppImage to {}:\n{}", backup_appimage_path, error.GetDescription()));1002FileSystem::DeleteFile(new_appimage_path.c_str());1003return false;1004}10051006// Rename "new" appimage.1007if (!FileSystem::RenamePath(new_appimage_path.c_str(), appimage_path, &error))1008{1009reportError(fmt::format("Failed to rename new AppImage to {}:\n{}", appimage_path, error.GetDescription()));1010return false;1011}10121013// Execute new appimage.1014QProcess* new_process = new QProcess();1015new_process->setProgram(QString::fromUtf8(appimage_path));1016new_process->setArguments(QStringList{"-updatecleanup"_L1});1017if (!new_process->startDetached())1018{1019reportError("Failed to execute new AppImage.");1020return false;1021}10221023// We exit once we return.1024return true;1025}10261027void AutoUpdaterDialog::cleanupAfterUpdate()1028{1029// Remove old/backup AppImage.1030const char* appimage_path = std::getenv("APPIMAGE");1031if (!appimage_path)1032return;10331034const std::string backup_appimage_path = fmt::format("{}.backup", appimage_path);1035if (!FileSystem::FileExists(backup_appimage_path.c_str()))1036return;10371038Error error;1039INFO_LOG("Removing backup AppImage: {}", backup_appimage_path);1040if (!FileSystem::DeleteFile(backup_appimage_path.c_str(), &error))1041ERROR_LOG("Failed to remove backup AppImage {}: {}", backup_appimage_path, error.GetDescription());1042}10431044#else10451046bool AutoUpdaterDialog::processUpdate(const std::vector<u8>& update_data)1047{1048return false;1049}10501051void AutoUpdaterDialog::cleanupAfterUpdate()1052{1053}10541055#endif105610571058