Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
stenzek
GitHub Repository: stenzek/duckstation
Path: blob/master/src/duckstation-qt/autoupdaterdialog.cpp
7513 views
1
// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin <[email protected]>
2
// SPDX-License-Identifier: 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 "core/core.h"
13
14
#include "util/http_downloader.h"
15
#include "util/translation.h"
16
17
#include "common/assert.h"
18
#include "common/error.h"
19
#include "common/file_system.h"
20
#include "common/log.h"
21
#include "common/minizip_helpers.h"
22
#include "common/path.h"
23
#include "common/string_util.h"
24
25
#include "fmt/format.h"
26
27
#include <QtCore/QCoreApplication>
28
#include <QtCore/QFileInfo>
29
#include <QtCore/QJsonArray>
30
#include <QtCore/QJsonDocument>
31
#include <QtCore/QJsonObject>
32
#include <QtCore/QJsonValue>
33
#include <QtCore/QProcess>
34
#include <QtCore/QString>
35
#include <QtCore/QTimer>
36
#include <QtWidgets/QCheckBox>
37
#include <QtWidgets/QDialog>
38
#include <QtWidgets/QMessageBox>
39
#include <QtWidgets/QProgressDialog>
40
#include <QtWidgets/QPushButton>
41
42
#include "moc_autoupdaterdialog.cpp"
43
44
using namespace Qt::StringLiterals;
45
46
// Interval at which HTTP requests are polled.
47
static constexpr u32 HTTP_POLL_INTERVAL = 10;
48
49
#if defined(_WIN32)
50
#include "common/windows_headers.h"
51
#include <shellapi.h>
52
#elif defined(__APPLE__)
53
#include "common/cocoa_tools.h"
54
#else
55
#include <sys/stat.h>
56
#endif
57
58
// Logic to detect whether we can use the auto updater.
59
// Requires that the channel be defined by the buildbot.
60
#if __has_include("scmversion/tag.h")
61
#include "scmversion/tag.h"
62
#else
63
#define UPDATER_RELEASE_CHANNEL "preview"
64
#endif
65
66
// Updater asset information.
67
// clang-format off
68
#if defined(_WIN32)
69
#if defined(CPU_ARCH_X64) && defined(CPU_ARCH_SSE41)
70
#define UPDATER_EXPECTED_EXECUTABLE "duckstation-qt-x64-ReleaseLTCG.exe"
71
#define UPDATER_ASSET_FILENAME "duckstation-windows-x64-release.zip"
72
#elif defined(CPU_ARCH_X64)
73
#define UPDATER_EXPECTED_EXECUTABLE "duckstation-qt-x64-ReleaseLTCG-SSE2.exe"
74
#define UPDATER_ASSET_FILENAME "duckstation-windows-x64-sse2-release.zip"
75
#elif defined(CPU_ARCH_ARM64)
76
#define UPDATER_EXPECTED_EXECUTABLE "duckstation-qt-ARM64-ReleaseLTCG.exe"
77
#define UPDATER_ASSET_FILENAME "duckstation-windows-arm64-release.zip"
78
#endif
79
#elif defined(__APPLE__)
80
#define UPDATER_ASSET_FILENAME "duckstation-mac-release.zip"
81
#elif defined(__linux__)
82
#if defined(CPU_ARCH_X64) && defined(CPU_ARCH_SSE41)
83
#define UPDATER_ASSET_FILENAME "DuckStation-x64.AppImage"
84
#elif defined(CPU_ARCH_X64)
85
#define UPDATER_ASSET_FILENAME "DuckStation-x64-SSE2.AppImage"
86
#elif defined(CPU_ARCH_ARM64)
87
#define UPDATER_ASSET_FILENAME "DuckStation-arm64.AppImage"
88
#elif defined(CPU_ARCH_ARM32)
89
#define UPDATER_ASSET_FILENAME "DuckStation-armhf.AppImage"
90
#elif defined(CPU_ARCH_RISCV64)
91
#define UPDATER_ASSET_FILENAME "DuckStation-riscv64.AppImage"
92
#endif
93
#endif
94
#ifndef UPDATER_ASSET_FILENAME
95
#error Unsupported platform.
96
#endif
97
// clang-format on
98
99
// URLs for downloading updates.
100
#define LATEST_TAG_URL "https://api.github.com/repos/stenzek/duckstation/tags"
101
#define LATEST_RELEASE_URL "https://api.github.com/repos/stenzek/duckstation/releases/tags/{}"
102
#define CHANGES_URL "https://api.github.com/repos/stenzek/duckstation/compare/{}...{}"
103
#define DOWNLOAD_PAGE_URL "https://github.com/stenzek/duckstation/releases/tag/{}"
104
105
// Update channels.
106
static constexpr const std::pair<const char*, const char*> s_update_channels[] = {
107
{"latest", QT_TRANSLATE_NOOP("AutoUpdaterWindow", "Stable Releases")},
108
{"preview", QT_TRANSLATE_NOOP("AutoUpdaterWindow", "Preview Releases")},
109
};
110
111
LOG_CHANNEL(Host);
112
113
AutoUpdaterDialog::AutoUpdaterDialog(QWidget* const parent, Error* const error) : QDialog(parent)
114
{
115
m_ui.setupUi(this);
116
QFont title_font(m_ui.titleLabel->font());
117
title_font.setBold(true);
118
title_font.setPixelSize(20);
119
m_ui.titleLabel->setFont(title_font);
120
setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
121
setDownloadSectionVisibility(false);
122
123
connect(m_ui.downloadAndInstall, &QPushButton::clicked, this, &AutoUpdaterDialog::downloadUpdateClicked);
124
connect(m_ui.skipThisUpdate, &QPushButton::clicked, this, &AutoUpdaterDialog::skipThisUpdateClicked);
125
connect(m_ui.remindMeLater, &QPushButton::clicked, this, &AutoUpdaterDialog::remindMeLaterClicked);
126
127
m_http = HTTPDownloader::Create(Core::GetHTTPUserAgent(), error);
128
129
m_http_poll_timer = new QTimer(this);
130
m_http_poll_timer->connect(m_http_poll_timer, &QTimer::timeout, this, &AutoUpdaterDialog::httpPollTimerPoll);
131
}
132
133
AutoUpdaterDialog::~AutoUpdaterDialog() = default;
134
135
AutoUpdaterDialog* AutoUpdaterDialog::create(QWidget* const parent, Error* const error)
136
{
137
AutoUpdaterDialog* const win = new AutoUpdaterDialog(parent, error);
138
if (!win->m_http)
139
{
140
delete win;
141
return nullptr;
142
}
143
144
return win;
145
}
146
147
void AutoUpdaterDialog::warnAboutUnofficialBuild()
148
{
149
//
150
// To those distributing their own builds or packages of DuckStation, and seeing this message:
151
//
152
// DuckStation is licensed under the CC-BY-NC-ND-4.0 license.
153
//
154
// This means that you do NOT have permission to re-distribute your own modified builds of DuckStation.
155
// Modifying DuckStation for personal use is fine, but you cannot distribute builds with your changes.
156
// As per the CC-BY-NC-ND conditions, you can re-distribute the official builds from https://www.duckstation.org/ and
157
// https://github.com/stenzek/duckstation, so long as they are left intact, without modification. I welcome and
158
// appreciate any pull requests made to the official repository at https://github.com/stenzek/duckstation.
159
//
160
// I made the decision to switch to a no-derivatives license because of numerous "forks" that were created purely for
161
// generating money for the person who knocked it off, and always died, leaving the community with multiple builds to
162
// choose from, most of which were out of date and broken, and endless confusion. Other forks copy/pasted upstream
163
// changes without attribution, violating copyright.
164
//
165
// Thanks, and I hope you understand.
166
//
167
168
#if !__has_include("scmversion/tag.h") || !UPDATER_RELEASE_IS_OFFICIAL
169
constexpr const char* CONFIG_SECTION = "UI";
170
constexpr const char* CONFIG_KEY = "UnofficialBuildWarningConfirmed";
171
if (
172
#if !defined(_WIN32) && !defined(__APPLE__)
173
EmuFolders::AppRoot.starts_with("/home") && // Devbuilds should be in home directory.
174
#endif
175
Core::GetBaseBoolSettingValue(CONFIG_SECTION, CONFIG_KEY, false))
176
{
177
return;
178
}
179
180
constexpr int DELAY_SECONDS = 10;
181
182
const QString message =
183
QStringLiteral("<h1>You are not using an official release!</h1><h3>DuckStation is licensed under the terms of "
184
"CC-BY-NC-ND-4.0, which does not allow modified builds to be distributed.</h3>"
185
"<p>If you are a developer and using a local build, you can check the box below and continue.</p>"
186
"<p>Otherwise, you should delete this build and download an official release from "
187
"<a href=\"https://www.duckstation.org/\">duckstation.org</a>.</p><p>Do you want to exit and "
188
"open this page now?</p>");
189
190
QMessageBox mbox;
191
mbox.setIcon(QMessageBox::Warning);
192
mbox.setWindowTitle(QStringLiteral("Unofficial Build Warning"));
193
mbox.setWindowIcon(QtHost::GetAppIcon());
194
mbox.setWindowFlag(Qt::CustomizeWindowHint, true);
195
mbox.setWindowFlag(Qt::WindowCloseButtonHint, false);
196
mbox.setTextFormat(Qt::RichText);
197
mbox.setText(message);
198
199
mbox.addButton(QMessageBox::Yes);
200
QPushButton* no = mbox.addButton(QMessageBox::No);
201
const QString orig_no_text = no->text();
202
no->setEnabled(false);
203
204
QCheckBox* cb = new QCheckBox(&mbox);
205
cb->setText(tr("Do not show again"));
206
mbox.setCheckBox(cb);
207
208
int remaining_time = DELAY_SECONDS;
209
no->setText(QStringLiteral("%1 [%2]").arg(orig_no_text).arg(remaining_time));
210
211
QTimer* timer = new QTimer(&mbox);
212
connect(timer, &QTimer::timeout, &mbox, [no, timer, &remaining_time, &orig_no_text]() {
213
remaining_time--;
214
if (remaining_time == 0)
215
{
216
no->setText(orig_no_text);
217
no->setEnabled(true);
218
timer->stop();
219
}
220
else
221
{
222
no->setText(QStringLiteral("%1 [%2]").arg(orig_no_text).arg(remaining_time));
223
}
224
});
225
timer->start(1000);
226
227
if (mbox.exec() == QMessageBox::Yes)
228
{
229
QtUtils::OpenURL(nullptr, "https://duckstation.org/");
230
QMetaObject::invokeMethod(qApp, &QApplication::quit, Qt::QueuedConnection);
231
return;
232
}
233
234
if (cb->isChecked())
235
Core::SetBaseBoolSettingValue(CONFIG_SECTION, CONFIG_KEY, true);
236
#endif
237
}
238
239
std::vector<std::pair<QString, QString>> AutoUpdaterDialog::getChannelList()
240
{
241
std::vector<std::pair<QString, QString>> ret;
242
ret.reserve(std::size(s_update_channels));
243
for (const auto& [name, desc] : s_update_channels)
244
ret.emplace_back(QString::fromUtf8(name), qApp->translate("AutoUpdaterWindow", desc));
245
return ret;
246
}
247
248
std::string AutoUpdaterDialog::getDefaultTag()
249
{
250
return UPDATER_RELEASE_CHANNEL;
251
}
252
253
std::string AutoUpdaterDialog::getCurrentUpdateTag()
254
{
255
return Core::GetBaseStringSettingValue("AutoUpdater", "UpdateTag", UPDATER_RELEASE_CHANNEL);
256
}
257
258
void AutoUpdaterDialog::setDownloadSectionVisibility(bool visible)
259
{
260
m_ui.downloadProgress->setVisible(visible);
261
m_ui.downloadStatus->setVisible(visible);
262
m_ui.downloadButtonBox->setVisible(visible);
263
m_ui.downloadAndInstall->setVisible(!visible);
264
m_ui.skipThisUpdate->setVisible(!visible);
265
m_ui.remindMeLater->setVisible(!visible);
266
}
267
268
void AutoUpdaterDialog::reportError(const std::string_view msg)
269
{
270
emit updateCheckAboutToComplete();
271
272
// if we're visible, use ourselves.
273
QWidget* const parent_widget = (isVisible() ? static_cast<QWidget*>(this) : parentWidget());
274
const QString full_msg = tr("Failed to retrieve or download update:\n\n%1\n\nYou can manually update DuckStation by "
275
"re-downloading the latest release. Do you want to open the download page now?")
276
.arg(QtUtils::StringViewToQString(msg));
277
QMessageBox* const msgbox = QtUtils::NewMessageBox(parent_widget, QMessageBox::Critical, tr("Updater Error"),
278
full_msg, QMessageBox::Yes | QMessageBox::No);
279
msgbox->connect(msgbox, &QMessageBox::accepted, parent_widget, [parent_widget]() {
280
QtUtils::OpenURL(parent_widget, fmt::format(DOWNLOAD_PAGE_URL, UPDATER_RELEASE_CHANNEL));
281
});
282
msgbox->open();
283
}
284
285
void AutoUpdaterDialog::ensureHttpPollingActive()
286
{
287
if (m_http_poll_timer->isActive())
288
return;
289
290
m_http_poll_timer->setSingleShot(false);
291
m_http_poll_timer->setInterval(HTTP_POLL_INTERVAL);
292
m_http_poll_timer->start();
293
}
294
295
void AutoUpdaterDialog::httpPollTimerPoll()
296
{
297
m_http->PollRequests();
298
299
if (!m_http->HasAnyRequests())
300
{
301
VERBOSE_LOG("All HTTP requests done.");
302
m_http_poll_timer->stop();
303
}
304
}
305
306
void AutoUpdaterDialog::cancel()
307
{
308
if (m_updates_available)
309
return;
310
311
m_http->CancelAllRequests();
312
}
313
314
bool AutoUpdaterDialog::handleCancelledRequest(s32 status_code)
315
{
316
if (status_code != HTTPDownloader::HTTP_STATUS_CANCELLED)
317
return false;
318
319
emit updateCheckAboutToComplete();
320
emit updateCheckCompleted(false);
321
return true;
322
}
323
324
void AutoUpdaterDialog::queueUpdateCheck(bool display_errors, bool ignore_skipped_updates)
325
{
326
if (ignore_skipped_updates)
327
{
328
// Wipe out the last version, that way it displays the update if we've previously skipped it.
329
Core::DeleteBaseSettingValue("AutoUpdater", "LastVersion");
330
Host::CommitBaseSettingChanges();
331
}
332
333
ensureHttpPollingActive();
334
m_http->CreateRequest(LATEST_TAG_URL,
335
[this, display_errors](s32 status_code, const Error& error, const std::string& content_type,
336
std::vector<u8> response) {
337
getLatestTagComplete(status_code, error, std::move(response), display_errors);
338
});
339
}
340
341
void AutoUpdaterDialog::queueGetLatestRelease()
342
{
343
ensureHttpPollingActive();
344
std::string url = fmt::format(LATEST_RELEASE_URL, getCurrentUpdateTag());
345
m_http->CreateRequest(std::move(url), std::bind(&AutoUpdaterDialog::getLatestReleaseComplete, this,
346
std::placeholders::_1, std::placeholders::_2, std::placeholders::_4));
347
}
348
349
void AutoUpdaterDialog::getLatestTagComplete(s32 status_code, const Error& error, std::vector<u8> response,
350
bool display_errors)
351
{
352
if (handleCancelledRequest(status_code))
353
return;
354
355
const std::string selected_tag(getCurrentUpdateTag());
356
const QString selected_tag_qstr = QString::fromStdString(selected_tag);
357
358
if (status_code == HTTPDownloader::HTTP_STATUS_OK)
359
{
360
QJsonParseError parse_error;
361
const QJsonDocument doc = QJsonDocument::fromJson(
362
QByteArray(reinterpret_cast<const char*>(response.data()), response.size()), &parse_error);
363
if (doc.isArray())
364
{
365
const QJsonArray doc_array(doc.array());
366
for (const QJsonValue& val : doc_array)
367
{
368
if (!val.isObject())
369
continue;
370
371
if (val["name"].toString() != selected_tag_qstr)
372
continue;
373
374
m_latest_sha = val["commit"].toObject()["sha"].toString();
375
if (m_latest_sha.isEmpty())
376
continue;
377
378
if (updateNeeded())
379
{
380
queueGetLatestRelease();
381
return;
382
}
383
else
384
{
385
emit updateCheckAboutToComplete();
386
387
if (display_errors)
388
{
389
QtUtils::AsyncMessageBox(parentWidget(), QMessageBox::Information, tr("Automatic Updater"),
390
tr("No updates are currently available. Please try again later."));
391
}
392
393
emit updateCheckCompleted(false);
394
return;
395
}
396
}
397
398
if (display_errors)
399
reportError(fmt::format("{} release not found in JSON", selected_tag));
400
}
401
else
402
{
403
if (display_errors)
404
reportError("JSON is not an array");
405
}
406
}
407
else
408
{
409
if (display_errors)
410
reportError(fmt::format("Failed to download latest tag info: {}", error.GetDescription()));
411
}
412
413
emit updateCheckCompleted(false);
414
}
415
416
void AutoUpdaterDialog::getLatestReleaseComplete(s32 status_code, const Error& error, std::vector<u8> response)
417
{
418
if (handleCancelledRequest(status_code))
419
return;
420
421
if (status_code == HTTPDownloader::HTTP_STATUS_OK)
422
{
423
QJsonParseError parse_error;
424
const QJsonDocument doc = QJsonDocument::fromJson(
425
QByteArray(reinterpret_cast<const char*>(response.data()), response.size()), &parse_error);
426
if (doc.isObject())
427
{
428
const QJsonObject doc_object(doc.object());
429
430
// search for the correct file
431
const QJsonArray assets(doc_object["assets"].toArray());
432
const QLatin1StringView asset_filename = UPDATER_ASSET_FILENAME ""_L1;
433
bool asset_found = false;
434
for (const QJsonValue& asset : assets)
435
{
436
const QJsonObject asset_obj(asset.toObject());
437
if (asset_obj["name"] == asset_filename)
438
{
439
m_download_url = asset_obj["browser_download_url"].toString();
440
if (!m_download_url.isEmpty())
441
m_download_size = asset_obj["size"].toInt();
442
asset_found = true;
443
break;
444
}
445
}
446
447
if (asset_found)
448
{
449
emit updateCheckAboutToComplete();
450
451
const QString current_date = QtHost::FormatNumber(
452
Host::NumberFormatType::ShortDateTime,
453
static_cast<s64>(
454
QDateTime::fromString(QString::fromUtf8(g_scm_date_str), Qt::DateFormat::ISODate).toSecsSinceEpoch()));
455
const QString release_date = QtHost::FormatNumber(
456
Host::NumberFormatType::ShortDateTime,
457
static_cast<s64>(
458
QDateTime::fromString(doc_object["published_at"].toString(), Qt::DateFormat::ISODate).toSecsSinceEpoch()));
459
460
m_ui.currentVersion->setText(tr("%1 (%2)")
461
.arg(QtUtils::StringViewToQString(
462
TinyString::from_format("{}/{}", g_scm_version_str, UPDATER_RELEASE_CHANNEL)))
463
.arg(current_date));
464
m_ui.newVersion->setText(tr("%1 (%2)").arg(QString::fromStdString(getCurrentUpdateTag())).arg(release_date));
465
m_ui.downloadSize->setText(tr("%1 MB").arg(static_cast<double>(m_download_size) / 1000000.0, 0, 'f', 2));
466
467
m_ui.downloadAndInstall->setEnabled(true);
468
m_ui.updateNotes->setText(tr("Loading..."));
469
queueGetChanges();
470
m_updates_available = true;
471
emit updateCheckCompleted(true);
472
return;
473
}
474
else
475
{
476
reportError("Asset not found");
477
}
478
}
479
else
480
{
481
reportError("JSON is not an object");
482
}
483
}
484
else
485
{
486
reportError(fmt::format("Failed to download latest release info: {}", error.GetDescription()));
487
}
488
489
emit updateCheckCompleted(false);
490
}
491
492
void AutoUpdaterDialog::queueGetChanges()
493
{
494
ensureHttpPollingActive();
495
std::string url = fmt::format(CHANGES_URL, g_scm_hash_str, getCurrentUpdateTag());
496
m_http->CreateRequest(std::move(url), std::bind(&AutoUpdaterDialog::getChangesComplete, this, std::placeholders::_1,
497
std::placeholders::_2, std::placeholders::_4));
498
}
499
500
void AutoUpdaterDialog::getChangesComplete(s32 status_code, const Error& error, std::vector<u8> response)
501
{
502
std::string_view error_message;
503
504
if (status_code == HTTPDownloader::HTTP_STATUS_OK)
505
{
506
QJsonParseError parse_error;
507
const QJsonDocument doc = QJsonDocument::fromJson(
508
QByteArray(reinterpret_cast<const char*>(response.data()), response.size()), &parse_error);
509
if (doc.isObject())
510
{
511
const QJsonObject doc_object(doc.object());
512
513
QString changes_html = tr("<h2>Changes:</h2>");
514
changes_html += "<ul>"_L1;
515
516
const QJsonArray commits(doc_object["commits"].toArray());
517
bool update_will_break_save_states = false;
518
519
for (const QJsonValue& commit : commits)
520
{
521
const QJsonObject commit_obj(commit["commit"].toObject());
522
523
QString message = commit_obj["message"].toString();
524
QString author = commit_obj["author"].toObject()["name"].toString();
525
const qsizetype first_line_terminator = message.indexOf('\n');
526
if (first_line_terminator >= 0)
527
message.remove(first_line_terminator, message.size() - first_line_terminator);
528
if (!message.isEmpty())
529
{
530
changes_html +=
531
QStringLiteral("<li>%1 <i>(%2)</i></li>").arg(message.toHtmlEscaped()).arg(author.toHtmlEscaped());
532
}
533
534
if (message.contains("[SAVEVERSION+]"_L1))
535
update_will_break_save_states = true;
536
}
537
538
changes_html += "</ul>";
539
540
if (update_will_break_save_states)
541
{
542
changes_html.prepend(tr("<h2>Save State Warning</h2><p>Installing this update will make your save states "
543
"<b>incompatible</b>. Please ensure you have saved your games to memory card "
544
"before installing this update or you will lose progress.</p>"));
545
}
546
547
m_ui.updateNotes->setText(changes_html);
548
return;
549
}
550
else
551
{
552
error_message = "Change list JSON is not an object";
553
}
554
}
555
else
556
{
557
error_message = error.GetDescription();
558
}
559
560
m_ui.updateNotes->setText(QString::fromStdString(
561
fmt::format("<h2>Failed to download change list</h2><p>The error was:<pre>{}</pre></p><p>You may be able to "
562
"install this update anyway. If the download installation fails, you can download the update "
563
"from:</p><p><a href=\"" DOWNLOAD_PAGE_URL "\">" DOWNLOAD_PAGE_URL "</a></p>",
564
error_message, UPDATER_RELEASE_CHANNEL, UPDATER_RELEASE_CHANNEL)));
565
}
566
567
void AutoUpdaterDialog::downloadUpdateClicked()
568
{
569
// Prevent multiple clicks of the button.
570
if (m_download_progress_callback)
571
return;
572
573
setDownloadSectionVisibility(true);
574
575
m_download_progress_callback = new QtProgressCallback(this);
576
m_download_progress_callback->connectWidgets(m_ui.downloadStatus, m_ui.downloadProgress,
577
m_ui.downloadButtonBox->button(QDialogButtonBox::Cancel));
578
m_download_progress_callback->SetStatusText(TRANSLATE_SV("AutoUpdaterWindow", "Downloading Update..."));
579
580
ensureHttpPollingActive();
581
m_http->CreateRequest(
582
m_download_url.toStdString(),
583
[this](s32 status_code, const Error& error, const std::string&, std::vector<u8> response) {
584
m_download_progress_callback->SetStatusText(TRANSLATE_SV("AutoUpdaterWindow", "Processing Update..."));
585
m_download_progress_callback->SetProgressRange(1);
586
m_download_progress_callback->SetProgressValue(1);
587
DebugAssert(m_download_progress_callback);
588
delete m_download_progress_callback;
589
m_download_progress_callback = nullptr;
590
591
if (status_code == HTTPDownloader::HTTP_STATUS_CANCELLED)
592
{
593
setDownloadSectionVisibility(false);
594
return;
595
}
596
597
if (status_code != HTTPDownloader::HTTP_STATUS_OK)
598
{
599
reportError(fmt::format("Download failed: {}", error.GetDescription()));
600
setDownloadSectionVisibility(false);
601
return;
602
}
603
604
if (response.empty())
605
{
606
reportError("Download failed: Update is empty");
607
setDownloadSectionVisibility(false);
608
return;
609
}
610
611
if (processUpdate(response))
612
{
613
// updater started, request exit. can't do it immediately as closing the main window will delete us.
614
QMetaObject::invokeMethod(g_main_window, &MainWindow::requestExit, Qt::QueuedConnection, false);
615
}
616
else
617
{
618
// allow user to try again
619
setDownloadSectionVisibility(false);
620
}
621
},
622
m_download_progress_callback);
623
}
624
625
bool AutoUpdaterDialog::updateNeeded() const
626
{
627
QString last_checked_sha = QString::fromStdString(Core::GetBaseStringSettingValue("AutoUpdater", "LastVersion"));
628
629
INFO_LOG("Current SHA: {}", g_scm_hash_str);
630
INFO_LOG("Latest SHA: {}", m_latest_sha.toUtf8().constData());
631
INFO_LOG("Last Checked SHA: {}", last_checked_sha.toUtf8().constData());
632
if (m_latest_sha == g_scm_hash_str || m_latest_sha == last_checked_sha)
633
{
634
INFO_LOG("No update needed.");
635
return false;
636
}
637
638
INFO_LOG("Update needed.");
639
return true;
640
}
641
642
void AutoUpdaterDialog::skipThisUpdateClicked()
643
{
644
Core::SetBaseStringSettingValue("AutoUpdater", "LastVersion", m_latest_sha.toUtf8().constData());
645
Host::CommitBaseSettingChanges();
646
close();
647
}
648
649
void AutoUpdaterDialog::remindMeLaterClicked()
650
{
651
close();
652
}
653
654
void AutoUpdaterDialog::closeEvent(QCloseEvent* event)
655
{
656
emit closed();
657
QWidget::closeEvent(event);
658
}
659
660
#ifdef _WIN32
661
662
static constexpr char UPDATER_EXECUTABLE[] = "updater.exe";
663
static constexpr char UPDATER_ARCHIVE_NAME[] = "update.zip";
664
665
bool AutoUpdaterDialog::doesUpdaterNeedElevation(const std::string& application_dir) const
666
{
667
// Try to create a dummy text file in the updater directory. If it fails, we probably won't have write permission.
668
const std::string dummy_path = Path::Combine(application_dir, "update.txt");
669
auto fp = FileSystem::OpenManagedCFile(dummy_path.c_str(), "wb");
670
if (!fp)
671
return true;
672
673
fp.reset();
674
FileSystem::DeleteFile(dummy_path.c_str());
675
return false;
676
}
677
678
bool AutoUpdaterDialog::processUpdate(const std::vector<u8>& update_data)
679
{
680
const std::string& application_dir = EmuFolders::AppRoot;
681
const std::string update_zip_path = Path::Combine(EmuFolders::DataRoot, UPDATER_ARCHIVE_NAME);
682
const std::string updater_path = Path::Combine(EmuFolders::DataRoot, UPDATER_EXECUTABLE);
683
684
const std::string program_path = QDir::toNativeSeparators(QCoreApplication::applicationFilePath()).toStdString();
685
if (program_path.empty())
686
{
687
reportError("Failed to get current application path");
688
return false;
689
}
690
691
Error error;
692
if ((FileSystem::FileExists(update_zip_path.c_str()) && !FileSystem::DeleteFile(update_zip_path.c_str(), &error)))
693
{
694
reportError(fmt::format("Removing existing update zip failed:\n{}", error.GetDescription()));
695
return false;
696
}
697
698
if (!FileSystem::WriteAtomicRenamedFile(update_zip_path.c_str(), update_data, &error))
699
{
700
reportError(fmt::format("Writing update zip to '{}' failed:\n{}", update_zip_path, error.GetDescription()));
701
return false;
702
}
703
704
Error updater_extract_error;
705
if (!extractUpdater(update_zip_path.c_str(), updater_path.c_str(), Path::GetFileName(program_path),
706
&updater_extract_error))
707
{
708
reportError(fmt::format("Extracting updater failed: {}", updater_extract_error.GetDescription()));
709
return false;
710
}
711
712
return doUpdate(application_dir, update_zip_path, updater_path, program_path);
713
}
714
715
bool AutoUpdaterDialog::extractUpdater(const std::string& zip_path, const std::string& destination_path,
716
const std::string_view check_for_file, Error* error)
717
{
718
unzFile zf = MinizipHelpers::OpenUnzFile(zip_path.c_str());
719
if (!zf)
720
{
721
reportError("Failed to open update zip");
722
return false;
723
}
724
725
// check the the expected program name is inside the updater zip. if it's not, we're going to launch the old binary
726
// with a partially updated installation
727
if (unzLocateFile(zf, std::string(check_for_file).c_str(), 0) != UNZ_OK)
728
{
729
if (QtUtils::MessageBoxIcon(
730
this, QMessageBox::Warning, tr("Updater Warning"),
731
tr("<h1>Inconsistent Application State</h1><h3>The update zip is missing the current executable:</h3><div "
732
"align=\"center\"><pre>%1</pre></div><p><strong>This is usually a result of manually renaming the "
733
"file.</strong> Continuing to install this update may result in a broken installation if the renamed "
734
"executable is used. The DuckStation executable should be named:<div "
735
"align=\"center\"><pre>%2</pre></div><p>Do you want to continue anyway?</p>")
736
.arg(QString::fromStdString(std::string(check_for_file)))
737
.arg(QStringLiteral(UPDATER_EXPECTED_EXECUTABLE)),
738
QMessageBox::Yes | QMessageBox::No, QMessageBox::NoButton) == QMessageBox::No)
739
{
740
Error::SetStringFmt(error, "Update zip is missing expected file: {}", check_for_file);
741
unzClose(zf);
742
return false;
743
}
744
}
745
746
if (unzLocateFile(zf, UPDATER_EXECUTABLE, 0) != UNZ_OK || unzOpenCurrentFile(zf) != UNZ_OK)
747
{
748
Error::SetString(error, "Failed to locate updater.exe");
749
unzClose(zf);
750
return false;
751
}
752
753
auto fp = FileSystem::OpenManagedCFile(destination_path.c_str(), "wb", error);
754
if (!fp)
755
{
756
Error::SetString(error, "Failed to open updater.exe for writing");
757
unzClose(zf);
758
return false;
759
}
760
761
static constexpr size_t CHUNK_SIZE = 4096;
762
char chunk[CHUNK_SIZE];
763
for (;;)
764
{
765
int size = unzReadCurrentFile(zf, chunk, CHUNK_SIZE);
766
if (size < 0)
767
{
768
Error::SetString(error, "Failed to decompress updater exe");
769
unzClose(zf);
770
fp.reset();
771
FileSystem::DeleteFile(destination_path.c_str());
772
return false;
773
}
774
else if (size == 0)
775
{
776
break;
777
}
778
779
if (std::fwrite(chunk, size, 1, fp.get()) != 1)
780
{
781
Error::SetErrno(error, "Failed to write updater exe: fwrite() failed: ", errno);
782
unzClose(zf);
783
fp.reset();
784
FileSystem::DeleteFile(destination_path.c_str());
785
return false;
786
}
787
}
788
789
unzClose(zf);
790
return true;
791
}
792
793
bool AutoUpdaterDialog::doUpdate(const std::string& application_dir, const std::string& zip_path,
794
const std::string& updater_path, const std::string& program_path)
795
{
796
const std::wstring wupdater_path = StringUtil::UTF8StringToWideString(updater_path);
797
const std::wstring wapplication_dir = StringUtil::UTF8StringToWideString(application_dir);
798
const std::wstring arguments = StringUtil::UTF8StringToWideString(fmt::format(
799
"{} \"{}\" \"{}\" \"{}\"", QCoreApplication::applicationPid(), application_dir, zip_path, program_path));
800
801
const bool needs_elevation = doesUpdaterNeedElevation(application_dir);
802
803
SHELLEXECUTEINFOW sei = {};
804
sei.cbSize = sizeof(sei);
805
sei.lpVerb = needs_elevation ? L"runas" : nullptr; // needed to trigger elevation
806
sei.lpFile = wupdater_path.c_str();
807
sei.lpParameters = arguments.c_str();
808
sei.lpDirectory = wapplication_dir.c_str();
809
sei.nShow = SW_SHOWNORMAL;
810
if (!ShellExecuteExW(&sei))
811
{
812
reportError(fmt::format("Failed to start {}: {}", needs_elevation ? "elevated updater" : "updater",
813
Error::CreateWin32(GetLastError()).GetDescription()));
814
return false;
815
}
816
817
return true;
818
}
819
820
void AutoUpdaterDialog::cleanupAfterUpdate()
821
{
822
// If we weren't portable, then updater executable gets left in the application directory.
823
if (EmuFolders::AppRoot == EmuFolders::DataRoot)
824
return;
825
826
const std::string updater_path = Path::Combine(EmuFolders::DataRoot, UPDATER_EXECUTABLE);
827
if (!FileSystem::FileExists(updater_path.c_str()))
828
return;
829
830
Error error;
831
if (!FileSystem::DeleteFile(updater_path.c_str(), &error))
832
{
833
QtUtils::MessageBoxCritical(
834
nullptr, tr("Updater Error"),
835
tr("Failed to remove updater exe after update:\n%1").arg(QString::fromStdString(error.GetDescription())));
836
return;
837
}
838
}
839
840
#elif defined(__APPLE__)
841
842
bool AutoUpdaterDialog::processUpdate(const std::vector<u8>& update_data)
843
{
844
std::optional<std::string> bundle_path = CocoaTools::GetNonTranslocatedBundlePath();
845
if (!bundle_path.has_value())
846
{
847
reportError("Couldn't obtain non-translocated bundle path.");
848
return false;
849
}
850
851
QFileInfo info(QString::fromStdString(bundle_path.value()));
852
if (!info.isBundle())
853
{
854
reportError(fmt::format("Application {} isn't a bundle.", bundle_path.value()));
855
return false;
856
}
857
if (info.suffix() != "app"_L1)
858
{
859
reportError(
860
fmt::format("Unexpected application suffix {} on {}.", info.suffix().toStdString(), bundle_path.value()));
861
return false;
862
}
863
864
// Use the updater from this version to unpack the new version.
865
const std::string updater_app = Path::Combine(bundle_path.value(), "Contents/Resources/Updater.app");
866
if (!FileSystem::DirectoryExists(updater_app.c_str()))
867
{
868
reportError(fmt::format("Failed to find updater at {}.", updater_app));
869
return false;
870
}
871
872
// We use the user data directory to temporarily store the update zip.
873
const std::string zip_path = Path::Combine(EmuFolders::DataRoot, "update.zip");
874
const std::string staging_directory = Path::Combine(EmuFolders::DataRoot, "UPDATE_STAGING");
875
Error error;
876
if (FileSystem::FileExists(zip_path.c_str()) && !FileSystem::DeleteFile(zip_path.c_str(), &error))
877
{
878
reportError(fmt::format("Failed to remove old update zip:\n{}", error.GetDescription()));
879
return false;
880
}
881
882
// Save update.
883
if (!FileSystem::WriteAtomicRenamedFile(zip_path.c_str(), update_data, &error))
884
{
885
reportError(fmt::format("Writing update zip to '{}' failed:\n{}", zip_path, error.GetDescription()));
886
return false;
887
}
888
889
INFO_LOG("Beginning update:\nUpdater path: {}\nZip path: {}\nStaging directory: {}\nOutput directory: {}",
890
updater_app, zip_path, staging_directory, bundle_path.value());
891
892
const std::string_view args[] = {
893
zip_path,
894
staging_directory,
895
bundle_path.value(),
896
};
897
898
// Kick off updater!
899
CocoaTools::DelayedLaunch(updater_app, args);
900
return true;
901
}
902
903
void AutoUpdaterDialog::cleanupAfterUpdate()
904
{
905
}
906
907
#elif defined(__linux__)
908
909
bool AutoUpdaterDialog::processUpdate(const std::vector<u8>& update_data)
910
{
911
const char* appimage_path = std::getenv("APPIMAGE");
912
if (!appimage_path || !FileSystem::FileExists(appimage_path))
913
{
914
reportError("Missing APPIMAGE.");
915
return false;
916
}
917
918
if (!FileSystem::FileExists(appimage_path))
919
{
920
reportError(fmt::format("Current AppImage does not exist: {}", appimage_path));
921
return false;
922
}
923
924
const std::string new_appimage_path = fmt::format("{}.new", appimage_path);
925
const std::string backup_appimage_path = fmt::format("{}.backup", appimage_path);
926
INFO_LOG("APPIMAGE = {}", appimage_path);
927
INFO_LOG("Backup AppImage path = {}", backup_appimage_path);
928
INFO_LOG("New AppImage path = {}", new_appimage_path);
929
930
// Remove old "new" appimage and existing backup appimage.
931
Error error;
932
if (FileSystem::FileExists(new_appimage_path.c_str()) && !FileSystem::DeleteFile(new_appimage_path.c_str(), &error))
933
{
934
reportError(
935
fmt::format("Failed to remove old destination AppImage: {}:\n{}", new_appimage_path, error.GetDescription()));
936
return false;
937
}
938
if (FileSystem::FileExists(backup_appimage_path.c_str()) &&
939
!FileSystem::DeleteFile(backup_appimage_path.c_str(), &error))
940
{
941
reportError(
942
fmt::format("Failed to remove old backup AppImage: {}:\n{}", backup_appimage_path, error.GetDescription()));
943
return false;
944
}
945
946
// Write "new" appimage.
947
{
948
// We want to copy the permissions from the old appimage to the new one.
949
static constexpr int permission_mask = S_IRWXU | S_IRWXG | S_IRWXO;
950
struct stat old_stat;
951
if (!FileSystem::StatFile(appimage_path, &old_stat, &error))
952
{
953
reportError(fmt::format("Failed to get old AppImage {} permissions:\n{}", appimage_path, error.GetDescription()));
954
return false;
955
}
956
957
// We do this as a manual write here, rather than using WriteAtomicUpdatedFile(), because we want to write the
958
// file and set the permissions as one atomic operation.
959
FileSystem::ManagedCFilePtr fp = FileSystem::OpenManagedCFile(new_appimage_path.c_str(), "wb", &error);
960
bool success = static_cast<bool>(fp);
961
if (fp)
962
{
963
if (std::fwrite(update_data.data(), update_data.size(), 1, fp.get()) == 1 && std::fflush(fp.get()) == 0)
964
{
965
const int fd = fileno(fp.get());
966
if (fd >= 0)
967
{
968
if (fchmod(fd, old_stat.st_mode & permission_mask) != 0)
969
{
970
error.SetErrno("fchmod() failed: ", errno);
971
success = false;
972
}
973
}
974
else
975
{
976
error.SetErrno("fileno() failed: ", errno);
977
success = false;
978
}
979
}
980
else
981
{
982
error.SetErrno("fwrite() failed: ", errno);
983
success = false;
984
}
985
986
fp.reset();
987
if (!success)
988
FileSystem::DeleteFile(new_appimage_path.c_str());
989
}
990
991
if (!success)
992
{
993
reportError(
994
fmt::format("Failed to write new destination AppImage: {}:\n{}", new_appimage_path, error.GetDescription()));
995
return false;
996
}
997
}
998
999
// Rename "old" appimage.
1000
if (!FileSystem::RenamePath(appimage_path, backup_appimage_path.c_str(), &error))
1001
{
1002
reportError(fmt::format("Failed to rename old AppImage to {}:\n{}", backup_appimage_path, error.GetDescription()));
1003
FileSystem::DeleteFile(new_appimage_path.c_str());
1004
return false;
1005
}
1006
1007
// Rename "new" appimage.
1008
if (!FileSystem::RenamePath(new_appimage_path.c_str(), appimage_path, &error))
1009
{
1010
reportError(fmt::format("Failed to rename new AppImage to {}:\n{}", appimage_path, error.GetDescription()));
1011
return false;
1012
}
1013
1014
// Execute new appimage.
1015
QProcess* new_process = new QProcess();
1016
new_process->setProgram(QString::fromUtf8(appimage_path));
1017
new_process->setArguments(QStringList{"-updatecleanup"_L1});
1018
if (!new_process->startDetached())
1019
{
1020
reportError("Failed to execute new AppImage.");
1021
return false;
1022
}
1023
1024
// We exit once we return.
1025
return true;
1026
}
1027
1028
void AutoUpdaterDialog::cleanupAfterUpdate()
1029
{
1030
// Remove old/backup AppImage.
1031
const char* appimage_path = std::getenv("APPIMAGE");
1032
if (!appimage_path)
1033
return;
1034
1035
const std::string backup_appimage_path = fmt::format("{}.backup", appimage_path);
1036
if (!FileSystem::FileExists(backup_appimage_path.c_str()))
1037
return;
1038
1039
Error error;
1040
INFO_LOG("Removing backup AppImage: {}", backup_appimage_path);
1041
if (!FileSystem::DeleteFile(backup_appimage_path.c_str(), &error))
1042
ERROR_LOG("Failed to remove backup AppImage {}: {}", backup_appimage_path, error.GetDescription());
1043
}
1044
1045
#else
1046
1047
bool AutoUpdaterDialog::processUpdate(const std::vector<u8>& update_data)
1048
{
1049
return false;
1050
}
1051
1052
void AutoUpdaterDialog::cleanupAfterUpdate()
1053
{
1054
}
1055
1056
#endif
1057
1058