This commit is contained in:
Mohammad Badir 2026-04-02 00:02:58 +00:00 committed by GitHub
commit 4a7d6095d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 716 additions and 2 deletions

View File

@ -143,6 +143,8 @@ option(ENABLE_NATIVE_OPTIMIZATION "Enables processor-specific optimizations via
option(CITRA_USE_PRECOMPILED_HEADERS "Use precompiled headers" ON)
option(CITRA_WARNINGS_AS_ERRORS "Enable warnings as errors" ON)
set(AZAHAR_IPC_SOCKET_NAME "" CACHE STRING "Override IPC socket name (e.g. azahar-prof-ipc)")
# Handle incompatible options for libretro builds
if(ENABLE_LIBRETRO)
# Check for explicitly-set conflicting options
@ -351,7 +353,7 @@ if (ENABLE_QT)
download_qt(6.9.3)
endif()
find_package(Qt6 REQUIRED COMPONENTS Widgets Multimedia Concurrent)
find_package(Qt6 REQUIRED COMPONENTS Widgets Multimedia Concurrent Network)
if (UNIX AND NOT APPLE)
find_package(Qt6 REQUIRED COMPONENTS DBus)

View File

@ -197,6 +197,7 @@ endif()
if (ENABLE_QT)
add_subdirectory(citra_qt)
add_subdirectory(azahar_ctl)
endif()
if (ENABLE_QT) # Or any other hypothetical future frontends

View File

@ -0,0 +1,15 @@
add_executable(azahar-ctl
azahar_ctl.cpp
)
target_link_libraries(azahar-ctl PRIVATE Qt6::Core Qt6::Network)
if (AZAHAR_IPC_SOCKET_NAME)
target_compile_definitions(azahar-ctl PRIVATE AZAHAR_IPC_SOCKET_NAME="${AZAHAR_IPC_SOCKET_NAME}")
endif()
if (UNIX AND NOT APPLE)
install(TARGETS azahar-ctl RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}/bin")
elseif(WIN32)
install(TARGETS azahar-ctl RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}/bin")
endif()

View File

@ -0,0 +1,173 @@
// Copyright Azahar Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#include <QCoreApplication>
#include <QElapsedTimer>
#include <QLocalSocket>
#include <cstdio>
#include <cstdlib>
#ifdef AZAHAR_IPC_SOCKET_NAME
static constexpr const char* SOCKET_NAME = AZAHAR_IPC_SOCKET_NAME;
#else
static constexpr const char* SOCKET_NAME = "azahar-dev-ipc";
#endif
static constexpr int CONNECT_TIMEOUT_MS = 3000;
static constexpr int DEFAULT_TIMEOUT_MS = 5000;
static constexpr int RELOAD_TIMEOUT_MS = 60000;
// Exit codes
static constexpr int EXIT_OK = 0;
static constexpr int EXIT_USAGE = 1;
static constexpr int EXIT_CONNECT_FAILED = 2;
static constexpr int EXIT_TIMEOUT = 3;
static constexpr int EXIT_SERVER_ERROR = 4;
static void PrintUsage() {
std::fprintf(stderr, "Usage: azahar-ctl <command> [args...]\n\n");
std::fprintf(stderr, "Commands:\n");
std::fprintf(stderr, " ping Check if Azahar is running\n");
std::fprintf(stderr, " status Show emulation state\n");
std::fprintf(stderr, " shutdown Stop current game\n");
std::fprintf(stderr, " hot-reload <file> [--purge] [--wipe]\n");
std::fprintf(stderr,
" Reload cycle (CIA/3DSX/ELF/3DS/CXI/APP)\n");
std::fprintf(stderr,
" --purge Uninstall all titles before CIA "
"install\n");
std::fprintf(stderr,
" --wipe Delete save data for the replaced "
"title\n");
std::fprintf(stderr, " hot-reload --last Re-run the previous hot-reload\n");
std::fprintf(stderr, " help Show this help message\n");
std::fprintf(stderr, "\nOptions:\n");
std::fprintf(stderr,
" --timeout <ms> Override response timeout (default: "
"5000/60000)\n");
std::fprintf(stderr, "\nExit codes:\n");
std::fprintf(stderr, " 0 Success\n");
std::fprintf(stderr, " 1 Usage error\n");
std::fprintf(stderr, " 2 Cannot connect to Azahar\n");
std::fprintf(stderr, " 3 Timeout waiting for response\n");
std::fprintf(stderr, " 4 Server returned an error\n");
}
static bool ReadSingleLine(QLocalSocket& socket, int timeout_ms, QString& out) {
QByteArray buffer;
QElapsedTimer timer;
timer.start();
while (true) {
const int remaining = timeout_ms - static_cast<int>(timer.elapsed());
if (remaining <= 0) {
break;
}
if (!socket.waitForReadyRead(remaining)) {
break;
}
buffer.append(socket.readAll());
if (buffer.contains('\n')) {
out = QString::fromUtf8(buffer).trimmed();
return true;
}
}
if (buffer.contains('\n')) {
out = QString::fromUtf8(buffer).trimmed();
return true;
}
return false;
}
static int ExtractTimeout(int& argc, char* argv[], int default_timeout) {
int timeout = default_timeout;
for (int i = 1; i < argc - 1; i++) {
if (QString::fromLocal8Bit(argv[i]) == QStringLiteral("--timeout")) {
timeout = std::atoi(argv[i + 1]);
if (timeout <= 0) {
timeout = default_timeout;
}
for (int j = i; j < argc - 2; j++) {
argv[j] = argv[j + 2];
}
argc -= 2;
break;
}
}
return timeout;
}
int main(int argc, char* argv[]) {
QCoreApplication app(argc, argv);
if (argc < 2) {
PrintUsage();
return EXIT_USAGE;
}
const QString verb = QString::fromLocal8Bit(argv[1]);
if (verb == QStringLiteral("help") || verb == QStringLiteral("--help") ||
verb == QStringLiteral("-h")) {
PrintUsage();
return EXIT_OK;
}
int custom_timeout = ExtractTimeout(argc, argv, 0);
QString command;
if (verb == QStringLiteral("ping")) {
command = QStringLiteral("PING");
} else if (verb == QStringLiteral("status")) {
command = QStringLiteral("STATUS");
} else if (verb == QStringLiteral("shutdown")) {
command = QStringLiteral("SHUTDOWN");
} else if (verb == QStringLiteral("hot-reload")) {
if (argc == 3 && QString::fromLocal8Bit(argv[2]) == QStringLiteral("--last")) {
command = QStringLiteral("HOT_RELOAD_LAST");
} else {
if (argc < 3) {
std::fprintf(stderr, "Error: hot-reload requires a file path or --last\n");
return EXIT_USAGE;
}
QString path = QString::fromLocal8Bit(argv[2]);
command = QStringLiteral("HOT_RELOAD ") + path;
for (int i = 3; i < argc; i++) {
command += QStringLiteral(" ") + QString::fromLocal8Bit(argv[i]);
}
}
} else {
std::fprintf(stderr, "Unknown command: %s\n", argv[1]);
PrintUsage();
return EXIT_USAGE;
}
QLocalSocket socket;
socket.connectToServer(QString::fromLatin1(SOCKET_NAME));
if (!socket.waitForConnected(CONNECT_TIMEOUT_MS)) {
std::fprintf(stderr, "Error: Cannot connect to Azahar.\n");
std::fprintf(stderr, "Make sure Azahar is running and 'Enable developer IPC server'\n");
std::fprintf(stderr, "is checked in Emulation > Configuration > Debug.\n");
return EXIT_CONNECT_FAILED;
}
socket.write((command + QStringLiteral("\n")).toUtf8());
socket.flush();
int timeout = custom_timeout > 0 ? custom_timeout
: verb == QStringLiteral("hot-reload") ? RELOAD_TIMEOUT_MS
: DEFAULT_TIMEOUT_MS;
QString response;
if (!ReadSingleLine(socket, timeout, response)) {
std::fprintf(stderr, "Error: Timeout waiting for response\n");
return EXIT_TIMEOUT;
}
std::printf("%s\n", response.toLocal8Bit().constData());
socket.disconnectFromServer();
return response.startsWith(QStringLiteral("ERR")) ? EXIT_SERVER_ERROR : EXIT_OK;
}

View File

@ -29,6 +29,8 @@ add_library(citra_qt STATIC EXCLUDE_FROM_ALL
camera/qt_multimedia_camera.h
citra_qt.cpp
citra_qt.h
dev_ipc_server.cpp
dev_ipc_server.h
configuration/config.cpp
configuration/config.h
configuration/configure.ui
@ -272,7 +274,11 @@ endif()
create_target_directory_groups(citra_qt)
target_link_libraries(citra_qt PRIVATE audio_core citra_common citra_core input_common network video_core)
target_link_libraries(citra_qt PRIVATE Boost::boost nihstro-headers Qt6::Widgets Qt6::Multimedia Qt6::Concurrent)
target_link_libraries(citra_qt PRIVATE Boost::boost nihstro-headers Qt6::Widgets Qt6::Multimedia Qt6::Concurrent Qt6::Network)
if (AZAHAR_IPC_SOCKET_NAME)
target_compile_definitions(citra_qt PRIVATE AZAHAR_IPC_SOCKET_NAME="${AZAHAR_IPC_SOCKET_NAME}")
endif()
target_link_libraries(citra_qt PRIVATE ${PLATFORM_LIBRARIES} Threads::Threads)
if (ENABLE_OPENGL)

View File

@ -48,6 +48,7 @@
#include "citra_qt/configuration/config.h"
#include "citra_qt/configuration/configure_dialog.h"
#include "citra_qt/configuration/configure_per_game.h"
#include "citra_qt/dev_ipc_server.h"
#include "citra_qt/debugger/console.h"
#include "citra_qt/debugger/graphics/graphics.h"
#include "citra_qt/debugger/graphics/graphics_breakpoints.h"
@ -394,6 +395,14 @@ GMainWindow::GMainWindow(Core::System& system_)
ConnectMenuEvents();
ConnectWidgetEvents();
dev_ipc_server_ = new DevIpcServer(this, this);
connect(dev_ipc_server_, &DevIpcServer::HotReloadRequested, this,
&GMainWindow::OnHotReloadRequested);
connect(dev_ipc_server_, &DevIpcServer::ShutdownRequested, this, &GMainWindow::ShutdownGame);
if (UISettings::values.enable_dev_ipc_server.GetValue()) {
dev_ipc_server_->Start();
}
LOG_INFO(Frontend, "Azahar Version: {} | {}-{}", Common::g_build_fullname, Common::g_scm_branch,
Common::g_scm_desc);
#if CITRA_ARCH(x86_64)
@ -2303,6 +2312,12 @@ void GMainWindow::OnMenuSetUpSystemFiles() {
}
void GMainWindow::OnMenuInstallCIA() {
if (hot_reload_pending_) {
QMessageBox::warning(this, tr("Install CIA"),
tr("Cannot install CIA while a hot-reload is in progress."));
return;
}
QStringList filepaths = QFileDialog::getOpenFileNames(
this, tr("Load Files"), UISettings::values.roms_path,
tr("3DS Installation File (*.cia *.zcia)") + QStringLiteral(";;") + tr("All Files (*.*)"));
@ -2360,6 +2375,13 @@ void GMainWindow::OnUpdateProgress(std::size_t written, std::size_t total) {
}
void GMainWindow::OnCIAInstallReport(Service::AM::InstallStatus status, QString filepath) {
if (hot_reload_pending_) {
if (status != Service::AM::InstallStatus::Success) {
hot_reload_install_status_ = status;
}
return;
}
QString filename = QFileInfo(filepath).fileName();
switch (status) {
case Service::AM::InstallStatus::Success:
@ -2420,9 +2442,159 @@ void GMainWindow::OnCIAInstallFinished() {
progress_bar->setValue(0);
game_list->SetDirectoryWatcherEnabled(true);
ui->action_Install_CIA->setEnabled(true);
if (hot_reload_pending_) {
hot_reload_pending_ = false;
if (hot_reload_install_status_ != Service::AM::InstallStatus::Success) {
const QString err = QStringLiteral("CIA install failed (status=%1)")
.arg(static_cast<u32>(hot_reload_install_status_));
LOG_ERROR(Frontend, "Hot-reload: {}", err.toStdString());
dev_ipc_server_->OnHotReloadComplete(false, err);
hot_reload_cia_path_.clear();
hot_reload_install_status_ = Service::AM::InstallStatus::Success;
game_list->PopulateAsync(UISettings::values.game_dirs);
return;
}
auto result = Service::AM::GetCIAInfos(hot_reload_cia_path_.toStdString());
if (result.Succeeded()) {
const auto& info = result.Unwrap().first;
u64 title_id = info.tid;
auto media_type = Service::AM::GetTitleMediaType(title_id);
std::string app_path = Service::AM::GetTitleContentPath(media_type, title_id);
if (FileUtil::Exists(app_path)) {
LOG_INFO(Frontend, "Hot-reload: Booting {}", app_path);
BootGame(QString::fromStdString(app_path));
dev_ipc_server_->OnHotReloadComplete(true, QString{});
} else {
const QString err =
QStringLiteral("Installed .app not found at: ") +
QString::fromStdString(app_path);
LOG_ERROR(Frontend, "Hot-reload: {}", err.toStdString());
dev_ipc_server_->OnHotReloadComplete(false, err);
}
} else {
dev_ipc_server_->OnHotReloadComplete(false,
QStringLiteral("Failed to read CIA title info"));
}
hot_reload_cia_path_.clear();
hot_reload_install_status_ = Service::AM::InstallStatus::Success;
game_list->PopulateAsync(UISettings::values.game_dirs);
return;
}
game_list->PopulateAsync(UISettings::values.game_dirs);
}
void GMainWindow::OnHotReloadRequested(const QString& file_path, bool purge,
bool wipe_saves) {
LOG_INFO(Frontend, "Hot-reload requested: {} (purge={}, wipe={})", file_path.toStdString(),
purge, wipe_saves);
const QString ext = QFileInfo(file_path).suffix().toLower();
const bool is_cia = (ext == QStringLiteral("cia"));
const bool is_direct_boot = (ext == QStringLiteral("3dsx") || ext == QStringLiteral("elf") ||
ext == QStringLiteral("3ds") || ext == QStringLiteral("cxi") ||
ext == QStringLiteral("app"));
if (!is_cia && !is_direct_boot) {
LOG_ERROR(Frontend, "Hot-reload: unsupported file type: {}", ext.toStdString());
dev_ipc_server_->OnHotReloadComplete(
false, QStringLiteral("Unsupported file type: .") + ext);
return;
}
if (emulation_running) {
auto video_dumper = system.GetVideoDumper();
if (video_dumper && video_dumper->IsDumping()) {
OnStopVideoDumping();
}
ShutdownGame();
}
if (is_direct_boot) {
LOG_INFO(Frontend, "Hot-reload: Direct-booting {}", file_path.toStdString());
BootGame(file_path);
dev_ipc_server_->OnHotReloadComplete(true, QString{});
return;
}
if (purge) {
// Uninstall all SDMC game titles (00040000)
const std::string sdmc_games_path =
Service::AM::GetMediaTitlePath(Service::FS::MediaType::SDMC) + "00040000/";
if (FileUtil::Exists(sdmc_games_path)) {
FileUtil::FSTEntry parent;
FileUtil::ScanDirectoryTree(sdmc_games_path, parent, 0);
for (const auto& entry : parent.children) {
if (!entry.isDirectory) {
continue;
}
u32 tid_low = std::strtoul(entry.virtualName.c_str(), nullptr, 16);
u64 title_id = (static_cast<u64>(0x00040000) << 32) | tid_low;
Service::AM::UninstallProgram(Service::FS::MediaType::SDMC, title_id);
if (wipe_saves) {
std::string data_path =
Service::AM::GetTitlePath(Service::FS::MediaType::SDMC, title_id) +
"data/";
if (FileUtil::Exists(data_path)) {
FileUtil::DeleteDirRecursively(data_path);
}
}
}
}
// Uninstall update titles (0004000e)
const std::string sdmc_updates_path =
Service::AM::GetMediaTitlePath(Service::FS::MediaType::SDMC) + "0004000e/";
if (FileUtil::Exists(sdmc_updates_path)) {
FileUtil::FSTEntry parent;
FileUtil::ScanDirectoryTree(sdmc_updates_path, parent, 0);
for (const auto& entry : parent.children) {
if (!entry.isDirectory) {
continue;
}
u32 tid_low = std::strtoul(entry.virtualName.c_str(), nullptr, 16);
u64 title_id = (static_cast<u64>(0x0004000e) << 32) | tid_low;
Service::AM::UninstallProgram(Service::FS::MediaType::SDMC, title_id);
}
}
} else {
auto cia_info = Service::AM::GetCIAInfos(file_path.toStdString());
if (cia_info.Succeeded()) {
u64 title_id = cia_info.Unwrap().first.tid;
auto media_type = Service::AM::GetTitleMediaType(title_id);
Service::AM::UninstallProgram(media_type, title_id);
if (wipe_saves) {
std::string data_path =
Service::AM::GetTitlePath(media_type, title_id) + "data/";
if (FileUtil::Exists(data_path)) {
FileUtil::DeleteDirRecursively(data_path);
}
}
}
}
hot_reload_pending_ = true;
hot_reload_cia_path_ = file_path;
hot_reload_install_status_ = Service::AM::InstallStatus::Success;
InstallCIA(QStringList{file_path});
}
void GMainWindow::UninstallTitles(
const std::vector<std::tuple<Service::FS::MediaType, u64, QString>>& titles) {
if (titles.empty()) {
@ -2837,6 +3009,7 @@ void GMainWindow::OnConfigure() {
#ifdef __unix__
const bool old_gamemode = Settings::values.enable_gamemode.GetValue();
#endif
const bool old_ipc_server = UISettings::values.enable_dev_ipc_server.GetValue();
auto result = configureDialog.exec();
game_list->SetDirectoryWatcherEnabled(true);
if (result == QDialog::Accepted) {
@ -2855,6 +3028,13 @@ void GMainWindow::OnConfigure() {
SetGamemodeEnabled(Settings::values.enable_gamemode.GetValue());
}
#endif
if (UISettings::values.enable_dev_ipc_server.GetValue() != old_ipc_server) {
if (UISettings::values.enable_dev_ipc_server.GetValue()) {
dev_ipc_server_->Start();
} else {
dev_ipc_server_->Stop();
}
}
if (!multiplayer_state->IsHostingPublicRoom())
multiplayer_state->UpdateCredentials();
emit UpdateThemedIcons();

View File

@ -32,6 +32,7 @@
class AboutDialog;
class QtConfig;
class ClickableLabel;
class DevIpcServer;
class EmuThread;
class GameList;
enum class GameListOpenTarget;
@ -121,6 +122,13 @@ public:
void UninstallTitles(
const std::vector<std::tuple<Service::FS::MediaType, u64, QString>>& titles);
bool IsEmulationRunning() const {
return emulation_running;
}
u64 GetGameTitleId() const {
return game_title_id;
}
public slots:
void OnAppFocusStateChanged(Qt::ApplicationState state);
void OnLoadComplete();
@ -315,6 +323,7 @@ private slots:
#ifdef ENABLE_DEVELOPER_OPTIONS
void StartLaunchStressTest(const QString& game_path);
#endif
void OnHotReloadRequested(const QString& file_path, bool purge, bool wipe_saves);
private:
Q_INVOKABLE void OnMoviePlaybackCompleted();
@ -444,6 +453,11 @@ private:
std::shared_ptr<Camera::QtMultimediaCameraHandlerFactory> qt_cameras;
DevIpcServer* dev_ipc_server_ = nullptr;
bool hot_reload_pending_ = false;
QString hot_reload_cia_path_;
Service::AM::InstallStatus hot_reload_install_status_{};
#ifdef ENABLE_QT_UPDATE_CHECKER
// Prompt shown when update check succeeds
QFuture<QString> update_future;

View File

@ -828,6 +828,7 @@ void QtConfig::ReadUIValues() {
ReadBasicSetting(UISettings::values.enable_discord_presence);
#endif
ReadBasicSetting(UISettings::values.screenshot_resolution_factor);
ReadBasicSetting(UISettings::values.enable_dev_ipc_server);
ReadUILayoutValues();
ReadUIGameListValues();
@ -1356,6 +1357,7 @@ void QtConfig::SaveUIValues() {
WriteBasicSetting(UISettings::values.enable_discord_presence);
#endif
WriteBasicSetting(UISettings::values.screenshot_resolution_factor);
WriteBasicSetting(UISettings::values.enable_dev_ipc_server);
SaveUILayoutValues();
SaveUIGameListValues();

View File

@ -110,6 +110,8 @@ void ConfigureDebug::SetConfiguration() {
#ifndef ENABLE_SCRIPTING
ui->enable_rpc_server->setVisible(false);
#endif // !ENABLE_SCRIPTING
ui->enable_dev_ipc_server->setChecked(
UISettings::values.enable_dev_ipc_server.GetValue());
ui->toggle_unique_data_console_type->setChecked(
Settings::values.toggle_unique_data_console_type.GetValue());
@ -151,6 +153,7 @@ void ConfigureDebug::ApplyConfiguration() {
Settings::values.deterministic_async_operations =
ui->deterministic_async_operations->isChecked();
Settings::values.enable_rpc_server = ui->enable_rpc_server->isChecked();
UISettings::values.enable_dev_ipc_server = ui->enable_dev_ipc_server->isChecked();
Settings::values.toggle_unique_data_console_type =
ui->toggle_unique_data_console_type->isChecked();
Settings::values.renderer_debug = ui->toggle_renderer_debug->isChecked();

View File

@ -299,6 +299,16 @@
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QCheckBox" name="enable_dev_ipc_server">
<property name="text">
<string>Enable developer IPC server</string>
</property>
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Enables a local IPC server for hot-reload and remote control via azahar-ctl. Takes effect immediately.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>

View File

@ -0,0 +1,238 @@
// Copyright Azahar Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#include <QFile>
#include "citra_qt/dev_ipc_server.h"
#include "citra_qt/citra_qt.h"
#include "common/logging/log.h"
namespace {
#ifdef AZAHAR_IPC_SOCKET_NAME
constexpr const char* SOCKET_NAME = AZAHAR_IPC_SOCKET_NAME;
#else
constexpr const char* SOCKET_NAME = "azahar-dev-ipc";
#endif
} // namespace
DevIpcServer::DevIpcServer(GMainWindow* main_window, QObject* parent)
: QObject(parent), main_window_(main_window) {
server_ = new QLocalServer(this);
connect(server_, &QLocalServer::newConnection, this, &DevIpcServer::OnNewConnection);
}
DevIpcServer::~DevIpcServer() {
if (server_ && server_->isListening()) {
server_->close();
}
}
bool DevIpcServer::Start() {
if (server_->isListening()) {
return true;
}
QLocalServer::removeServer(QString::fromLatin1(SOCKET_NAME));
if (!server_->listen(QString::fromLatin1(SOCKET_NAME))) {
LOG_ERROR(Frontend, "DevIpcServer: Failed to listen: {}",
server_->errorString().toStdString());
return false;
}
#ifdef _WIN32
LOG_INFO(Frontend, "DevIpcServer: Listening on \\\\.\\pipe\\{}", SOCKET_NAME);
#else
LOG_INFO(Frontend, "DevIpcServer: Listening on {}", SOCKET_NAME);
#endif
return true;
}
void DevIpcServer::Stop() {
if (server_->isListening()) {
server_->close();
LOG_INFO(Frontend, "DevIpcServer: Stopped");
}
pending_reload_socket_ = nullptr;
reload_in_progress_ = false;
read_buffers_.clear();
}
void DevIpcServer::OnNewConnection() {
while (QLocalSocket* socket = server_->nextPendingConnection()) {
if (read_buffers_.size() >= MAX_CONNECTIONS) {
LOG_WARNING(Frontend, "DevIpcServer: Rejecting connection (limit {})",
MAX_CONNECTIONS);
socket->disconnectFromServer();
socket->deleteLater();
continue;
}
read_buffers_[socket] = {};
connect(socket, &QLocalSocket::readyRead, this, &DevIpcServer::OnReadyRead);
connect(socket, &QLocalSocket::disconnected, this, &DevIpcServer::OnDisconnected);
}
}
void DevIpcServer::OnReadyRead() {
auto* socket = qobject_cast<QLocalSocket*>(sender());
if (!socket) {
return;
}
QByteArray& buffer = read_buffers_[socket];
buffer.append(socket->readAll());
if (buffer.size() > MAX_BUFFER_SIZE) {
LOG_WARNING(Frontend, "DevIpcServer: Client exceeded buffer limit, disconnecting");
read_buffers_.remove(socket);
socket->disconnectFromServer();
socket->deleteLater();
return;
}
while (buffer.contains('\n')) {
const int newline_pos = buffer.indexOf('\n');
const QByteArray line_bytes = buffer.left(newline_pos).trimmed();
buffer.remove(0, newline_pos + 1);
if (!line_bytes.isEmpty()) {
HandleCommand(socket, QString::fromUtf8(line_bytes));
}
}
}
void DevIpcServer::OnDisconnected() {
auto* socket = qobject_cast<QLocalSocket*>(sender());
if (!socket) {
return;
}
if (socket == pending_reload_socket_) {
pending_reload_socket_ = nullptr;
LOG_WARNING(Frontend, "DevIpcServer: Hot-reload client disconnected before completion");
}
read_buffers_.remove(socket);
socket->deleteLater();
}
QString DevIpcServer::ParseFlags(const QString& args, bool& purge, bool& wipe) {
const QStringList tokens = args.split(QLatin1Char(' '), Qt::SkipEmptyParts);
QStringList path_parts;
purge = false;
wipe = false;
for (const auto& token : tokens) {
if (token == QStringLiteral("--purge")) {
purge = true;
} else if (token == QStringLiteral("--wipe")) {
wipe = true;
} else {
path_parts.append(token);
}
}
return path_parts.join(QLatin1Char(' '));
}
void DevIpcServer::HandleCommand(QLocalSocket* socket, const QString& command) {
LOG_DEBUG(Frontend, "DevIpcServer: Received command: {}", command.toStdString());
if (command == QStringLiteral("PING")) {
SendResponse(socket, QStringLiteral("OK"));
} else if (command == QStringLiteral("STATUS")) {
SendResponse(socket, GetStatus());
} else if (command == QStringLiteral("SHUTDOWN")) {
if (main_window_->IsEmulationRunning()) {
emit ShutdownRequested();
SendResponse(socket, QStringLiteral("OK"));
} else {
SendResponse(socket, QStringLiteral("ERR:not-running"));
}
} else if (command.startsWith(QStringLiteral("HOT_RELOAD "))) {
if (reload_in_progress_) {
SendResponse(socket, QStringLiteral("ERR:reload-in-progress"));
return;
}
bool purge, wipe;
const QString file_path = ParseFlags(command.mid(11), purge, wipe);
if (file_path.isEmpty()) {
SendResponse(socket, QStringLiteral("ERR:missing-file-path"));
return;
}
if (!QFile::exists(file_path)) {
SendResponse(socket, QStringLiteral("ERR:file-not-found"));
return;
}
pending_reload_path_ = file_path;
pending_reload_purge_ = purge;
pending_reload_wipe_ = wipe;
reload_in_progress_ = true;
pending_reload_socket_ = socket;
emit HotReloadRequested(file_path, purge, wipe);
} else if (command == QStringLiteral("HOT_RELOAD_LAST")) {
if (reload_in_progress_) {
SendResponse(socket, QStringLiteral("ERR:reload-in-progress"));
return;
}
if (last_reload_path_.isEmpty()) {
SendResponse(socket, QStringLiteral("ERR:no-previous-reload"));
return;
}
if (!QFile::exists(last_reload_path_)) {
SendResponse(socket, QStringLiteral("ERR:file-not-found"));
return;
}
pending_reload_path_ = last_reload_path_;
pending_reload_purge_ = last_reload_purge_;
pending_reload_wipe_ = last_reload_wipe_;
reload_in_progress_ = true;
pending_reload_socket_ = socket;
emit HotReloadRequested(last_reload_path_, last_reload_purge_, last_reload_wipe_);
} else {
SendResponse(socket, QStringLiteral("ERR:unknown-command"));
}
}
void DevIpcServer::OnHotReloadComplete(bool success, const QString& error) {
reload_in_progress_ = false;
if (success) {
last_reload_path_ = pending_reload_path_;
last_reload_purge_ = pending_reload_purge_;
last_reload_wipe_ = pending_reload_wipe_;
}
if (pending_reload_socket_ &&
pending_reload_socket_->state() == QLocalSocket::ConnectedState) {
if (success) {
SendResponse(pending_reload_socket_, QStringLiteral("OK"));
} else {
SendResponse(pending_reload_socket_, QStringLiteral("ERR:") + error);
}
}
pending_reload_socket_ = nullptr;
}
void DevIpcServer::SendResponse(QLocalSocket* socket, const QString& response) {
if (!socket || socket->state() != QLocalSocket::ConnectedState) {
return;
}
socket->write((response + QStringLiteral("\n")).toUtf8());
socket->flush();
}
QString DevIpcServer::GetStatus() const {
if (main_window_->IsEmulationRunning()) {
return QStringLiteral("RUNNING %1")
.arg(main_window_->GetGameTitleId(), 16, 16, QLatin1Char('0'));
}
return QStringLiteral("IDLE");
}

View File

@ -0,0 +1,68 @@
// Copyright Azahar Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
#pragma once
#include <QHash>
#include <QLocalServer>
#include <QLocalSocket>
#include <QObject>
#include <QPointer>
#include <QString>
class GMainWindow;
/// Developer IPC server for hot-reload and remote control via azahar-ctl.
class DevIpcServer : public QObject {
Q_OBJECT
public:
explicit DevIpcServer(GMainWindow* main_window, QObject* parent = nullptr);
~DevIpcServer() override;
bool Start();
void Stop();
bool IsReloadInProgress() const {
return reload_in_progress_;
}
signals:
void HotReloadRequested(const QString& file_path, bool purge, bool wipe_saves);
void ShutdownRequested();
public slots:
void OnHotReloadComplete(bool success, const QString& error);
private slots:
void OnNewConnection();
void OnReadyRead();
void OnDisconnected();
private:
void HandleCommand(QLocalSocket* socket, const QString& command);
void SendResponse(QLocalSocket* socket, const QString& response);
QString GetStatus() const;
static QString ParseFlags(const QString& args, bool& purge, bool& wipe);
static constexpr int MAX_BUFFER_SIZE = 65536;
static constexpr int MAX_CONNECTIONS = 16;
QLocalServer* server_ = nullptr;
GMainWindow* main_window_ = nullptr;
QHash<QLocalSocket*, QByteArray> read_buffers_;
QPointer<QLocalSocket> pending_reload_socket_;
bool reload_in_progress_ = false;
// In-flight reload params, committed to last_reload_* on success.
QString pending_reload_path_;
bool pending_reload_purge_ = false;
bool pending_reload_wipe_ = false;
// Last successful reload params for HOT_RELOAD_LAST.
QString last_reload_path_;
bool last_reload_purge_ = false;
bool last_reload_wipe_ = false;
};

View File

@ -102,6 +102,8 @@ struct Values {
Settings::Setting<bool> enable_discord_presence{true, "enable_discord_presence"};
#endif
Settings::Setting<bool> enable_dev_ipc_server{false, "enable_dev_ipc_server"};
// Game List
Settings::Setting<GameListIconSize> game_list_icon_size{GameListIconSize::LargeIcon,
"iconSize"};