diff --git a/src/duckstation-qt/CMakeLists.txt b/src/duckstation-qt/CMakeLists.txt
index a1e1edfac..9e2fd8645 100644
--- a/src/duckstation-qt/CMakeLists.txt
+++ b/src/duckstation-qt/CMakeLists.txt
@@ -106,6 +106,9 @@ set(SRCS
interfacesettingswidget.cpp
interfacesettingswidget.h
interfacesettingswidget.ui
+ isobrowserwindow.cpp
+ isobrowserwindow.h
+ isobrowserwindow.ui
logwindow.cpp
logwindow.h
mainwindow.cpp
diff --git a/src/duckstation-qt/duckstation-qt.vcxproj b/src/duckstation-qt/duckstation-qt.vcxproj
index d29bacc47..f11917df0 100644
--- a/src/duckstation-qt/duckstation-qt.vcxproj
+++ b/src/duckstation-qt/duckstation-qt.vcxproj
@@ -27,6 +27,7 @@
+
@@ -85,6 +86,7 @@
+
@@ -242,6 +244,7 @@
+
@@ -354,6 +357,9 @@
Document
+
+ Document
+
diff --git a/src/duckstation-qt/duckstation-qt.vcxproj.filters b/src/duckstation-qt/duckstation-qt.vcxproj.filters
index 220b25d9c..2e0b9024a 100644
--- a/src/duckstation-qt/duckstation-qt.vcxproj.filters
+++ b/src/duckstation-qt/duckstation-qt.vcxproj.filters
@@ -175,6 +175,10 @@
moc
+
+
+ moc
+
@@ -237,6 +241,7 @@
+
@@ -289,6 +294,7 @@
+
diff --git a/src/duckstation-qt/isobrowserwindow.cpp b/src/duckstation-qt/isobrowserwindow.cpp
new file mode 100644
index 000000000..cb15c820c
--- /dev/null
+++ b/src/duckstation-qt/isobrowserwindow.cpp
@@ -0,0 +1,340 @@
+// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin
+// SPDX-License-Identifier: CC-BY-NC-ND-4.0
+
+#include "isobrowserwindow.h"
+#include "qtprogresscallback.h"
+#include "qtutils.h"
+
+#include "util/cd_image.h"
+
+#include "common/align.h"
+#include "common/error.h"
+#include "common/file_system.h"
+#include "common/log.h"
+#include "common/path.h"
+
+#include
+#include
+#include
+#include
+#include
+
+LOG_CHANNEL(Host);
+
+ISOBrowserWindow::ISOBrowserWindow(QWidget* parent) : QWidget(parent)
+{
+ m_ui.setupUi(this);
+ m_ui.splitter->setSizes({200, 600});
+ setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+
+ connect(m_ui.openFile, &QAbstractButton::clicked, this, &ISOBrowserWindow::onOpenFileClicked);
+ connect(m_ui.directoryView, &QTreeWidget::itemClicked, this, &ISOBrowserWindow::onDirectoryItemClicked);
+ connect(m_ui.fileView, &QTreeWidget::itemActivated, this, &ISOBrowserWindow::onFileItemActivated);
+ connect(m_ui.fileView, &QTreeWidget::itemSelectionChanged, this, &ISOBrowserWindow::onFileItemSelectionChanged);
+ connect(m_ui.fileView, &QTreeWidget::customContextMenuRequested, this, &ISOBrowserWindow::onFileContextMenuRequested);
+ connect(m_ui.close, &QAbstractButton::clicked, this, &ISOBrowserWindow::close);
+}
+
+ISOBrowserWindow::~ISOBrowserWindow() = default;
+
+ISOBrowserWindow* ISOBrowserWindow::createAndOpenFile(QWidget* parent, const QString& path)
+{
+ ISOBrowserWindow* ib = new ISOBrowserWindow(nullptr);
+
+ Error error;
+ if (!ib->tryOpenFile(path, &error))
+ {
+ QMessageBox::critical(parent, tr("Error"),
+ tr("Failed to open %1:\n%2").arg(path).arg(QString::fromStdString(error.GetDescription())));
+ delete ib;
+ return nullptr;
+ }
+
+ return ib;
+}
+
+bool ISOBrowserWindow::tryOpenFile(const QString& path, Error* error /*= nullptr*/)
+{
+ const std::string native_path = QDir::toNativeSeparators(path).toStdString();
+ std::unique_ptr image = CDImage::Open(native_path.c_str(), false, error);
+ if (!image)
+ return false;
+
+ IsoReader new_reader;
+ if (!new_reader.Open(image.get(), 1, error))
+ return false;
+
+ m_image = std::move(image);
+ m_iso = std::move(new_reader);
+ m_ui.openPath->setText(QString::fromStdString(native_path));
+ setWindowTitle(tr("ISO Browser - %1").arg(QtUtils::StringViewToQString(Path::GetFileName(native_path))));
+ populateDirectories();
+ populateFiles(QString());
+ return true;
+}
+
+void ISOBrowserWindow::resizeEvent(QResizeEvent* ev)
+{
+ QWidget::resizeEvent(ev);
+ resizeFileListColumns();
+}
+
+void ISOBrowserWindow::showEvent(QShowEvent* ev)
+{
+ QWidget::showEvent(ev);
+ resizeFileListColumns();
+}
+
+void ISOBrowserWindow::onOpenFileClicked()
+{
+ const QString path = QFileDialog::getOpenFileName(
+ this, tr("Select File"),
+ m_image ? QtUtils::StringViewToQString(Path::GetDirectory(m_image->GetPath())) : QString());
+ if (path.isEmpty())
+ return;
+
+ Error error;
+ if (!tryOpenFile(path, &error))
+ {
+ QMessageBox::critical(this, tr("Error"),
+ tr("Failed to open %1:\n%2").arg(path).arg(QString::fromStdString(error.GetDescription())));
+ return;
+ }
+}
+
+void ISOBrowserWindow::onDirectoryItemClicked(QTreeWidgetItem* item, int column)
+{
+ populateFiles(item->data(0, Qt::UserRole).toString());
+}
+
+void ISOBrowserWindow::onFileItemActivated(QTreeWidgetItem* item, int column)
+{
+ if (item->data(0, Qt::UserRole + 1).toBool())
+ {
+ // directory
+ const QString dir = item->data(0, Qt::UserRole).toString();
+ populateFiles(dir);
+
+ // select it in the directory list
+ QTreeWidgetItem* dir_item = findDirectoryItemForPath(dir, nullptr);
+ if (dir_item)
+ {
+ QSignalBlocker sb(m_ui.directoryView);
+ m_ui.directoryView->clearSelection();
+ dir_item->setSelected(true);
+ }
+
+ return;
+ }
+
+ // file, go to extract
+ extractFile(item->data(0, Qt::UserRole).toString());
+}
+
+void ISOBrowserWindow::onFileItemSelectionChanged()
+{
+ const QList items = m_ui.fileView->selectedItems();
+ if (items.isEmpty())
+ {
+ m_ui.extract->setEnabled(false);
+ return;
+ }
+
+ // directory?
+ const bool is_directory = items.front()->data(0, Qt::UserRole + 1).toBool();
+ m_ui.extract->setEnabled(!is_directory);
+}
+
+void ISOBrowserWindow::onFileContextMenuRequested(const QPoint& pos)
+{
+ const QList items = m_ui.fileView->selectedItems();
+ if (items.isEmpty())
+ return;
+
+ QMenu menu;
+
+ const bool is_directory = items.front()->data(0, Qt::UserRole + 1).toBool();
+ const QString path = items.front()->data(0, Qt::UserRole).toString();
+ if (is_directory)
+ {
+ connect(menu.addAction(QIcon::fromTheme(QIcon::ThemeIcon::FolderOpen), tr("&Open")), &QAction::triggered, this,
+ [this, &path]() { populateFiles(path); });
+ }
+ else
+ {
+ connect(menu.addAction(QIcon::fromTheme(QIcon::ThemeIcon::DocumentSaveAs), tr("&Extract")), &QAction::triggered,
+ this, [this, &path]() { extractFile(path); });
+ }
+
+ menu.exec(m_ui.fileView->mapToGlobal(pos));
+}
+
+void ISOBrowserWindow::resizeFileListColumns()
+{
+ QtUtils::ResizeColumnsForTreeView(m_ui.fileView, {-1, 200, 100});
+}
+
+void ISOBrowserWindow::extractFile(const QString& path)
+{
+ const std::string spath = path.toStdString();
+ const QString filename = QtUtils::StringViewToQString(Path::GetFileName(spath));
+ std::string save_path =
+ QDir::toNativeSeparators(QFileDialog::getSaveFileName(this, tr("Extract File"), filename)).toStdString();
+ if (save_path.empty())
+ return;
+
+ Error error;
+ std::optional de = m_iso.LocateFile(path.toStdString(), &error);
+ if (de.has_value())
+ {
+ auto fp = FileSystem::CreateAtomicRenamedFile(std::move(save_path), &error);
+ if (fp)
+ {
+ QtModalProgressCallback cb(this, 0.15f);
+ cb.SetCancellable(true);
+ cb.SetTitle("ISO Browser");
+ cb.SetStatusText(tr("Extracting %1...").arg(filename).toStdString());
+ if (m_iso.WriteFileToStream(de.value(), fp.get(), &error, &cb))
+ {
+ if (FileSystem::CommitAtomicRenamedFile(fp, &error))
+ return;
+ }
+ else
+ {
+ // don't display error if cancelled
+ FileSystem::DiscardAtomicRenamedFile(fp);
+ if (cb.IsCancellable())
+ return;
+ }
+ }
+ }
+
+ QMessageBox::critical(this, tr("Error"),
+ tr("Failed to save %1:\n%2").arg(path).arg(QString::fromStdString(error.GetDescription())));
+}
+
+QTreeWidgetItem* ISOBrowserWindow::findDirectoryItemForPath(const QString& path, QTreeWidgetItem* parent) const
+{
+ if (!parent)
+ {
+ parent = m_ui.directoryView->topLevelItem(0);
+ if (path.isEmpty())
+ return parent;
+ }
+
+ const int count = parent->childCount();
+ for (int i = 0; i < count; i++)
+ {
+ QTreeWidgetItem* item = parent->child(i);
+ if (item->data(0, Qt::UserRole) == path)
+ return item;
+
+ QTreeWidgetItem* child_item = findDirectoryItemForPath(path, item);
+ if (child_item)
+ return child_item;
+ }
+
+ return nullptr;
+}
+
+void ISOBrowserWindow::populateDirectories()
+{
+ m_ui.directoryView->clear();
+ m_ui.extract->setEnabled(false);
+
+ QTreeWidgetItem* root = new QTreeWidgetItem;
+ root->setIcon(0, QIcon::fromTheme("disc-line"));
+ root->setText(0, QtUtils::StringViewToQString(Path::GetFileTitle(m_image->GetPath())));
+ root->setData(0, Qt::UserRole, QString());
+ m_ui.directoryView->addTopLevelItem(root);
+
+ populateSubdirectories(std::string_view(), root);
+
+ root->setExpanded(true);
+
+ QSignalBlocker sb(m_ui.directoryView);
+ root->setSelected(true);
+}
+
+void ISOBrowserWindow::populateSubdirectories(std::string_view dir, QTreeWidgetItem* parent)
+{
+ Error error;
+ std::vector> entries = m_iso.GetEntriesInDirectory(dir, &error);
+ if (entries.empty() && error.IsValid())
+ {
+ ERROR_LOG("Failed to populate directory '{}': {}", dir, error.GetDescription());
+ return;
+ }
+
+ for (const auto& [full_path, entry] : entries)
+ {
+ if (!entry.IsDirectory())
+ continue;
+
+ QTreeWidgetItem* item = new QTreeWidgetItem(parent);
+ item->setIcon(0, QIcon::fromTheme(QStringLiteral("folder-open-line")));
+ item->setText(0, QtUtils::StringViewToQString(Path::GetFileName(full_path)));
+ item->setData(0, Qt::UserRole, QString::fromStdString(full_path));
+ populateSubdirectories(full_path, item);
+ }
+}
+
+void ISOBrowserWindow::populateFiles(const QString& path)
+{
+ const std::string spath = path.toStdString();
+
+ m_ui.fileView->clear();
+
+ Error error;
+ std::vector> entries =
+ m_iso.GetEntriesInDirectory(spath, &error);
+ if (entries.empty() && error.IsValid())
+ {
+ ERROR_LOG("Failed to populate files '{}': {}", spath, error.GetDescription());
+ return;
+ }
+
+ const auto add_entry = [this](const std::string& full_path, const IsoReader::ISODirectoryEntry& entry) {
+ const std::string_view filename = Path::GetFileName(full_path);
+
+ QTreeWidgetItem* item = new QTreeWidgetItem;
+ item->setIcon(
+ 0, QIcon::fromTheme(entry.IsDirectory() ? QStringLiteral("folder-open-line") : QStringLiteral("file-line")));
+ item->setText(0, QtUtils::StringViewToQString(Path::GetFileName(full_path)));
+ item->setData(0, Qt::UserRole, QString::fromStdString(full_path));
+ item->setData(0, Qt::UserRole + 1, entry.IsDirectory());
+ item->setText(1, QString::fromStdString(entry.recoding_time.GetFormattedTime()));
+ item->setText(2, tr("%1 KB").arg(Common::AlignUpPow2(entry.length_le, 1024) / 1024));
+ m_ui.fileView->addTopLevelItem(item);
+ };
+
+ if (!path.isEmpty())
+ {
+ QTreeWidgetItem* item = new QTreeWidgetItem;
+ item->setIcon(0, QIcon::fromTheme(QStringLiteral("folder-open-line")));
+ item->setText(0, tr(""));
+ item->setData(0, Qt::UserRole, QtUtils::StringViewToQString(Path::GetDirectory(spath)));
+ item->setData(0, Qt::UserRole + 1, true);
+ m_ui.fileView->addTopLevelItem(item);
+ }
+
+ // list directories first
+ for (const auto& [full_path, entry] : entries)
+ {
+ if (!entry.IsDirectory())
+ continue;
+
+ add_entry(full_path, entry);
+ }
+
+ for (const auto& [full_path, entry] : entries)
+ {
+ if (entry.IsDirectory())
+ continue;
+
+ add_entry(full_path, entry);
+ }
+
+ // this is utter shit, the scrollbar visibility doesn't update in time, so we have to queue it.
+ QTimer::singleShot(20, Qt::TimerType::CoarseTimer, this, SLOT(resizeFileListColumns()));
+}
diff --git a/src/duckstation-qt/isobrowserwindow.h b/src/duckstation-qt/isobrowserwindow.h
new file mode 100644
index 000000000..6e67c8c03
--- /dev/null
+++ b/src/duckstation-qt/isobrowserwindow.h
@@ -0,0 +1,45 @@
+// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin
+// SPDX-License-Identifier: CC-BY-NC-ND-4.0
+
+#pragma once
+
+#include "ui_isobrowserwindow.h"
+
+#include "util/iso_reader.h"
+
+class ISOBrowserWindow : public QWidget
+{
+ Q_OBJECT
+
+public:
+ ISOBrowserWindow(QWidget* parent = nullptr);
+ ~ISOBrowserWindow();
+
+ static ISOBrowserWindow* createAndOpenFile(QWidget* parent, const QString& path);
+
+ bool tryOpenFile(const QString& path, Error* error = nullptr);
+
+protected:
+ void resizeEvent(QResizeEvent* ev);
+ void showEvent(QShowEvent* ev);
+
+private Q_SLOTS:
+ void onOpenFileClicked();
+ void onDirectoryItemClicked(QTreeWidgetItem* item, int column);
+ void onFileItemActivated(QTreeWidgetItem* item, int column);
+ void onFileItemSelectionChanged();
+ void onFileContextMenuRequested(const QPoint& pos);
+ void resizeFileListColumns();
+
+private:
+ void populateDirectories();
+ void populateSubdirectories(std::string_view dir, QTreeWidgetItem* parent);
+ void populateFiles(const QString& path);
+ void extractFile(const QString& path);
+
+ QTreeWidgetItem* findDirectoryItemForPath(const QString& path, QTreeWidgetItem* parent = nullptr) const;
+
+ Ui::ISOBrowserWindow m_ui;
+ std::unique_ptr m_image;
+ IsoReader m_iso;
+};
diff --git a/src/duckstation-qt/isobrowserwindow.ui b/src/duckstation-qt/isobrowserwindow.ui
new file mode 100644
index 000000000..5dd42e5fc
--- /dev/null
+++ b/src/duckstation-qt/isobrowserwindow.ui
@@ -0,0 +1,132 @@
+
+
+ ISOBrowserWindow
+
+
+
+ 0
+ 0
+ 828
+ 511
+
+
+
+ Form
+
+
+
+ :/icons/media-optical.png:/icons/media-optical.png
+
+
+ -
+
+
-
+
+
+ File:
+
+
+
+ -
+
+
+ true
+
+
+
+ -
+
+
+ ...
+
+
+
+
+
+
+
+
+ -
+
+
+ Qt::Orientation::Horizontal
+
+
+
+ QAbstractItemView::EditTrigger::NoEditTriggers
+
+
+ true
+
+
+
+ Name
+
+
+
+
+
+ Qt::ContextMenuPolicy::CustomContextMenu
+
+
+ QAbstractItemView::EditTrigger::NoEditTriggers
+
+
+ false
+
+
+
+ Name
+
+
+
+
+ Date
+
+
+
+
+ Size
+
+
+
+
+
+ -
+
+
-
+
+
+ Qt::Orientation::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Extract
+
+
+
+ -
+
+
+ Close
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/duckstation-qt/mainwindow.cpp b/src/duckstation-qt/mainwindow.cpp
index 44e349107..02aab3b49 100644
--- a/src/duckstation-qt/mainwindow.cpp
+++ b/src/duckstation-qt/mainwindow.cpp
@@ -12,6 +12,7 @@
#include "gamelistsettingswidget.h"
#include "gamelistwidget.h"
#include "interfacesettingswidget.h"
+#include "isobrowserwindow.h"
#include "logwindow.h"
#include "memorycardeditorwindow.h"
#include "memoryscannerwindow.h"
@@ -1422,6 +1423,18 @@ void MainWindow::onGameListEntryContextMenuRequested(const QPoint& point)
QtUtils::OpenURL(this, QUrl::fromLocalFile(fi.absolutePath()));
});
+ if (entry->IsDisc())
+ {
+ connect(menu.addAction(tr("Browse ISO...")), &QAction::triggered, [this, entry]() {
+ ISOBrowserWindow* ib = ISOBrowserWindow::createAndOpenFile(this, QString::fromStdString(entry->path));
+ if (ib)
+ {
+ ib->setAttribute(Qt::WA_DeleteOnClose);
+ ib->show();
+ }
+ });
+ }
+
connect(menu.addAction(tr("Set Cover Image...")), &QAction::triggered,
[this, entry]() { setGameListEntryCoverImage(entry); });