diff --git a/src/duckstation-qt/CMakeLists.txt b/src/duckstation-qt/CMakeLists.txt
index 63b4ab99a..2e1ea28d7 100644
--- a/src/duckstation-qt/CMakeLists.txt
+++ b/src/duckstation-qt/CMakeLists.txt
@@ -12,6 +12,9 @@ set(SRCS
   audiosettingswidget.cpp
   audiosettingswidget.h
   audiosettingswidget.ui
+  autoupdaterdialog.cpp
+  autoupdaterdialog.h
+  autoupdaterdialog.ui
   consolesettingswidget.cpp
   consolesettingswidget.h
   consolesettingswidget.ui
diff --git a/src/duckstation-qt/autoupdaterdialog.cpp b/src/duckstation-qt/autoupdaterdialog.cpp
new file mode 100644
index 000000000..cbaf71f26
--- /dev/null
+++ b/src/duckstation-qt/autoupdaterdialog.cpp
@@ -0,0 +1,444 @@
+#include "autoupdaterdialog.h"
+#include "common/file_system.h"
+#include "common/log.h"
+#include "common/minizip_helpers.h"
+#include "common/string_util.h"
+#include "qthostinterface.h"
+#include "qtutils.h"
+#include "scmversion/scmversion.h"
+#include "unzip.h"
+#include <QtCore/QCoreApplication>
+#include <QtCore/QFile>
+#include <QtCore/QJsonArray>
+#include <QtCore/QJsonDocument>
+#include <QtCore/QJsonObject>
+#include <QtCore/QJsonValue>
+#include <QtCore/QProcess>
+#include <QtCore/QString>
+#include <QtNetwork/QNetworkAccessManager>
+#include <QtNetwork/QNetworkReply>
+#include <QtNetwork/QNetworkRequest>
+#include <QtWidgets/QDialog>
+#include <QtWidgets/QMessageBox>
+#include <QtWidgets/QProgressDialog>
+Log_SetChannel(AutoUpdaterDialog);
+
+// Logic to detect whether we can use the auto updater.
+// Currently Windows-only, and requires that the channel be defined by the buildbot.
+#ifdef WIN32
+#if defined(__has_include) && __has_include("scmversion/tag.h")
+#include "scmversion/tag.h"
+#ifdef SCM_RELEASE_TAG
+#define AUTO_UPDATER_SUPPORTED
+#endif
+#endif
+#endif
+
+#ifdef AUTO_UPDATER_SUPPORTED
+
+static constexpr char LATEST_TAG_URL[] = "https://api.github.com/repos/stenzek/duckstation/tags";
+static constexpr char LATEST_RELEASE_URL[] =
+  "https://api.github.com/repos/stenzek/duckstation/releases/tags/" SCM_RELEASE_TAG;
+static constexpr char UPDATE_ASSET_FILENAME[] = SCM_RELEASE_ASSET;
+
+#else
+
+static constexpr char LATEST_TAG_URL[] = "";
+static constexpr char LATEST_RELEASE_URL[] = "";
+static constexpr char UPDATE_ASSET_FILENAME[] = "";
+
+#endif
+
+AutoUpdaterDialog::AutoUpdaterDialog(QtHostInterface* host_interface, QWidget* parent /* = nullptr */)
+  : QDialog(parent), m_host_interface(host_interface)
+{
+  m_network_access_mgr = new QNetworkAccessManager(this);
+
+  m_ui.setupUi(this);
+
+  setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+
+  // m_ui.description->setTextInteractionFlags(Qt::TextBrowserInteraction);
+  // m_ui.description->setOpenExternalLinks(true);
+
+  connect(m_ui.downloadAndInstall, &QPushButton::clicked, this, &AutoUpdaterDialog::downloadUpdateClicked);
+  connect(m_ui.skipThisUpdate, &QPushButton::clicked, this, &AutoUpdaterDialog::skipThisUpdateClicked);
+  connect(m_ui.remindMeLater, &QPushButton::clicked, this, &AutoUpdaterDialog::remindMeLaterClicked);
+}
+
+AutoUpdaterDialog::~AutoUpdaterDialog() = default;
+
+bool AutoUpdaterDialog::isSupported()
+{
+#ifdef AUTO_UPDATER_SUPPORTED
+  return true;
+#else
+  return false;
+#endif
+}
+
+void AutoUpdaterDialog::reportError(const char* msg, ...)
+{
+  std::va_list ap;
+  va_start(ap, msg);
+  std::string full_msg = StringUtil::StdStringFromFormatV(msg, ap);
+  va_end(ap);
+
+  QMessageBox::critical(this, tr("Updater Error"), QString::fromStdString(full_msg));
+}
+
+void AutoUpdaterDialog::queueUpdateCheck(bool display_message)
+{
+  connect(m_network_access_mgr, &QNetworkAccessManager::finished, this, &AutoUpdaterDialog::getLatestTagComplete);
+
+  QUrl url(QUrl::fromEncoded(QByteArray(LATEST_TAG_URL, sizeof(LATEST_TAG_URL) - 1)));
+  QNetworkRequest request(url);
+  request.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true);
+  m_network_access_mgr->get(request);
+
+  m_display_messages = display_message;
+}
+
+void AutoUpdaterDialog::queueGetLatestRelease()
+{
+  connect(m_network_access_mgr, &QNetworkAccessManager::finished, this, &AutoUpdaterDialog::getLatestReleaseComplete);
+
+  QUrl url(QUrl::fromEncoded(QByteArray(LATEST_RELEASE_URL, sizeof(LATEST_RELEASE_URL) - 1)));
+  QNetworkRequest request(url);
+  request.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true);
+  m_network_access_mgr->get(request);
+}
+
+void AutoUpdaterDialog::getLatestTagComplete(QNetworkReply* reply)
+{
+  // this might fail due to a lack of internet connection - in which case, don't spam the user with messages every time.
+  m_network_access_mgr->disconnect(this);
+  reply->deleteLater();
+
+  if (reply->error() == QNetworkReply::NoError)
+  {
+    const QByteArray reply_json(reply->readAll());
+    QJsonParseError parse_error;
+    QJsonDocument doc(QJsonDocument::fromJson(reply_json, &parse_error));
+    if (doc.isArray())
+    {
+      const QJsonArray doc_array(doc.array());
+      for (const QJsonValue& val : doc_array)
+      {
+        if (!val.isObject())
+          continue;
+
+        if (val["name"].toString() != QStringLiteral(SCM_RELEASE_TAG))
+          continue;
+
+        m_latest_sha = val["commit"].toObject()["sha"].toString();
+        if (m_latest_sha.isEmpty())
+          continue;
+
+        if (updateNeeded())
+        {
+          queueGetLatestRelease();
+          return;
+        }
+        else
+        {
+          if (m_display_messages)
+            QMessageBox::information(this, tr("Automatic Updater"),
+                                     tr("No updates are currently available. Please try again later."));
+          emit updateCheckCompleted();
+          return;
+        }
+      }
+
+      if (m_display_messages)
+        reportError("latest release not found in JSON");
+    }
+    else
+    {
+      if (m_display_messages)
+        reportError("JSON is not an array");
+    }
+  }
+  else
+  {
+    if (m_display_messages)
+      reportError("Failed to download latest tag info: %d", static_cast<int>(reply->error()));
+  }
+
+  emit updateCheckCompleted();
+}
+
+void AutoUpdaterDialog::getLatestReleaseComplete(QNetworkReply* reply)
+{
+  m_network_access_mgr->disconnect(this);
+  reply->deleteLater();
+
+  if (reply->error() == QNetworkReply::NoError)
+  {
+    const QByteArray reply_json(reply->readAll());
+    QJsonParseError parse_error;
+    QJsonDocument doc(QJsonDocument::fromJson(reply_json, &parse_error));
+    if (doc.isObject())
+    {
+      const QJsonObject doc_object(doc.object());
+
+      // search for the correct file
+      const QJsonArray assets(doc_object["assets"].toArray());
+      const QString asset_filename(UPDATE_ASSET_FILENAME);
+      for (const QJsonValue& asset : assets)
+      {
+        const QJsonObject asset_obj(asset.toObject());
+        if (asset_obj["name"] == asset_filename)
+        {
+          m_download_url = asset_obj["browser_download_url"].toString();
+          if (!m_download_url.isEmpty())
+          {
+            m_ui.currentVersion->setText(tr("Current Version: %1 (%2)").arg(g_scm_hash_str).arg(__TIMESTAMP__));
+            m_ui.newVersion->setText(
+              tr("New Version: %1 (%2)").arg(m_latest_sha).arg(doc_object["published_at"].toString()));
+            m_ui.updateNotes->setText(doc_object["body"].toString());
+            exec();
+            emit updateCheckCompleted();
+            return;
+          }
+
+          break;
+        }
+      }
+
+      reportError("Asset/asset download not found");
+    }
+    else
+    {
+      reportError("JSON is not an object");
+    }
+  }
+  else
+  {
+    reportError("Failed to download latest release info: %d", static_cast<int>(reply->error()));
+  }
+
+  emit updateCheckCompleted();
+}
+
+void AutoUpdaterDialog::downloadUpdateClicked()
+{
+  QUrl url(m_download_url);
+  QNetworkRequest request(url);
+  request.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true);
+  QNetworkReply* reply = m_network_access_mgr->get(request);
+
+  QProgressDialog progress(tr("Downloading %1...").arg(m_download_url), tr("Cancel"), 0, 1);
+  progress.setWindowTitle(tr("Automatic Updater"));
+  progress.setWindowIcon(windowIcon());
+  progress.setAutoClose(false);
+
+  connect(reply, &QNetworkReply::downloadProgress, [&progress](quint64 received, quint64 total) {
+    progress.setRange(0, static_cast<int>(total));
+    progress.setValue(static_cast<int>(received));
+  });
+
+  connect(m_network_access_mgr, &QNetworkAccessManager::finished, [this, &progress](QNetworkReply* reply) {
+    m_network_access_mgr->disconnect();
+
+    if (reply->error() != QNetworkReply::NoError)
+    {
+      reportError("Download failed: %s", reply->errorString().toUtf8().constData());
+      progress.done(-1);
+      return;
+    }
+
+    const QByteArray data = reply->readAll();
+    if (data.isEmpty())
+    {
+      reportError("Download failed: Update is empty");
+      progress.done(-1);
+      return;
+    }
+
+    if (processUpdate(data))
+      progress.done(1);
+    else
+      progress.done(-1);
+  });
+
+  const int result = progress.exec();
+  if (result == 0)
+  {
+    // cancelled
+    reply->abort();
+  }
+  else if (result == 1)
+  {
+    // updater started
+    m_host_interface->requestExit();
+    done(0);
+  }
+
+  reply->deleteLater();
+}
+
+bool AutoUpdaterDialog::updateNeeded() const
+{
+  QString last_checked_sha =
+    QString::fromStdString(m_host_interface->GetStringSettingValue("AutoUpdater", "LastVersion"));
+
+  Log_InfoPrintf("Current SHA: %s", g_scm_hash_str);
+  Log_InfoPrintf("Latest SHA: %s", m_latest_sha.toUtf8().constData());
+  Log_InfoPrintf("Last Checked SHA: %s", last_checked_sha.toUtf8().constData());
+  if (m_latest_sha == g_scm_hash_str || m_latest_sha == last_checked_sha)
+  {
+    Log_InfoPrintf("No update needed.");
+    return false;
+  }
+
+  Log_InfoPrintf("Update needed.");
+  return true;
+}
+
+void AutoUpdaterDialog::skipThisUpdateClicked()
+{
+  m_host_interface->SetStringSettingValue("AutoUpdater", "LastVersion", m_latest_sha.toUtf8().constData());
+  done(0);
+}
+
+void AutoUpdaterDialog::remindMeLaterClicked()
+{
+  done(0);
+}
+
+#ifdef WIN32
+
+bool AutoUpdaterDialog::processUpdate(const QByteArray& update_data)
+{
+  const QString update_directory = QCoreApplication::applicationDirPath();
+  const QString update_zip_path = update_directory + QStringLiteral("\\update.zip");
+  const QString updater_path = update_directory + QStringLiteral("\\updater.exe");
+
+  Q_ASSERT(!update_zip_path.isEmpty() && !updater_path.isEmpty() && !update_directory.isEmpty());
+  if ((QFile::exists(update_zip_path) && !QFile::remove(update_zip_path)) ||
+      (QFile::exists(updater_path) && !QFile::remove(updater_path)))
+  {
+    reportError("Removing existing update zip/updater failed");
+    return false;
+  }
+
+  {
+    QFile update_zip_file(update_zip_path);
+    if (!update_zip_file.open(QIODevice::WriteOnly) || update_zip_file.write(update_data) != update_data.size())
+    {
+      reportError("Writing update zip to '%s' failed", update_zip_path.toUtf8().constData());
+      return false;
+    }
+    update_zip_file.close();
+  }
+
+  if (!extractUpdater(update_zip_path, updater_path))
+  {
+    reportError("Extracting updater failed");
+    return false;
+  }
+
+  if (!doUpdate(update_zip_path, updater_path, update_directory))
+  {
+    reportError("Launching updater failed");
+    return false;
+  }
+
+  return true;
+}
+
+bool AutoUpdaterDialog::extractUpdater(const QString& zip_path, const QString& destination_path)
+{
+  unzFile zf = MinizipHelpers::OpenUnzFile(zip_path.toUtf8().constData());
+  if (!zf)
+  {
+    reportError("Failed to open update zip");
+    return false;
+  }
+
+  if (unzLocateFile(zf, "updater.exe", 0) != UNZ_OK || unzOpenCurrentFile(zf) != UNZ_OK)
+  {
+    reportError("Failed to locate updater.exe");
+    unzClose(zf);
+    return false;
+  }
+
+  QFile updater_exe(destination_path);
+  if (!updater_exe.open(QIODevice::WriteOnly))
+  {
+    reportError("Failed to open updater.exe for writing");
+    unzClose(zf);
+    return false;
+  }
+
+  static constexpr size_t CHUNK_SIZE = 4096;
+  char chunk[CHUNK_SIZE];
+  for (;;)
+  {
+    int size = unzReadCurrentFile(zf, chunk, CHUNK_SIZE);
+    if (size < 0)
+    {
+      reportError("Failed to decompress updater exe");
+      unzClose(zf);
+      updater_exe.close();
+      updater_exe.remove();
+      return false;
+    }
+    else if (size == 0)
+    {
+      break;
+    }
+
+    if (updater_exe.write(chunk, size) != size)
+    {
+      reportError("Failed to write updater exe");
+      unzClose(zf);
+      updater_exe.close();
+      updater_exe.remove();
+      return false;
+    }
+  }
+
+  unzClose(zf);
+  updater_exe.close();
+  return true;
+}
+
+bool AutoUpdaterDialog::doUpdate(const QString& zip_path, const QString& updater_path, const QString& destination_path)
+{
+  const QString program_path = QCoreApplication::applicationFilePath();
+  if (program_path.isEmpty())
+  {
+    reportError("Failed to get current application path");
+    return false;
+  }
+
+  QStringList arguments;
+  arguments << QStringLiteral("%1").arg(QCoreApplication::applicationPid());
+  arguments << destination_path;
+  arguments << zip_path;
+  arguments << program_path;
+
+  // this will leak, but not sure how else to handle it...
+  QProcess* updater_process = new QProcess();
+  updater_process->setProgram(updater_path);
+  updater_process->setArguments(arguments);
+  updater_process->start(QIODevice::NotOpen);
+  if (!updater_process->waitForStarted())
+  {
+    reportError("Failed to start updater");
+    return false;
+  }
+
+  return true;
+}
+
+#else
+
+bool AutoUpdaterDialog::processUpdate(const QByteArray& update_data)
+{
+  return false;
+}
+
+#endif
diff --git a/src/duckstation-qt/autoupdaterdialog.h b/src/duckstation-qt/autoupdaterdialog.h
new file mode 100644
index 000000000..dcd3b4195
--- /dev/null
+++ b/src/duckstation-qt/autoupdaterdialog.h
@@ -0,0 +1,55 @@
+#pragma once
+#include "ui_autoupdaterdialog.h"
+#include <QtWidgets/QDialog>
+
+class QNetworkAccessManager;
+class QNetworkReply;
+
+class QtHostInterface;
+
+class AutoUpdaterDialog final : public QDialog
+{
+  Q_OBJECT
+
+public:
+  explicit AutoUpdaterDialog(QtHostInterface* host_interface, QWidget* parent = nullptr);
+  ~AutoUpdaterDialog();
+
+  static bool isSupported();
+
+Q_SIGNALS:
+  void updateCheckCompleted();
+
+public Q_SLOTS:
+  void queueUpdateCheck(bool display_message);
+  void queueGetLatestRelease();
+
+private Q_SLOTS:
+  void getLatestTagComplete(QNetworkReply* reply);
+  void getLatestReleaseComplete(QNetworkReply* reply);
+
+  void downloadUpdateClicked();
+  void skipThisUpdateClicked();
+  void remindMeLaterClicked();
+
+private:
+  void reportError(const char* msg, ...);
+  bool updateNeeded() const;
+
+#ifdef WIN32
+  bool processUpdate(const QByteArray& update_data);
+  bool extractUpdater(const QString& zip_path, const QString& destination_path);
+  bool doUpdate(const QString& zip_path, const QString& updater_path, const QString& destination_path);
+#else
+  bool processUpdate(const QByteArray& update_data);
+#endif
+
+  Ui::AutoUpdaterDialog m_ui;
+
+  QtHostInterface* m_host_interface;
+  QNetworkAccessManager* m_network_access_mgr = nullptr;
+  QString m_latest_sha;
+  QString m_download_url;
+
+  bool m_display_messages = false;
+};
diff --git a/src/duckstation-qt/autoupdaterdialog.ui b/src/duckstation-qt/autoupdaterdialog.ui
new file mode 100644
index 000000000..a5b456956
--- /dev/null
+++ b/src/duckstation-qt/autoupdaterdialog.ui
@@ -0,0 +1,110 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>AutoUpdaterDialog</class>
+ <widget class="QDialog" name="AutoUpdaterDialog">
+  <property name="windowModality">
+   <enum>Qt::ApplicationModal</enum>
+  </property>
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>651</width>
+    <height>474</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>Automatic Updater</string>
+  </property>
+  <property name="modal">
+   <bool>true</bool>
+  </property>
+  <layout class="QVBoxLayout" name="verticalLayout">
+   <item>
+    <widget class="QLabel" name="label">
+     <property name="font">
+      <font>
+       <pointsize>16</pointsize>
+       <weight>75</weight>
+       <bold>true</bold>
+      </font>
+     </property>
+     <property name="text">
+      <string>Update Available</string>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QLabel" name="currentVersion">
+     <property name="text">
+      <string>Current Version: </string>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QLabel" name="newVersion">
+     <property name="text">
+      <string>New Version: </string>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QLabel" name="label_4">
+     <property name="text">
+      <string>Update Notes:</string>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <widget class="QTextEdit" name="updateNotes">
+     <property name="readOnly">
+      <bool>true</bool>
+     </property>
+    </widget>
+   </item>
+   <item>
+    <layout class="QHBoxLayout" name="horizontalLayout">
+     <item>
+      <spacer name="horizontalSpacer">
+       <property name="orientation">
+        <enum>Qt::Horizontal</enum>
+       </property>
+       <property name="sizeHint" stdset="0">
+        <size>
+         <width>40</width>
+         <height>20</height>
+        </size>
+       </property>
+      </spacer>
+     </item>
+     <item>
+      <widget class="QPushButton" name="downloadAndInstall">
+       <property name="text">
+        <string>Download and Install...</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QPushButton" name="skipThisUpdate">
+       <property name="text">
+        <string>Skip This Update</string>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QPushButton" name="remindMeLater">
+       <property name="text">
+        <string>Remind Me Later</string>
+       </property>
+       <property name="default">
+        <bool>true</bool>
+       </property>
+      </widget>
+     </item>
+    </layout>
+   </item>
+  </layout>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/src/duckstation-qt/duckstation-qt.vcxproj b/src/duckstation-qt/duckstation-qt.vcxproj
index ccc5f6842..13df310ed 100644
--- a/src/duckstation-qt/duckstation-qt.vcxproj
+++ b/src/duckstation-qt/duckstation-qt.vcxproj
@@ -38,6 +38,7 @@
     <ClCompile Include="aboutdialog.cpp" />
     <ClCompile Include="advancedsettingswidget.cpp" />
     <ClCompile Include="audiosettingswidget.cpp" />
+    <ClCompile Include="autoupdaterdialog.cpp" />
     <ClCompile Include="consolesettingswidget.cpp" />
     <ClCompile Include="gamelistmodel.cpp" />
     <ClCompile Include="gamelistsearchdirectoriesmodel.cpp" />
@@ -74,6 +75,7 @@
     <QtMoc Include="inputbindingdialog.h" />
     <QtMoc Include="gamelistmodel.h" />
     <QtMoc Include="gamelistsearchdirectoriesmodel.h" />
+    <QtMoc Include="autoupdaterdialog.h" />
     <ClInclude Include="resource.h" />
     <ClInclude Include="settingwidgetbinder.h" />
     <QtMoc Include="consolesettingswidget.h" />
@@ -145,6 +147,7 @@
   <ItemGroup>
     <ClCompile Include="$(IntDir)moc_aboutdialog.cpp" />
     <ClCompile Include="$(IntDir)moc_audiosettingswidget.cpp" />
+    <ClCompile Include="$(IntDir)moc_autoupdaterdialog.cpp" />
     <ClCompile Include="$(IntDir)moc_advancedsettingswidget.cpp" />
     <ClCompile Include="$(IntDir)moc_consolesettingswidget.cpp" />
     <ClCompile Include="$(IntDir)moc_controllersettingswidget.cpp" />
@@ -195,6 +198,11 @@
     <None Include="translations\duckstation-qt_pt-br.ts" />
     <None Include="translations\duckstation-qt_pt-pt.ts" />
   </ItemGroup>
+  <ItemGroup>
+    <QtUi Include="autoupdaterdialog.ui">
+      <FileType>Document</FileType>
+    </QtUi>
+  </ItemGroup>
   <Target Name="CopyCommonDataFiles" AfterTargets="Build" Inputs="@(CommonDataFiles)" Outputs="@(CommonDataFiles -> '$(BinaryOutputDir)%(RecursiveDir)%(Filename)%(Extension)')">
     <Message Text="Copying common data files" Importance="High" />
     <Copy SourceFiles="@(CommonDataFiles)" DestinationFolder="$(BinaryOutputDir)\%(RecursiveDir)" SkipUnchangedFiles="true" />
diff --git a/src/duckstation-qt/duckstation-qt.vcxproj.filters b/src/duckstation-qt/duckstation-qt.vcxproj.filters
index a97a5dd23..ce67f9945 100644
--- a/src/duckstation-qt/duckstation-qt.vcxproj.filters
+++ b/src/duckstation-qt/duckstation-qt.vcxproj.filters
@@ -46,6 +46,8 @@
     <ClCompile Include="$(IntDir)moc_gamelistmodel.cpp" />
     <ClCompile Include="gamelistsearchdirectoriesmodel.cpp" />
     <ClCompile Include="$(IntDir)moc_gamelistsearchdirectoriesmodel.cpp" />
+    <ClCompile Include="autoupdaterdialog.cpp" />
+    <ClCompile Include="$(IntDir)moc_autoupdaterdialog.cpp" />
   </ItemGroup>
   <ItemGroup>
     <ClInclude Include="qtutils.h" />
@@ -85,6 +87,7 @@
     <QtMoc Include="inputbindingdialog.h" />
     <QtMoc Include="gamelistmodel.h" />
     <QtMoc Include="gamelistsearchdirectoriesmodel.h" />
+    <QtMoc Include="autoupdaterdialog.h" />
   </ItemGroup>
   <ItemGroup>
     <QtUi Include="consolesettingswidget.ui" />
@@ -98,6 +101,7 @@
     <QtUi Include="gamepropertiesdialog.ui" />
     <QtUi Include="aboutdialog.ui" />
     <QtUi Include="inputbindingdialog.ui" />
+    <QtUi Include="autoupdaterdialog.ui" />
   </ItemGroup>
   <ItemGroup>
     <Natvis Include="qt5.natvis" />
diff --git a/src/duckstation-qt/generalsettingswidget.cpp b/src/duckstation-qt/generalsettingswidget.cpp
index 634ab1f8e..2f3d4e67d 100644
--- a/src/duckstation-qt/generalsettingswidget.cpp
+++ b/src/duckstation-qt/generalsettingswidget.cpp
@@ -1,4 +1,5 @@
 #include "generalsettingswidget.h"
+#include "autoupdaterdialog.h"
 #include "settingsdialog.h"
 #include "settingwidgetbinder.h"
 
@@ -80,16 +81,27 @@ GeneralSettingsWidget::GeneralSettingsWidget(QtHostInterface* host_interface, QW
     tr("Shows the current emulation speed of the system in the top-right corner of the display as a percentage."));
 
   // Since this one is compile-time selected, we don't put it in the .ui file.
+  const int last_row_count = m_ui.formLayout_4->rowCount();
 #ifdef WITH_DISCORD_PRESENCE
   {
     QCheckBox* enableDiscordPresence = new QCheckBox(tr("Enable Discord Presence"), m_ui.groupBox_4);
     SettingWidgetBinder::BindWidgetToBoolSetting(m_host_interface, enableDiscordPresence, "Main",
                                                  "EnableDiscordPresence");
-    m_ui.formLayout_4->addWidget(enableDiscordPresence, m_ui.formLayout_4->rowCount(), 0);
+    m_ui.formLayout_4->addWidget(enableDiscordPresence, last_row_count, 0);
     dialog->registerWidgetHelp(enableDiscordPresence, tr("Enable Discord Presence"), tr("Unchecked"),
                                tr("Shows the game you are currently playing as part of your profile in Discord."));
   }
 #endif
+  if (AutoUpdaterDialog::isSupported())
+  {
+    QCheckBox* enableDiscordPresence = new QCheckBox(tr("Enable Automatic Update Check"), m_ui.groupBox_4);
+    SettingWidgetBinder::BindWidgetToBoolSetting(m_host_interface, enableDiscordPresence, "AutoUpdater",
+                                                 "CheckAtStartup");
+    m_ui.formLayout_4->addWidget(enableDiscordPresence, last_row_count, 1);
+    dialog->registerWidgetHelp(enableDiscordPresence, tr("Enable Automatic Update Check"), tr("Checked"),
+                               tr("Automatically checks for updates to the program on startup. Updates can be deferred "
+                                  "until later or skipped entirely."));
+  }
 }
 
 GeneralSettingsWidget::~GeneralSettingsWidget() = default;
diff --git a/src/duckstation-qt/main.cpp b/src/duckstation-qt/main.cpp
index 7f5ce7804..8eb740aaf 100644
--- a/src/duckstation-qt/main.cpp
+++ b/src/duckstation-qt/main.cpp
@@ -46,6 +46,10 @@ int main(int argc, char* argv[])
     host_interface->bootSystem(*boot_params);
     boot_params.reset();
   }
+  else
+  {
+    window->startupUpdateCheck();
+  }
 
   int result = app.exec();
 
diff --git a/src/duckstation-qt/mainwindow.cpp b/src/duckstation-qt/mainwindow.cpp
index 22b92b136..374662089 100644
--- a/src/duckstation-qt/mainwindow.cpp
+++ b/src/duckstation-qt/mainwindow.cpp
@@ -1,5 +1,6 @@
 #include "mainwindow.h"
 #include "aboutdialog.h"
+#include "autoupdaterdialog.h"
 #include "common/assert.h"
 #include "core/game_list.h"
 #include "core/host_display.h"
@@ -609,6 +610,7 @@ void MainWindow::connectSignals()
   connect(m_ui.actionIssueTracker, &QAction::triggered, this, &MainWindow::onIssueTrackerActionTriggered);
   connect(m_ui.actionDiscordServer, &QAction::triggered, this, &MainWindow::onDiscordServerActionTriggered);
   connect(m_ui.actionAbout, &QAction::triggered, this, &MainWindow::onAboutActionTriggered);
+  connect(m_ui.actionCheckForUpdates, &QAction::triggered, [this]() { checkForUpdates(true); });
 
   connect(m_host_interface, &QtHostInterface::errorReported, this, &MainWindow::reportError,
           Qt::BlockingQueuedConnection);
@@ -820,3 +822,38 @@ void MainWindow::changeEvent(QEvent* event)
 
   QMainWindow::changeEvent(event);
 }
+
+void MainWindow::startupUpdateCheck()
+{
+  if (!m_host_interface->GetBoolSettingValue("AutoUpdater", "CheckAtStartup", true))
+    return;
+
+  checkForUpdates(false);
+}
+
+void MainWindow::checkForUpdates(bool display_message)
+{
+  if (!AutoUpdaterDialog::isSupported())
+  {
+    if (display_message)
+      QMessageBox::critical(this, tr("Updater Error"), tr("Updates are not supported on this build."));
+
+    return;
+  }
+
+  if (m_auto_updater_dialog)
+    return;
+
+  m_auto_updater_dialog = new AutoUpdaterDialog(m_host_interface, this);
+  connect(m_auto_updater_dialog, &AutoUpdaterDialog::updateCheckCompleted, this, &MainWindow::onUpdateCheckComplete);
+  m_auto_updater_dialog->queueUpdateCheck(display_message);
+}
+
+void MainWindow::onUpdateCheckComplete()
+{
+  if (!m_auto_updater_dialog)
+    return;
+
+  m_auto_updater_dialog->deleteLater();
+  m_auto_updater_dialog = nullptr;
+}
diff --git a/src/duckstation-qt/mainwindow.h b/src/duckstation-qt/mainwindow.h
index 82978fbf1..1facc3fa8 100644
--- a/src/duckstation-qt/mainwindow.h
+++ b/src/duckstation-qt/mainwindow.h
@@ -13,6 +13,7 @@ class QThread;
 class GameListWidget;
 class QtHostInterface;
 class QtDisplayWidget;
+class AutoUpdaterDialog;
 
 class HostDisplay;
 struct GameListEntry;
@@ -25,6 +26,9 @@ public:
   explicit MainWindow(QtHostInterface* host_interface);
   ~MainWindow();
 
+  /// Performs update check if enabled in settings.
+  void startupUpdateCheck();
+
 private Q_SLOTS:
   void reportError(const QString& message);
   void reportMessage(const QString& message);
@@ -61,6 +65,9 @@ private Q_SLOTS:
   void onGameListEntryDoubleClicked(const GameListEntry* entry);
   void onGameListContextMenuRequested(const QPoint& point, const GameListEntry* entry);
 
+  void checkForUpdates(bool display_message);
+  void onUpdateCheckComplete();
+
 protected:
   void closeEvent(QCloseEvent* event) override;
   void changeEvent(QEvent* event) override;
@@ -94,6 +101,7 @@ private:
   QLabel* m_status_frame_time_widget = nullptr;
 
   SettingsDialog* m_settings_dialog = nullptr;
+  AutoUpdaterDialog* m_auto_updater_dialog = nullptr;
 
   bool m_emulation_running = false;
 };
diff --git a/src/duckstation-qt/mainwindow.ui b/src/duckstation-qt/mainwindow.ui
index 13dfb1458..a8d927a7d 100644
--- a/src/duckstation-qt/mainwindow.ui
+++ b/src/duckstation-qt/mainwindow.ui
@@ -122,6 +122,8 @@
     <addaction name="actionIssueTracker"/>
     <addaction name="actionDiscordServer"/>
     <addaction name="separator"/>
+    <addaction name="actionCheckForUpdates"/>
+    <addaction name="separator"/>
     <addaction name="actionAbout"/>
    </widget>
    <widget class="QMenu" name="menuDebug">
@@ -346,6 +348,11 @@
     <string>&amp;Discord Server...</string>
    </property>
   </action>
+  <action name="actionCheckForUpdates">
+   <property name="text">
+    <string>Check for &amp;Updates...</string>
+   </property>
+  </action>
   <action name="actionAbout">
    <property name="text">
     <string>&amp;About...</string>
diff --git a/src/duckstation-qt/qthostinterface.cpp b/src/duckstation-qt/qthostinterface.cpp
index 710aa01ee..dfecfcda4 100644
--- a/src/duckstation-qt/qthostinterface.cpp
+++ b/src/duckstation-qt/qthostinterface.cpp
@@ -2,6 +2,7 @@
 #include "common/assert.h"
 #include "common/audio_stream.h"
 #include "common/byte_stream.h"
+#include "common/file_system.h"
 #include "common/log.h"
 #include "common/string_util.h"
 #include "core/controller.h"
@@ -720,7 +721,7 @@ void QtHostInterface::saveInputProfile(const QString& profile_name)
 QString QtHostInterface::getUserDirectoryRelativePath(const QString& arg) const
 {
   QString result = QString::fromStdString(m_user_directory);
-  result += '/';
+  result += FS_OSPATH_SEPERATOR_CHARACTER;
   result += arg;
   return result;
 }
@@ -728,11 +729,16 @@ QString QtHostInterface::getUserDirectoryRelativePath(const QString& arg) const
 QString QtHostInterface::getProgramDirectoryRelativePath(const QString& arg) const
 {
   QString result = QString::fromStdString(m_program_directory);
-  result += '/';
+  result += FS_OSPATH_SEPERATOR_CHARACTER;
   result += arg;
   return result;
 }
 
+QString QtHostInterface::getProgramDirectory() const
+{
+  return QString::fromStdString(m_program_directory);
+}
+
 void QtHostInterface::powerOffSystem()
 {
   if (!isOnWorkerThread())
diff --git a/src/duckstation-qt/qthostinterface.h b/src/duckstation-qt/qthostinterface.h
index 96de18982..86f613605 100644
--- a/src/duckstation-qt/qthostinterface.h
+++ b/src/duckstation-qt/qthostinterface.h
@@ -71,6 +71,7 @@ public:
   ALWAYS_INLINE const HotkeyInfoList& getHotkeyInfoList() const { return GetHotkeyInfoList(); }
   ALWAYS_INLINE ControllerInterface* getControllerInterface() const { return GetControllerInterface(); }
   ALWAYS_INLINE bool inBatchMode() const { return InBatchMode(); }
+  ALWAYS_INLINE void requestExit() { RequestExit(); }
 
   ALWAYS_INLINE bool isOnWorkerThread() const { return QThread::currentThread() == m_worker_thread; }
 
@@ -99,6 +100,9 @@ public:
   /// Returns a list of supported languages and codes (suffixes for translation files).
   static std::vector<std::pair<QString, QString>> getAvailableLanguageList();
 
+  /// Returns program directory as a QString.
+  QString getProgramDirectory() const;
+
 Q_SIGNALS:
   void errorReported(const QString& message);
   void messageReported(const QString& message);