autoupdaterdialog.cpp (29280B)
1 // SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin <stenzek@gmail.com> 2 // SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0) 3 4 #include "autoupdaterdialog.h" 5 #include "mainwindow.h" 6 #include "qthost.h" 7 #include "qtprogresscallback.h" 8 #include "qtutils.h" 9 #include "scmversion/scmversion.h" 10 #include "unzip.h" 11 12 #include "util/http_downloader.h" 13 14 #include "common/assert.h" 15 #include "common/error.h" 16 #include "common/file_system.h" 17 #include "common/log.h" 18 #include "common/minizip_helpers.h" 19 #include "common/path.h" 20 #include "common/string_util.h" 21 22 #include <QtCore/QCoreApplication> 23 #include <QtCore/QFile> 24 #include <QtCore/QJsonArray> 25 #include <QtCore/QJsonDocument> 26 #include <QtCore/QJsonObject> 27 #include <QtCore/QJsonValue> 28 #include <QtCore/QProcess> 29 #include <QtCore/QString> 30 #include <QtCore/QTimer> 31 #include <QtWidgets/QCheckBox> 32 #include <QtWidgets/QDialog> 33 #include <QtWidgets/QMessageBox> 34 #include <QtWidgets/QProgressDialog> 35 #include <QtWidgets/QPushButton> 36 37 // Interval at which HTTP requests are polled. 38 static constexpr u32 HTTP_POLL_INTERVAL = 10; 39 40 #if defined(_WIN32) 41 #include "common/windows_headers.h" 42 #include <shellapi.h> 43 #elif defined(__APPLE__) 44 #include "common/cocoa_tools.h" 45 #endif 46 47 // Logic to detect whether we can use the auto updater. 48 // Requires that the channel be defined by the buildbot. 49 #if __has_include("scmversion/tag.h") 50 #include "scmversion/tag.h" 51 #ifdef SCM_RELEASE_TAGS 52 #define AUTO_UPDATER_SUPPORTED 53 #endif 54 #endif 55 56 #ifdef AUTO_UPDATER_SUPPORTED 57 58 static const char* LATEST_TAG_URL = "https://api.github.com/repos/stenzek/duckstation/tags"; 59 static const char* LATEST_RELEASE_URL = "https://api.github.com/repos/stenzek/duckstation/releases/tags/{}"; 60 static const char* CHANGES_URL = "https://api.github.com/repos/stenzek/duckstation/compare/{}...{}"; 61 static const char* UPDATE_ASSET_FILENAME = SCM_RELEASE_ASSET; 62 static const char* UPDATE_TAGS[] = SCM_RELEASE_TAGS; 63 static const char* THIS_RELEASE_TAG = SCM_RELEASE_TAG; 64 65 #endif 66 67 Log_SetChannel(AutoUpdaterDialog); 68 69 AutoUpdaterDialog::AutoUpdaterDialog(QWidget* parent /* = nullptr */) : QDialog(parent) 70 { 71 m_ui.setupUi(this); 72 73 setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); 74 75 connect(m_ui.downloadAndInstall, &QPushButton::clicked, this, &AutoUpdaterDialog::downloadUpdateClicked); 76 connect(m_ui.skipThisUpdate, &QPushButton::clicked, this, &AutoUpdaterDialog::skipThisUpdateClicked); 77 connect(m_ui.remindMeLater, &QPushButton::clicked, this, &AutoUpdaterDialog::remindMeLaterClicked); 78 79 m_http = HTTPDownloader::Create(Host::GetHTTPUserAgent()); 80 if (!m_http) 81 ERROR_LOG("Failed to create HTTP downloader, auto updater will not be available."); 82 } 83 84 AutoUpdaterDialog::~AutoUpdaterDialog() = default; 85 86 bool AutoUpdaterDialog::isSupported() 87 { 88 #ifdef AUTO_UPDATER_SUPPORTED 89 #ifdef __linux__ 90 // For Linux, we need to check whether we're running from the appimage. 91 if (!std::getenv("APPIMAGE")) 92 { 93 INFO_LOG("We're a CI release, but not running from an AppImage. Disabling automatic updater."); 94 return false; 95 } 96 97 return true; 98 #else 99 // Windows/Mac - always supported. 100 return true; 101 #endif 102 #else 103 return false; 104 #endif 105 } 106 107 bool AutoUpdaterDialog::isOfficialBuild() 108 { 109 #if !__has_include("scmversion/tag.h") 110 return false; 111 #else 112 return true; 113 #endif 114 } 115 116 bool AutoUpdaterDialog::warnAboutUnofficialBuild() 117 { 118 // 119 // To those distributing their own builds or packages of DuckStation, and seeing this message: 120 // 121 // This message is here for a reason. Under the terms of the license, you are within your rights to distribute your 122 // own builds of my application. However, it is a headache for me, as users run into broken functionality, or end up 123 // on untested/preview commits that have not been adequately tested, and I cannot resolve their issues. I provide 124 // builds for a range of platforms that covers almost all use cases, and can guarantee quality of these builds. 125 // 126 // If you must distribute builds/packages, per the GPL, modified builds should be clearly marked as such. 127 // This message is thus one way of meeting the requirement. See Section 5 of the GPLv3: 128 // https://www.gnu.org/licenses/gpl-3.0.en.html#section5 129 // 130 // This includes building the binary with any method that does not match the official release, including dependencies, 131 // as it is not uncommon for differing dependency versions to create issues I cannot reproduce. 132 // 133 // You should also provide user support for your package, and not direct them to upstream, as any users that ask for 134 // community help will be instructed to download a supported release instead. 135 // 136 #if !__has_include("scmversion/tag.h") && !defined(_DEBUG) 137 constexpr const char* CONFIG_SECTION = "UI"; 138 constexpr const char* CONFIG_KEY = "UnofficialBuildWarningConfirmed"; 139 if (Host::GetBaseBoolSettingValue(CONFIG_SECTION, CONFIG_KEY, false)) 140 return true; 141 142 constexpr int DELAY_SECONDS = 5; 143 144 const QString message = QStringLiteral( 145 "<h1>You are not using an official release!</h1><h3>If you continue to use this build, expect to run into " 146 "issues.</h3><p><strong>No assistance will be provided by the developers or community</strong>, as we cannot fix " 147 "broken functionality in builds we do not control.</p><p>We <strong>strongly recommend</strong> downloading an " 148 "official release from <a href=\"https://www.duckstation.org/\">duckstation.org</a>.</p><p>Do you want to exit and " 149 "open this page now?</p>"); 150 151 QMessageBox mbox; 152 mbox.setIcon(QMessageBox::Warning); 153 mbox.setWindowTitle(QStringLiteral("Unofficial Build Warning")); 154 mbox.setWindowIcon(QtHost::GetAppIcon()); 155 mbox.setTextFormat(Qt::RichText); 156 mbox.setText(message); 157 158 mbox.addButton(QMessageBox::Yes); 159 QPushButton* no = mbox.addButton(QMessageBox::No); 160 const QString orig_no_text = no->text(); 161 no->setEnabled(false); 162 163 QCheckBox* cb = new QCheckBox(&mbox); 164 cb->setText(tr("Do not show again")); 165 mbox.setCheckBox(cb); 166 167 int remaining_time = DELAY_SECONDS; 168 no->setText(QStringLiteral("%1 [%2]").arg(orig_no_text).arg(remaining_time)); 169 170 QTimer* timer = new QTimer(&mbox); 171 connect(timer, &QTimer::timeout, &mbox, [no, timer, &remaining_time, &orig_no_text]() { 172 remaining_time--; 173 if (remaining_time == 0) 174 { 175 no->setText(orig_no_text); 176 no->setEnabled(true); 177 timer->stop(); 178 } 179 else 180 { 181 no->setText(QStringLiteral("%1 [%2]").arg(orig_no_text).arg(remaining_time)); 182 } 183 }); 184 timer->start(1000); 185 186 if (mbox.exec() == QMessageBox::Yes) 187 { 188 QtUtils::OpenURL(nullptr, "https://duckstation.org/"); 189 return false; 190 } 191 192 if (cb->isChecked()) 193 Host::SetBaseBoolSettingValue(CONFIG_SECTION, CONFIG_KEY, true); 194 195 return true; 196 #else 197 return true; 198 #endif 199 } 200 201 QStringList AutoUpdaterDialog::getTagList() 202 { 203 #ifdef AUTO_UPDATER_SUPPORTED 204 return QStringList(std::begin(UPDATE_TAGS), std::end(UPDATE_TAGS)); 205 #else 206 return QStringList(); 207 #endif 208 } 209 210 std::string AutoUpdaterDialog::getDefaultTag() 211 { 212 #ifdef AUTO_UPDATER_SUPPORTED 213 return THIS_RELEASE_TAG; 214 #else 215 return {}; 216 #endif 217 } 218 219 std::string AutoUpdaterDialog::getCurrentUpdateTag() const 220 { 221 #ifdef AUTO_UPDATER_SUPPORTED 222 return Host::GetBaseStringSettingValue("AutoUpdater", "UpdateTag", THIS_RELEASE_TAG); 223 #else 224 return {}; 225 #endif 226 } 227 228 void AutoUpdaterDialog::reportError(const std::string_view msg) 229 { 230 QMessageBox::critical(this, tr("Updater Error"), QtUtils::StringViewToQString(msg)); 231 } 232 233 bool AutoUpdaterDialog::ensureHttpReady() 234 { 235 if (!m_http) 236 return false; 237 238 if (!m_http_poll_timer) 239 { 240 m_http_poll_timer = new QTimer(this); 241 m_http_poll_timer->connect(m_http_poll_timer, &QTimer::timeout, this, &AutoUpdaterDialog::httpPollTimerPoll); 242 } 243 244 if (!m_http_poll_timer->isActive()) 245 { 246 m_http_poll_timer->setSingleShot(false); 247 m_http_poll_timer->setInterval(HTTP_POLL_INTERVAL); 248 m_http_poll_timer->start(); 249 } 250 251 return true; 252 } 253 254 void AutoUpdaterDialog::httpPollTimerPoll() 255 { 256 Assert(m_http); 257 m_http->PollRequests(); 258 259 if (!m_http->HasAnyRequests()) 260 { 261 VERBOSE_LOG("All HTTP requests done."); 262 m_http_poll_timer->stop(); 263 } 264 } 265 266 void AutoUpdaterDialog::queueUpdateCheck(bool display_message) 267 { 268 m_display_messages = display_message; 269 270 #ifdef AUTO_UPDATER_SUPPORTED 271 if (!ensureHttpReady()) 272 { 273 emit updateCheckCompleted(); 274 return; 275 } 276 277 m_http->CreateRequest(LATEST_TAG_URL, std::bind(&AutoUpdaterDialog::getLatestTagComplete, this, std::placeholders::_1, 278 std::placeholders::_3)); 279 #else 280 emit updateCheckCompleted(); 281 #endif 282 } 283 284 void AutoUpdaterDialog::queueGetLatestRelease() 285 { 286 #ifdef AUTO_UPDATER_SUPPORTED 287 if (!ensureHttpReady()) 288 { 289 emit updateCheckCompleted(); 290 return; 291 } 292 293 std::string url = fmt::format(fmt::runtime(LATEST_RELEASE_URL), getCurrentUpdateTag()); 294 m_http->CreateRequest(std::move(url), std::bind(&AutoUpdaterDialog::getLatestReleaseComplete, this, 295 std::placeholders::_1, std::placeholders::_3)); 296 #endif 297 } 298 299 void AutoUpdaterDialog::getLatestTagComplete(s32 status_code, std::vector<u8> response) 300 { 301 #ifdef AUTO_UPDATER_SUPPORTED 302 const std::string selected_tag(getCurrentUpdateTag()); 303 const QString selected_tag_qstr = QString::fromStdString(selected_tag); 304 305 if (status_code == HTTPDownloader::HTTP_STATUS_OK) 306 { 307 QJsonParseError parse_error; 308 const QJsonDocument doc = QJsonDocument::fromJson( 309 QByteArray(reinterpret_cast<const char*>(response.data()), response.size()), &parse_error); 310 if (doc.isArray()) 311 { 312 const QJsonArray doc_array(doc.array()); 313 for (const QJsonValue& val : doc_array) 314 { 315 if (!val.isObject()) 316 continue; 317 318 if (val["name"].toString() != selected_tag_qstr) 319 continue; 320 321 m_latest_sha = val["commit"].toObject()["sha"].toString(); 322 if (m_latest_sha.isEmpty()) 323 continue; 324 325 if (updateNeeded()) 326 { 327 queueGetLatestRelease(); 328 return; 329 } 330 else 331 { 332 if (m_display_messages) 333 QMessageBox::information(this, tr("Automatic Updater"), 334 tr("No updates are currently available. Please try again later.")); 335 emit updateCheckCompleted(); 336 return; 337 } 338 } 339 340 if (m_display_messages) 341 reportError(fmt::format("{} release not found in JSON", selected_tag)); 342 } 343 else 344 { 345 if (m_display_messages) 346 reportError("JSON is not an array"); 347 } 348 } 349 else 350 { 351 if (m_display_messages) 352 reportError(fmt::format("Failed to download latest tag info: HTTP {}", status_code)); 353 } 354 355 emit updateCheckCompleted(); 356 #endif 357 } 358 359 void AutoUpdaterDialog::getLatestReleaseComplete(s32 status_code, std::vector<u8> response) 360 { 361 #ifdef AUTO_UPDATER_SUPPORTED 362 if (status_code == HTTPDownloader::HTTP_STATUS_OK) 363 { 364 QJsonParseError parse_error; 365 const QJsonDocument doc = QJsonDocument::fromJson( 366 QByteArray(reinterpret_cast<const char*>(response.data()), response.size()), &parse_error); 367 if (doc.isObject()) 368 { 369 const QJsonObject doc_object(doc.object()); 370 371 // search for the correct file 372 const QJsonArray assets(doc_object["assets"].toArray()); 373 const QString asset_filename(UPDATE_ASSET_FILENAME); 374 for (const QJsonValue& asset : assets) 375 { 376 const QJsonObject asset_obj(asset.toObject()); 377 if (asset_obj["name"] == asset_filename) 378 { 379 m_download_url = asset_obj["browser_download_url"].toString(); 380 if (!m_download_url.isEmpty()) 381 { 382 m_download_size = asset_obj["size"].toInt(); 383 m_ui.currentVersion->setText(tr("Current Version: %1 (%2)").arg(g_scm_hash_str).arg(g_scm_date_str)); 384 m_ui.newVersion->setText( 385 tr("New Version: %1 (%2)").arg(m_latest_sha).arg(doc_object["published_at"].toString())); 386 m_ui.updateNotes->setText(tr("Loading...")); 387 m_ui.downloadAndInstall->setEnabled(true); 388 queueGetChanges(); 389 390 // We have to defer this, because it comes back through the timer/HTTP callback... 391 QMetaObject::invokeMethod(this, "exec", Qt::QueuedConnection); 392 393 emit updateCheckCompleted(); 394 return; 395 } 396 397 break; 398 } 399 } 400 401 reportError("Asset/asset download not found"); 402 } 403 else 404 { 405 reportError("JSON is not an object"); 406 } 407 } 408 else 409 { 410 reportError(fmt::format("Failed to download latest release info: HTTP {}", status_code)); 411 } 412 413 emit updateCheckCompleted(); 414 #endif 415 } 416 417 void AutoUpdaterDialog::queueGetChanges() 418 { 419 #ifdef AUTO_UPDATER_SUPPORTED 420 if (!ensureHttpReady()) 421 return; 422 423 std::string url = fmt::format(fmt::runtime(CHANGES_URL), g_scm_hash_str, getCurrentUpdateTag()); 424 m_http->CreateRequest(std::move(url), std::bind(&AutoUpdaterDialog::getChangesComplete, this, std::placeholders::_1, 425 std::placeholders::_3)); 426 #endif 427 } 428 429 void AutoUpdaterDialog::getChangesComplete(s32 status_code, std::vector<u8> response) 430 { 431 #ifdef AUTO_UPDATER_SUPPORTED 432 if (status_code == HTTPDownloader::HTTP_STATUS_OK) 433 { 434 QJsonParseError parse_error; 435 const QJsonDocument doc = QJsonDocument::fromJson( 436 QByteArray(reinterpret_cast<const char*>(response.data()), response.size()), &parse_error); 437 if (doc.isObject()) 438 { 439 const QJsonObject doc_object(doc.object()); 440 441 QString changes_html = tr("<h2>Changes:</h2>"); 442 changes_html += QStringLiteral("<ul>"); 443 444 const QJsonArray commits(doc_object["commits"].toArray()); 445 bool update_will_break_save_states = false; 446 bool update_increases_settings_version = false; 447 448 for (const QJsonValue& commit : commits) 449 { 450 const QJsonObject commit_obj(commit["commit"].toObject()); 451 452 QString message = commit_obj["message"].toString(); 453 QString author = commit_obj["author"].toObject()["name"].toString(); 454 const int first_line_terminator = message.indexOf('\n'); 455 if (first_line_terminator >= 0) 456 message.remove(first_line_terminator, message.size() - first_line_terminator); 457 if (!message.isEmpty()) 458 { 459 changes_html += 460 QStringLiteral("<li>%1 <i>(%2)</i></li>").arg(message.toHtmlEscaped()).arg(author.toHtmlEscaped()); 461 } 462 463 if (message.contains(QStringLiteral("[SAVEVERSION+]"))) 464 update_will_break_save_states = true; 465 466 if (message.contains(QStringLiteral("[SETTINGSVERSION+]"))) 467 update_increases_settings_version = true; 468 } 469 470 changes_html += "</ul>"; 471 472 if (update_will_break_save_states) 473 { 474 changes_html.prepend(tr("<h2>Save State Warning</h2><p>Installing this update will make your save states " 475 "<b>incompatible</b>. Please ensure you have saved your games to memory card " 476 "before installing this update or you will lose progress.</p>")); 477 } 478 479 if (update_increases_settings_version) 480 { 481 changes_html.prepend( 482 tr("<h2>Settings Warning</h2><p>Installing this update will reset your program configuration. Please note " 483 "that you will have to reconfigure your settings after this update.</p>")); 484 } 485 486 changes_html += tr("<h4>Installing this update will download %1 MB through your internet connection.</h4>") 487 .arg(static_cast<double>(m_download_size) / 1000000.0, 0, 'f', 2); 488 489 m_ui.updateNotes->setText(changes_html); 490 } 491 else 492 { 493 reportError("Change list JSON is not an object"); 494 } 495 } 496 else 497 { 498 reportError(fmt::format("Failed to download change list: HTTP {}", status_code)); 499 } 500 #endif 501 } 502 503 void AutoUpdaterDialog::downloadUpdateClicked() 504 { 505 m_display_messages = true; 506 507 std::optional<bool> download_result; 508 QtModalProgressCallback progress(this); 509 progress.SetTitle(tr("Automatic Updater").toUtf8().constData()); 510 progress.SetStatusText(tr("Downloading %1...").arg(m_latest_sha).toUtf8().constData()); 511 progress.GetDialog().setWindowIcon(windowIcon()); 512 progress.SetCancellable(true); 513 514 m_http->CreateRequest( 515 m_download_url.toStdString(), 516 [this, &download_result](s32 status_code, const std::string&, std::vector<u8> response) { 517 if (status_code == HTTPDownloader::HTTP_STATUS_CANCELLED) 518 return; 519 520 if (status_code != HTTPDownloader::HTTP_STATUS_OK) 521 { 522 reportError(fmt::format("Download failed: HTTP status code {}", status_code)); 523 download_result = false; 524 return; 525 } 526 527 if (response.empty()) 528 { 529 reportError("Download failed: Update is empty"); 530 download_result = false; 531 return; 532 } 533 534 download_result = processUpdate(response); 535 }, 536 &progress); 537 538 // Since we're going to block, don't allow the timer to poll, otherwise the progress callback can cause the timer to 539 // run, and recursively poll again. 540 m_http_poll_timer->stop(); 541 542 // Block until completion. 543 while (m_http->HasAnyRequests()) 544 { 545 QApplication::processEvents(QEventLoop::AllEvents, HTTP_POLL_INTERVAL); 546 m_http->PollRequests(); 547 } 548 549 if (download_result.value_or(false)) 550 { 551 // updater started. since we're a modal on the main window, we have to queue this. 552 QMetaObject::invokeMethod(g_main_window, "requestExit", Qt::QueuedConnection, Q_ARG(bool, true)); 553 done(0); 554 } 555 } 556 557 bool AutoUpdaterDialog::updateNeeded() const 558 { 559 QString last_checked_sha = QString::fromStdString(Host::GetBaseStringSettingValue("AutoUpdater", "LastVersion")); 560 561 INFO_LOG("Current SHA: {}", g_scm_hash_str); 562 INFO_LOG("Latest SHA: {}", m_latest_sha.toUtf8().constData()); 563 INFO_LOG("Last Checked SHA: {}", last_checked_sha.toUtf8().constData()); 564 if (m_latest_sha == g_scm_hash_str || m_latest_sha == last_checked_sha) 565 { 566 INFO_LOG("No update needed."); 567 return false; 568 } 569 570 INFO_LOG("Update needed."); 571 return true; 572 } 573 574 void AutoUpdaterDialog::skipThisUpdateClicked() 575 { 576 Host::SetBaseStringSettingValue("AutoUpdater", "LastVersion", m_latest_sha.toUtf8().constData()); 577 Host::CommitBaseSettingChanges(); 578 done(0); 579 } 580 581 void AutoUpdaterDialog::remindMeLaterClicked() 582 { 583 done(0); 584 } 585 586 #ifdef _WIN32 587 588 static constexpr char UPDATER_EXECUTABLE[] = "updater.exe"; 589 static constexpr char UPDATER_ARCHIVE_NAME[] = "update.zip"; 590 591 bool AutoUpdaterDialog::doesUpdaterNeedElevation(const std::string& application_dir) const 592 { 593 // Try to create a dummy text file in the PCSX2 updater directory. If it fails, we probably won't have write 594 // permission. 595 const std::string dummy_path = Path::Combine(application_dir, "update.txt"); 596 auto fp = FileSystem::OpenManagedCFile(dummy_path.c_str(), "wb"); 597 if (!fp) 598 return true; 599 600 fp.reset(); 601 FileSystem::DeleteFile(dummy_path.c_str()); 602 return false; 603 } 604 605 bool AutoUpdaterDialog::processUpdate(const std::vector<u8>& update_data) 606 { 607 const std::string& application_dir = EmuFolders::AppRoot; 608 const std::string update_zip_path = Path::Combine(EmuFolders::DataRoot, UPDATER_ARCHIVE_NAME); 609 const std::string updater_path = Path::Combine(EmuFolders::DataRoot, UPDATER_EXECUTABLE); 610 611 if ((FileSystem::FileExists(update_zip_path.c_str()) && !FileSystem::DeleteFile(update_zip_path.c_str()))) 612 { 613 reportError("Removing existing update zip failed"); 614 return false; 615 } 616 617 if (!FileSystem::WriteBinaryFile(update_zip_path.c_str(), update_data.data(), update_data.size())) 618 { 619 reportError(fmt::format("Writing update zip to '{}' failed", update_zip_path)); 620 return false; 621 } 622 623 Error updater_extract_error; 624 if (!extractUpdater(update_zip_path.c_str(), updater_path.c_str(), &updater_extract_error)) 625 { 626 reportError(fmt::format("Extracting updater failed: {}", updater_extract_error.GetDescription())); 627 return false; 628 } 629 630 return doUpdate(application_dir, update_zip_path, updater_path); 631 } 632 633 bool AutoUpdaterDialog::extractUpdater(const std::string& zip_path, const std::string& destination_path, Error* error) 634 { 635 unzFile zf = MinizipHelpers::OpenUnzFile(zip_path.c_str()); 636 if (!zf) 637 { 638 reportError("Failed to open update zip"); 639 return false; 640 } 641 642 if (unzLocateFile(zf, UPDATER_EXECUTABLE, 0) != UNZ_OK || unzOpenCurrentFile(zf) != UNZ_OK) 643 { 644 Error::SetString(error, "Failed to locate updater.exe"); 645 unzClose(zf); 646 return false; 647 } 648 649 auto fp = FileSystem::OpenManagedCFile(destination_path.c_str(), "wb", error); 650 if (!fp) 651 { 652 Error::SetString(error, "Failed to open updater.exe for writing"); 653 unzClose(zf); 654 return false; 655 } 656 657 static constexpr size_t CHUNK_SIZE = 4096; 658 char chunk[CHUNK_SIZE]; 659 for (;;) 660 { 661 int size = unzReadCurrentFile(zf, chunk, CHUNK_SIZE); 662 if (size < 0) 663 { 664 Error::SetString(error, "Failed to decompress updater exe"); 665 unzClose(zf); 666 fp.reset(); 667 FileSystem::DeleteFile(destination_path.c_str()); 668 return false; 669 } 670 else if (size == 0) 671 { 672 break; 673 } 674 675 if (std::fwrite(chunk, size, 1, fp.get()) != 1) 676 { 677 Error::SetString(error, "Failed to write updater exe"); 678 unzClose(zf); 679 fp.reset(); 680 FileSystem::DeleteFile(destination_path.c_str()); 681 return false; 682 } 683 } 684 685 unzClose(zf); 686 return true; 687 } 688 689 bool AutoUpdaterDialog::doUpdate(const std::string& application_dir, const std::string& zip_path, 690 const std::string& updater_path) 691 { 692 const std::string program_path = QDir::toNativeSeparators(QCoreApplication::applicationFilePath()).toStdString(); 693 if (program_path.empty()) 694 { 695 reportError("Failed to get current application path"); 696 return false; 697 } 698 699 const std::wstring wupdater_path = StringUtil::UTF8StringToWideString(updater_path); 700 const std::wstring wapplication_dir = StringUtil::UTF8StringToWideString(application_dir); 701 const std::wstring arguments = StringUtil::UTF8StringToWideString(fmt::format( 702 "{} \"{}\" \"{}\" \"{}\"", QCoreApplication::applicationPid(), application_dir, zip_path, program_path)); 703 704 const bool needs_elevation = doesUpdaterNeedElevation(application_dir); 705 706 SHELLEXECUTEINFOW sei = {}; 707 sei.cbSize = sizeof(sei); 708 sei.lpVerb = needs_elevation ? L"runas" : nullptr; // needed to trigger elevation 709 sei.lpFile = wupdater_path.c_str(); 710 sei.lpParameters = arguments.c_str(); 711 sei.lpDirectory = wapplication_dir.c_str(); 712 sei.nShow = SW_SHOWNORMAL; 713 if (!ShellExecuteExW(&sei)) 714 { 715 reportError(fmt::format("Failed to start {}: {}", needs_elevation ? "elevated updater" : "updater", 716 Error::CreateWin32(GetLastError()).GetDescription())); 717 return false; 718 } 719 720 return true; 721 } 722 723 void AutoUpdaterDialog::cleanupAfterUpdate() 724 { 725 // If we weren't portable, then updater executable gets left in the application directory. 726 if (EmuFolders::AppRoot == EmuFolders::DataRoot) 727 return; 728 729 const std::string updater_path = Path::Combine(EmuFolders::DataRoot, UPDATER_EXECUTABLE); 730 if (!FileSystem::FileExists(updater_path.c_str())) 731 return; 732 733 if (!FileSystem::DeleteFile(updater_path.c_str())) 734 { 735 QMessageBox::critical(nullptr, tr("Updater Error"), tr("Failed to remove updater exe after update.")); 736 return; 737 } 738 } 739 740 #elif defined(__APPLE__) 741 742 bool AutoUpdaterDialog::processUpdate(const std::vector<u8>& update_data) 743 { 744 std::optional<std::string> bundle_path = CocoaTools::GetNonTranslocatedBundlePath(); 745 if (!bundle_path.has_value()) 746 { 747 reportError("Couldn't obtain non-translocated bundle path."); 748 return false; 749 } 750 751 QFileInfo info(QString::fromStdString(bundle_path.value())); 752 if (!info.isBundle()) 753 { 754 reportError(fmt::format("Application {} isn't a bundle.", bundle_path.value())); 755 return false; 756 } 757 if (info.suffix() != QStringLiteral("app")) 758 { 759 reportError( 760 fmt::format("Unexpected application suffix {} on {}.", info.suffix().toStdString(), bundle_path.value())); 761 return false; 762 } 763 764 // Use the updater from this version to unpack the new version. 765 const std::string updater_app = Path::Combine(bundle_path.value(), "Contents/Resources/Updater.app"); 766 if (!FileSystem::DirectoryExists(updater_app.c_str())) 767 { 768 reportError(fmt::format("Failed to find updater at {}.", updater_app)); 769 return false; 770 } 771 772 // We use the user data directory to temporarily store the update zip. 773 const std::string zip_path = Path::Combine(EmuFolders::DataRoot, "update.zip"); 774 const std::string staging_directory = Path::Combine(EmuFolders::DataRoot, "UPDATE_STAGING"); 775 if (FileSystem::FileExists(zip_path.c_str()) && !FileSystem::DeleteFile(zip_path.c_str())) 776 { 777 reportError("Failed to remove old update zip."); 778 return false; 779 } 780 781 // Save update. 782 { 783 QFile zip_file(QString::fromStdString(zip_path)); 784 if (!zip_file.open(QIODevice::WriteOnly) || 785 zip_file.write(reinterpret_cast<const char*>(update_data.data()), static_cast<qint64>(update_data.size())) != 786 static_cast<qint64>(update_data.size())) 787 { 788 reportError(fmt::format("Writing update zip to '{}' failed", zip_path)); 789 return false; 790 } 791 zip_file.close(); 792 } 793 794 INFO_LOG("Beginning update:\nUpdater path: {}\nZip path: {}\nStaging directory: {}\nOutput directory: {}", 795 updater_app, zip_path, staging_directory, bundle_path.value()); 796 797 const std::string_view args[] = { 798 zip_path, 799 staging_directory, 800 bundle_path.value(), 801 }; 802 803 // Kick off updater! 804 CocoaTools::DelayedLaunch(updater_app, args); 805 return true; 806 } 807 808 void AutoUpdaterDialog::cleanupAfterUpdate() 809 { 810 } 811 812 #elif defined(__linux__) 813 814 bool AutoUpdaterDialog::processUpdate(const std::vector<u8>& update_data) 815 { 816 const char* appimage_path = std::getenv("APPIMAGE"); 817 if (!appimage_path || !FileSystem::FileExists(appimage_path)) 818 { 819 reportError("Missing APPIMAGE."); 820 return false; 821 } 822 823 const QString qappimage_path(QString::fromUtf8(appimage_path)); 824 if (!QFile::exists(qappimage_path)) 825 { 826 reportError(fmt::format("Current AppImage does not exist: {}", appimage_path)); 827 return false; 828 } 829 830 const QString new_appimage_path(qappimage_path + QStringLiteral(".new")); 831 const QString backup_appimage_path(qappimage_path + QStringLiteral(".backup")); 832 INFO_LOG("APPIMAGE = {}", appimage_path); 833 INFO_LOG("Backup AppImage path = {}", backup_appimage_path.toStdString()); 834 INFO_LOG("New AppImage path = {}", new_appimage_path.toStdString()); 835 836 // Remove old "new" appimage and existing backup appimage. 837 if (QFile::exists(new_appimage_path) && !QFile::remove(new_appimage_path)) 838 { 839 reportError(fmt::format("Failed to remove old destination AppImage: {}", new_appimage_path.toStdString())); 840 return false; 841 } 842 if (QFile::exists(backup_appimage_path) && !QFile::remove(backup_appimage_path)) 843 { 844 reportError(fmt::format("Failed to remove old backup AppImage: {}", new_appimage_path.toStdString())); 845 return false; 846 } 847 848 // Write "new" appimage. 849 { 850 // We want to copy the permissions from the old appimage to the new one. 851 QFile old_file(qappimage_path); 852 const QFileDevice::Permissions old_permissions = old_file.permissions(); 853 QFile new_file(new_appimage_path); 854 if (!new_file.open(QIODevice::WriteOnly) || 855 new_file.write(reinterpret_cast<const char*>(update_data.data()), static_cast<qint64>(update_data.size())) != 856 static_cast<qint64>(update_data.size()) || 857 !new_file.setPermissions(old_permissions)) 858 { 859 QFile::remove(new_appimage_path); 860 reportError(fmt::format("Failed to write new destination AppImage: {}", new_appimage_path.toStdString())); 861 return false; 862 } 863 } 864 865 // Rename "old" appimage. 866 if (!QFile::rename(qappimage_path, backup_appimage_path)) 867 { 868 reportError(fmt::format("Failed to rename old AppImage to {}", backup_appimage_path.toStdString())); 869 QFile::remove(new_appimage_path); 870 return false; 871 } 872 873 // Rename "new" appimage. 874 if (!QFile::rename(new_appimage_path, qappimage_path)) 875 { 876 reportError(fmt::format("Failed to rename new AppImage to {}", qappimage_path.toStdString())); 877 return false; 878 } 879 880 // Execute new appimage. 881 QProcess* new_process = new QProcess(); 882 new_process->setProgram(qappimage_path); 883 new_process->setArguments(QStringList{QStringLiteral("-updatecleanup")}); 884 if (!new_process->startDetached()) 885 { 886 reportError("Failed to execute new AppImage."); 887 return false; 888 } 889 890 // We exit once we return. 891 return true; 892 } 893 894 void AutoUpdaterDialog::cleanupAfterUpdate() 895 { 896 // Remove old/backup AppImage. 897 const char* appimage_path = std::getenv("APPIMAGE"); 898 if (!appimage_path) 899 return; 900 901 const QString qappimage_path(QString::fromUtf8(appimage_path)); 902 const QString backup_appimage_path(qappimage_path + QStringLiteral(".backup")); 903 if (!QFile::exists(backup_appimage_path)) 904 return; 905 906 INFO_LOG(QStringLiteral("Removing backup AppImage %1").arg(backup_appimage_path).toStdString().c_str()); 907 if (!QFile::remove(backup_appimage_path)) 908 { 909 ERROR_LOG(QStringLiteral("Failed to remove backup AppImage %1").arg(backup_appimage_path).toStdString().c_str()); 910 } 911 } 912 913 #else 914 915 bool AutoUpdaterDialog::processUpdate(const std::vector<u8>& update_data) 916 { 917 return false; 918 } 919 920 void AutoUpdaterDialog::cleanupAfterUpdate() 921 { 922 } 923 924 #endif