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