From 7a6532171f237af8c5611f9dff34984bbc363782 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Thu, 2 Apr 2026 02:49:20 +0300 Subject: [PATCH] Add developer IPC server for hot-reload support --- CMakeLists.txt | 4 +- src/CMakeLists.txt | 1 + src/azahar_ctl/CMakeLists.txt | 15 ++ src/azahar_ctl/azahar_ctl.cpp | 173 +++++++++++++ src/citra_qt/CMakeLists.txt | 8 +- src/citra_qt/citra_qt.cpp | 180 +++++++++++++ src/citra_qt/citra_qt.h | 14 ++ src/citra_qt/configuration/config.cpp | 2 + .../configuration/configure_debug.cpp | 3 + src/citra_qt/configuration/configure_debug.ui | 10 + src/citra_qt/dev_ipc_server.cpp | 238 ++++++++++++++++++ src/citra_qt/dev_ipc_server.h | 68 +++++ src/citra_qt/uisettings.h | 2 + 13 files changed, 716 insertions(+), 2 deletions(-) create mode 100644 src/azahar_ctl/CMakeLists.txt create mode 100644 src/azahar_ctl/azahar_ctl.cpp create mode 100644 src/citra_qt/dev_ipc_server.cpp create mode 100644 src/citra_qt/dev_ipc_server.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 54eb90e90..3bad53c48 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f0da8bbe3..89ed295de 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 diff --git a/src/azahar_ctl/CMakeLists.txt b/src/azahar_ctl/CMakeLists.txt new file mode 100644 index 000000000..b93140ce5 --- /dev/null +++ b/src/azahar_ctl/CMakeLists.txt @@ -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() diff --git a/src/azahar_ctl/azahar_ctl.cpp b/src/azahar_ctl/azahar_ctl.cpp new file mode 100644 index 000000000..5c78d8254 --- /dev/null +++ b/src/azahar_ctl/azahar_ctl.cpp @@ -0,0 +1,173 @@ +// Copyright Azahar Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include +#include + +#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 [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 [--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 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(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; +} diff --git a/src/citra_qt/CMakeLists.txt b/src/citra_qt/CMakeLists.txt index 20971828a..89f9a8559 100644 --- a/src/citra_qt/CMakeLists.txt +++ b/src/citra_qt/CMakeLists.txt @@ -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) diff --git a/src/citra_qt/citra_qt.cpp b/src/citra_qt/citra_qt.cpp index 9bb0b2e34..d34bc3125 100644 --- a/src/citra_qt/citra_qt.cpp +++ b/src/citra_qt/citra_qt.cpp @@ -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(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(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(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>& 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(); diff --git a/src/citra_qt/citra_qt.h b/src/citra_qt/citra_qt.h index cdf9eaff6..da2742edc 100644 --- a/src/citra_qt/citra_qt.h +++ b/src/citra_qt/citra_qt.h @@ -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>& 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 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 update_future; diff --git a/src/citra_qt/configuration/config.cpp b/src/citra_qt/configuration/config.cpp index a5aa9896b..9b03ece71 100644 --- a/src/citra_qt/configuration/config.cpp +++ b/src/citra_qt/configuration/config.cpp @@ -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(); diff --git a/src/citra_qt/configuration/configure_debug.cpp b/src/citra_qt/configuration/configure_debug.cpp index 0fe4f49cf..231e6e1ae 100644 --- a/src/citra_qt/configuration/configure_debug.cpp +++ b/src/citra_qt/configuration/configure_debug.cpp @@ -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(); diff --git a/src/citra_qt/configuration/configure_debug.ui b/src/citra_qt/configuration/configure_debug.ui index 990835a80..9d424a3ca 100644 --- a/src/citra_qt/configuration/configure_debug.ui +++ b/src/citra_qt/configuration/configure_debug.ui @@ -299,6 +299,16 @@ + + + + Enable developer IPC server + + + <html><head/><body><p>Enables a local IPC server for hot-reload and remote control via azahar-ctl. Takes effect immediately.</p></body></html> + + + diff --git a/src/citra_qt/dev_ipc_server.cpp b/src/citra_qt/dev_ipc_server.cpp new file mode 100644 index 000000000..aae4d6383 --- /dev/null +++ b/src/citra_qt/dev_ipc_server.cpp @@ -0,0 +1,238 @@ +// Copyright Azahar Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#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(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(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"); +} diff --git a/src/citra_qt/dev_ipc_server.h b/src/citra_qt/dev_ipc_server.h new file mode 100644 index 000000000..5db84e424 --- /dev/null +++ b/src/citra_qt/dev_ipc_server.h @@ -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 +#include +#include +#include +#include +#include + +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 read_buffers_; + QPointer 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; +}; diff --git a/src/citra_qt/uisettings.h b/src/citra_qt/uisettings.h index ef87b3fe3..9c7287242 100644 --- a/src/citra_qt/uisettings.h +++ b/src/citra_qt/uisettings.h @@ -102,6 +102,8 @@ struct Values { Settings::Setting enable_discord_presence{true, "enable_discord_presence"}; #endif + Settings::Setting enable_dev_ipc_server{false, "enable_dev_ipc_server"}; + // Game List Settings::Setting game_list_icon_size{GameListIconSize::LargeIcon, "iconSize"};