diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 886eb54..8dd64d0 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -9,6 +9,7 @@ add_executable(alakarte game.cpp gamepadmanager.cpp inputmanager.cpp + inputserviceclient.cpp mediamanager.cpp runnermanagerclient.cpp screenshotmodel.cpp @@ -122,6 +123,7 @@ ecm_add_qml_module(alakarte URI org.kde.alakarte game.h gamepadmanager.h inputmanager.h + inputserviceclient.h platformimporter.h steamimporter.h lutrisimporter.h @@ -142,3 +144,5 @@ install(TARGETS alakarte ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) add_subdirectory(gamecenter) add_subdirectory(runner) + +add_subdirectory(input) diff --git a/src/app.cpp b/src/app.cpp index 50f99ee..b9c771b 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -28,6 +28,7 @@ App::App(QObject *parent) , m_gameModel(new GameModel(this)) , m_launcher(new GameLauncher(this)) , m_runnerManager(new RunnerManagerClient(this)) + , m_inputService(new InputServiceClient(this)) , m_steamGridDB(new SteamGridDB(this)) , m_mediaManager(new MediaManager(this)) , m_config(new Config(this)) @@ -175,6 +176,11 @@ RunnerManagerClient *App::runnerManager() const return m_runnerManager; } +InputServiceClient *App::inputService() const +{ + return m_inputService; +} + SteamGridDB *App::steamGridDB() const { return m_steamGridDB; diff --git a/src/app.h b/src/app.h index a398976..0214ec3 100644 --- a/src/app.h +++ b/src/app.h @@ -11,6 +11,7 @@ #include "config.h" #include "gamelauncher.h" #include "gamemodel.h" +#include "inputserviceclient.h" #include "mediamanager.h" #include "runnermanagerclient.h" #include "steamgriddb.h" @@ -24,6 +25,7 @@ class App : public QObject Q_PROPERTY(GameModel *gameModel READ gameModel CONSTANT) Q_PROPERTY(GameLauncher *launcher READ launcher CONSTANT) Q_PROPERTY(RunnerManagerClient *runnerManager READ runnerManager CONSTANT) + Q_PROPERTY(InputServiceClient *inputService READ inputService CONSTANT) Q_PROPERTY(SteamGridDB *steamGridDB READ steamGridDB CONSTANT) Q_PROPERTY(MediaManager *mediaManager READ mediaManager CONSTANT) Q_PROPERTY(Config *config READ config CONSTANT) @@ -37,6 +39,7 @@ public: GameModel *gameModel() const; GameLauncher *launcher() const; RunnerManagerClient *runnerManager() const; + InputServiceClient *inputService() const; SteamGridDB *steamGridDB() const; MediaManager *mediaManager() const; Config *config() const; @@ -78,6 +81,7 @@ private: GameModel *m_gameModel; GameLauncher *m_launcher; RunnerManagerClient *m_runnerManager; + InputServiceClient *m_inputService; SteamGridDB *m_steamGridDB; MediaManager *m_mediaManager; Config *m_config; diff --git a/src/gamepadmanager.cpp b/src/gamepadmanager.cpp index 6a871b8..6c948d7 100644 --- a/src/gamepadmanager.cpp +++ b/src/gamepadmanager.cpp @@ -136,6 +136,21 @@ bool GamepadManager::active() const return m_active; } +QString GamepadManager::name() const +{ + return m_name; +} + +int GamepadManager::vendorId() const +{ + return m_vendorId; +} + +int GamepadManager::productId() const +{ + return m_productId; +} + GamepadManager::ControllerStyle GamepadManager::controllerStyle() const { return m_controllerStyle; @@ -229,14 +244,24 @@ void GamepadManager::closeGamepad() void GamepadManager::updateControllerInfo() { ControllerStyle style = UnknownController; + QString name; + int vendorId = 0; + int productId = 0; QString confirmLabel; QString backLabel; QString detailsLabel; QString searchLabel; if (m_gamepad) { + const char *padName = SDL_GetGamepadName(m_gamepad); + if (padName) { + name = QString::fromUtf8(padName); + } const SDL_GamepadType type = SDL_GetGamepadType(m_gamepad); const Uint16 vendor = SDL_GetGamepadVendor(m_gamepad); + const Uint16 product = SDL_GetGamepadProduct(m_gamepad); + vendorId = static_cast(vendor); + productId = static_cast(product); style = controllerStyleFromTypeVendor(type, vendor); #if SDL_VERSION_ATLEAST(3, 2, 0) @@ -291,9 +316,13 @@ void GamepadManager::updateControllerInfo() } const bool changed = (style != m_controllerStyle) || (confirmLabel != m_confirmButtonLabel) || (backLabel != m_backButtonLabel) - || (detailsLabel != m_detailsButtonLabel) || (searchLabel != m_searchButtonLabel); + || (detailsLabel != m_detailsButtonLabel) || (searchLabel != m_searchButtonLabel) || (name != m_name) || (vendorId != m_vendorId) + || (productId != m_productId); m_controllerStyle = style; + m_name = name; + m_vendorId = vendorId; + m_productId = productId; m_confirmButtonLabel = confirmLabel; m_backButtonLabel = backLabel; m_detailsButtonLabel = detailsLabel; diff --git a/src/gamepadmanager.h b/src/gamepadmanager.h index 0741392..992cee4 100644 --- a/src/gamepadmanager.h +++ b/src/gamepadmanager.h @@ -18,6 +18,9 @@ class GamepadManager : public QObject Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged) Q_PROPERTY(bool active READ active NOTIFY activeChanged) + Q_PROPERTY(QString name READ name NOTIFY controllerInfoChanged) + Q_PROPERTY(int vendorId READ vendorId NOTIFY controllerInfoChanged) + Q_PROPERTY(int productId READ productId NOTIFY controllerInfoChanged) Q_PROPERTY(ControllerStyle controllerStyle READ controllerStyle NOTIFY controllerInfoChanged) Q_PROPERTY(QString confirmButtonLabel READ confirmButtonLabel NOTIFY controllerInfoChanged) Q_PROPERTY(QString backButtonLabel READ backButtonLabel NOTIFY controllerInfoChanged) @@ -40,6 +43,10 @@ public: bool connected() const; bool active() const; + QString name() const; + int vendorId() const; + int productId() const; + ControllerStyle controllerStyle() const; QString confirmButtonLabel() const; QString backButtonLabel() const; @@ -86,6 +93,10 @@ private: qint8 m_leftDirX = 0; qint8 m_leftDirY = 0; + QString m_name; + int m_vendorId = 0; + int m_productId = 0; + void openFirstAvailableGamepad(); void openGamepad(int deviceId); void closeGamepad(); diff --git a/src/input/CMakeLists.txt b/src/input/CMakeLists.txt new file mode 100644 index 0000000..2739d8a --- /dev/null +++ b/src/input/CMakeLists.txt @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: BSD-3-Clause +# SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +add_executable(alakarte_inputd + main.cpp + inputdaemon.cpp + inputdaemon.h +) + +target_link_libraries(alakarte_inputd PRIVATE + Qt6::Core + Qt6::DBus + SDL3::SDL3 +) + +set_target_properties(alakarte_inputd PROPERTIES + OUTPUT_NAME "alakarte-inputd" +) + +install(TARGETS alakarte_inputd ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) + +file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/dbus") +file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/systemd") + +configure_file(dbus/org.kde.ALaKarte.Input1.service.in + "${CMAKE_CURRENT_BINARY_DIR}/dbus/org.kde.ALaKarte.Input1.service" + @ONLY +) + +configure_file(systemd/org.kde.ALaKarte.Input1.service.in + "${CMAKE_CURRENT_BINARY_DIR}/systemd/org.kde.ALaKarte.Input1.service" + @ONLY +) + +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/dbus/org.kde.ALaKarte.Input1.service" + DESTINATION ${KDE_INSTALL_DBUSSERVICEDIR} +) + +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/systemd/org.kde.ALaKarte.Input1.service" + DESTINATION ${KDE_INSTALL_SYSTEMDUSERUNITDIR} +) diff --git a/src/input/dbus/org.kde.ALaKarte.Input1.service.in b/src/input/dbus/org.kde.ALaKarte.Input1.service.in new file mode 100644 index 0000000..11cd92c --- /dev/null +++ b/src/input/dbus/org.kde.ALaKarte.Input1.service.in @@ -0,0 +1,4 @@ +[D-BUS Service] +Name=org.kde.ALaKarte.Input1 +Exec=@CMAKE_INSTALL_PREFIX@/@KDE_INSTALL_BINDIR@/alakarte-inputd +SystemdService=org.kde.ALaKarte.Input1.service diff --git a/src/input/inputdaemon.cpp b/src/input/inputdaemon.cpp new file mode 100644 index 0000000..ca1b9e1 --- /dev/null +++ b/src/input/inputdaemon.cpp @@ -0,0 +1,618 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include "inputdaemon.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#define SDL_MAIN_HANDLED 1 +#include +#include + +static const QString kInputService = QStringLiteral("org.kde.ALaKarte.Input1"); +static const QString kInputPath = QStringLiteral("/org/kde/ALaKarte/Input1"); +static const QString kInputInterface = QStringLiteral("org.kde.ALaKarte.Input1"); + +namespace +{ +static QString guidToString(SDL_GUID guid) +{ + char buf[64] = {0}; + SDL_GUIDToString(guid, buf, sizeof(buf)); + return QString::fromLatin1(buf); +} + +static QString powerStateToString(SDL_PowerState st) +{ + switch (st) { + case SDL_POWERSTATE_ON_BATTERY: + return QStringLiteral("on_battery"); + case SDL_POWERSTATE_CHARGING: + return QStringLiteral("charging"); + case SDL_POWERSTATE_CHARGED: + return QStringLiteral("charged"); + case SDL_POWERSTATE_NO_BATTERY: + return QStringLiteral("no_battery"); + case SDL_POWERSTATE_UNKNOWN: + return QStringLiteral("unknown"); + default: + return QStringLiteral("error"); + } +} + +static QString connectionStateToString(SDL_JoystickConnectionState st) +{ + switch (st) { + case SDL_JOYSTICK_CONNECTION_WIRED: + return QStringLiteral("wired"); + case SDL_JOYSTICK_CONNECTION_WIRELESS: + return QStringLiteral("wireless"); + default: + return QStringLiteral("unknown"); + } +} + +static QString gamepadTypeToString(SDL_GamepadType t) +{ + switch (t) { + case SDL_GAMEPAD_TYPE_XBOX360: + case SDL_GAMEPAD_TYPE_XBOXONE: + return QStringLiteral("xbox"); + case SDL_GAMEPAD_TYPE_PS3: + case SDL_GAMEPAD_TYPE_PS4: + case SDL_GAMEPAD_TYPE_PS5: + return QStringLiteral("playstation"); + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT: + case SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR: + return QStringLiteral("nintendo"); + case SDL_GAMEPAD_TYPE_STANDARD: + return QStringLiteral("standard"); + default: + return QStringLiteral("unknown"); + } +} + +static QVariantMap unwrapVariantMap(QVariant v) +{ + if (v.canConvert()) { + return v.toMap(); + } + return {}; +} +} + +QVariantMap InputDaemon::Profile::toVariantMap() const +{ + QVariantMap out; + out.insert(QStringLiteral("id"), id); + out.insert(QStringLiteral("name"), name); + if (!mapping.isEmpty()) { + out.insert(QStringLiteral("mapping"), mapping); + } + return out; +} + +InputDaemon::Profile InputDaemon::Profile::fromVariantMap(const QVariantMap &map) +{ + Profile p; + p.id = map.value(QStringLiteral("id")).toString(); + p.name = map.value(QStringLiteral("name")).toString(); + p.mapping = map.value(QStringLiteral("mapping")).toMap(); + return p; +} + +bool InputDaemon::Profile::isValid() const +{ + return !id.isEmpty() && !name.isEmpty(); +} + +InputDaemon::InputDaemon(QObject *parent) + : QObject(parent) +{ + SDL_SetMainReady(); + + m_pollTimer.setInterval(16); + connect(&m_pollTimer, &QTimer::timeout, this, &InputDaemon::pollEvents); + + if (QCoreApplication::instance()) { + connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]() { + shutdownSdl(); + }); + } +} + +bool InputDaemon::init() +{ + loadProfiles(); + + if (!initSdl()) { + qWarning() << "InputDaemon: SDL init failed" << SDL_GetError(); + return false; + } + + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + qWarning() << "InputDaemon: session bus not connected"; + return false; + } + + if (!bus.registerService(kInputService)) { + qWarning() << "InputDaemon: failed to register service" << kInputService << bus.lastError().message(); + return false; + } + + if (!bus.registerObject(kInputPath, this, QDBusConnection::ExportAllSlots | QDBusConnection::ExportAllSignals)) { + qWarning() << "InputDaemon: failed to register object" << kInputPath << bus.lastError().message(); + return false; + } + + openExistingControllers(); + m_pollTimer.start(); + + return true; +} + +bool InputDaemon::initSdl() +{ + if (m_sdlInitialized) { + return true; + } + + m_sdlInitialized = SDL_Init(SDL_INIT_GAMEPAD | SDL_INIT_JOYSTICK); + if (!m_sdlInitialized) { + return false; + } + + SDL_SetGamepadEventsEnabled(true); + SDL_SetJoystickEventsEnabled(true); + return true; +} + +void InputDaemon::shutdownSdl() +{ + if (!m_sdlInitialized) { + return; + } + + m_pollTimer.stop(); + + const QList keys = m_controllers.keys(); + for (int instanceId : keys) { + closeController(instanceId); + } + + SDL_Quit(); + m_sdlInitialized = false; +} + +QString InputDaemon::Ping() const +{ + return QStringLiteral("ok"); +} + +void InputDaemon::Version(uint &major, uint &minor) const +{ + major = 1; + minor = 0; +} + +QVariantMap InputDaemon::GetCapabilities() const +{ + return { + {QStringLiteral("supportsBattery"), true}, + {QStringLiteral("supportsHotplug"), true}, + {QStringLiteral("supportsProfiles"), true}, + }; +} + +QString InputDaemon::profilesPath() const +{ + const QString base = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); + return base + QStringLiteral("/input-profiles.json"); +} + +void InputDaemon::loadProfiles() +{ + m_profiles.clear(); + m_controllerKeyToProfileId.clear(); + + QFile f(profilesPath()); + if (f.open(QIODevice::ReadOnly)) { + const QJsonDocument doc = QJsonDocument::fromJson(f.readAll()); + if (doc.isObject()) { + const QJsonObject root = doc.object(); + + const QJsonArray profiles = root.value(QStringLiteral("profiles")).toArray(); + for (const QJsonValue &v : profiles) { + if (!v.isObject()) { + continue; + } + const QVariantMap m = v.toObject().toVariantMap(); + const Profile p = Profile::fromVariantMap(m); + if (p.isValid()) { + m_profiles.insert(p.id, p); + } + } + + const QJsonObject assignments = root.value(QStringLiteral("assignments")).toObject(); + for (auto it = assignments.begin(); it != assignments.end(); ++it) { + const QString controllerKey = it.key(); + const QString profileId = it.value().toString(); + if (!controllerKey.isEmpty() && !profileId.isEmpty()) { + m_controllerKeyToProfileId.insert(controllerKey, profileId); + } + } + } + } + + if (m_profiles.isEmpty()) { + Profile def; + def.id = QStringLiteral("default"); + def.name = QStringLiteral("Default"); + m_profiles.insert(def.id, def); + saveProfiles(); + } +} + +bool InputDaemon::saveProfiles() const +{ + const QString path = profilesPath(); + QDir().mkpath(QFileInfo(path).absolutePath()); + + QJsonArray profiles; + for (const Profile &p : m_profiles) { + profiles.push_back(QJsonObject::fromVariantMap(p.toVariantMap())); + } + + QJsonObject assignments; + for (auto it = m_controllerKeyToProfileId.constBegin(); it != m_controllerKeyToProfileId.constEnd(); ++it) { + assignments.insert(it.key(), it.value()); + } + + QJsonObject root; + root.insert(QStringLiteral("profiles"), profiles); + root.insert(QStringLiteral("assignments"), assignments); + + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + + f.write(QJsonDocument(root).toJson()); + return true; +} + +InputDaemon::Profile InputDaemon::profileById(const QString &profileId) const +{ + auto it = m_profiles.constFind(profileId); + if (it == m_profiles.constEnd()) { + return {}; + } + return it.value(); +} + +QVariantList InputDaemon::ListProfiles() const +{ + QVariantList out; + out.reserve(m_profiles.size()); + + for (const Profile &p : m_profiles) { + out.push_back(p.toVariantMap()); + } + + return out; +} + +QVariantMap InputDaemon::GetProfile(const QString &profileId) const +{ + const Profile p = profileById(profileId); + return p.toVariantMap(); +} + +QString InputDaemon::CreateProfile(const QString &name) +{ + const QString trimmed = name.trimmed(); + if (trimmed.isEmpty()) { + return {}; + } + + const QString id = QString::number(QDateTime::currentMSecsSinceEpoch(), 16); + + Profile p; + p.id = QStringLiteral("p_") + id; + p.name = trimmed; + + if (!p.isValid()) { + return {}; + } + + m_profiles.insert(p.id, p); + saveProfiles(); + Q_EMIT ProfilesChanged(); + + return p.id; +} + +bool InputDaemon::DeleteProfile(const QString &profileId) +{ + if (profileId == QLatin1String("default")) { + return false; + } + + if (!m_profiles.contains(profileId)) { + return false; + } + + m_profiles.remove(profileId); + + for (auto it = m_controllerKeyToProfileId.begin(); it != m_controllerKeyToProfileId.end();) { + if (it.value() == profileId) { + it = m_controllerKeyToProfileId.erase(it); + } else { + ++it; + } + } + + saveProfiles(); + Q_EMIT ProfilesChanged(); + return true; +} + +QString InputDaemon::controllerKeyForProfile(const Controller &c) const +{ + if (!c.guid.isEmpty() && c.vendorId > 0 && c.productId > 0) { + return QStringLiteral("guid:%1:%2:%3").arg(c.guid).arg(c.vendorId).arg(c.productId); + } + if (!c.guid.isEmpty()) { + return QStringLiteral("guid:%1").arg(c.guid); + } + if (!c.path.isEmpty()) { + return QStringLiteral("path:%1").arg(c.path); + } + return c.controllerId; +} + +QString InputDaemon::GetActiveProfile(const QString &controllerId) const +{ + for (const Controller &c : m_controllers) { + if (c.controllerId == controllerId) { + const QString key = controllerKeyForProfile(c); + return m_controllerKeyToProfileId.value(key, QStringLiteral("default")); + } + } + return QStringLiteral("default"); +} + +bool InputDaemon::SetActiveProfile(const QString &controllerId, const QString &profileId) +{ + if (!m_profiles.contains(profileId)) { + return false; + } + + for (const Controller &c : m_controllers) { + if (c.controllerId == controllerId) { + const QString key = controllerKeyForProfile(c); + m_controllerKeyToProfileId.insert(key, profileId); + saveProfiles(); + Q_EMIT ControllerProfileChanged(controllerId, profileId); + Q_EMIT ControllerChanged(controllerToVariantMap(c)); + return true; + } + } + + return false; +} + +QVariantMap InputDaemon::controllerToVariantMap(const Controller &c) const +{ + QVariantMap out; + out.insert(QStringLiteral("controllerId"), c.controllerId); + out.insert(QStringLiteral("instanceId"), c.instanceId); + out.insert(QStringLiteral("guid"), c.guid); + out.insert(QStringLiteral("name"), c.name); + out.insert(QStringLiteral("path"), c.path); + out.insert(QStringLiteral("vendorId"), c.vendorId); + out.insert(QStringLiteral("productId"), c.productId); + out.insert(QStringLiteral("type"), c.type); + out.insert(QStringLiteral("connectionState"), c.connectionState); + + out.insert(QStringLiteral("batteryState"), c.batteryState); + out.insert(QStringLiteral("batteryStateName"), powerStateToString(static_cast(c.batteryState))); + out.insert(QStringLiteral("batteryPercent"), c.batteryPercent); + + const QString activeId = m_controllerKeyToProfileId.value(controllerKeyForProfile(c), QStringLiteral("default")); + out.insert(QStringLiteral("activeProfileId"), activeId); + out.insert(QStringLiteral("activeProfileName"), profileById(activeId).name); + + return out; +} + +QVariantList InputDaemon::ListControllers() const +{ + QVariantList out; + out.reserve(m_controllers.size()); + + for (const Controller &c : m_controllers) { + out.push_back(controllerToVariantMap(c)); + } + + return out; +} + +QVariantMap InputDaemon::GetController(const QString &controllerId) const +{ + for (const Controller &c : m_controllers) { + if (c.controllerId == controllerId) { + return controllerToVariantMap(c); + } + } + return {}; +} + +bool InputDaemon::Rescan() +{ + openExistingControllers(); + return true; +} + +void InputDaemon::openExistingControllers() +{ + if (!m_sdlInitialized) { + return; + } + + int count = 0; + std::unique_ptr ids(SDL_GetGamepads(&count), SDL_free); + if (!ids || count <= 0) { + return; + } + + for (int i = 0; i < count; ++i) { + const int instanceId = static_cast(ids[i]); + if (!m_controllers.contains(instanceId)) { + openController(instanceId); + } + } +} + +void InputDaemon::refreshControllerPower(Controller &c) +{ + if (!c.pad) { + c.batteryState = static_cast(SDL_POWERSTATE_UNKNOWN); + c.batteryPercent = -1; + return; + } + + int percent = -1; + const SDL_PowerState st = SDL_GetGamepadPowerInfo(c.pad, &percent); + c.batteryState = static_cast(st); + c.batteryPercent = percent; +} + +void InputDaemon::openController(int instanceId) +{ + if (!m_sdlInitialized) { + return; + } + + if (m_controllers.contains(instanceId)) { + return; + } + + SDL_Gamepad *pad = SDL_OpenGamepad(static_cast(instanceId)); + if (!pad) { + return; + } + + Controller c; + c.pad = pad; + c.instanceId = instanceId; + c.controllerId = QStringLiteral("sdl:%1").arg(instanceId); + + c.guid = guidToString(SDL_GetGamepadGUIDForID(static_cast(instanceId))); + + const char *name = SDL_GetGamepadName(pad); + if (name) { + c.name = QString::fromUtf8(name); + } + + const char *path = SDL_GetGamepadPath(pad); + if (path) { + c.path = QString::fromUtf8(path); + } + + c.vendorId = SDL_GetGamepadVendor(pad); + c.productId = SDL_GetGamepadProduct(pad); + c.type = gamepadTypeToString(SDL_GetGamepadType(pad)); + c.connectionState = connectionStateToString(SDL_GetGamepadConnectionState(pad)); + + refreshControllerPower(c); + + m_controllers.insert(instanceId, c); + Q_EMIT ControllerAdded(controllerToVariantMap(c)); +} + +void InputDaemon::closeController(int instanceId) +{ + auto it = m_controllers.find(instanceId); + if (it == m_controllers.end()) { + return; + } + + const QString controllerId = it.value().controllerId; + + if (it.value().pad) { + SDL_CloseGamepad(it.value().pad); + it.value().pad = nullptr; + } + + m_controllers.erase(it); + Q_EMIT ControllerRemoved(controllerId); +} + +void InputDaemon::pollEvents() +{ + if (!m_sdlInitialized) { + return; + } + + SDL_Event e; + bool anyChanged = false; + + while (SDL_PollEvent(&e)) { + switch (e.type) { + case SDL_EVENT_GAMEPAD_ADDED: + openController(static_cast(e.gdevice.which)); + break; + case SDL_EVENT_GAMEPAD_REMOVED: + closeController(static_cast(e.gdevice.which)); + break; + case SDL_EVENT_JOYSTICK_BATTERY_UPDATED: { + const int instanceId = static_cast(e.jbattery.which); + auto it = m_controllers.find(instanceId); + if (it == m_controllers.end()) { + break; + } + it.value().batteryState = static_cast(e.jbattery.state); + it.value().batteryPercent = e.jbattery.percent; + Q_EMIT ControllerChanged(controllerToVariantMap(it.value())); + anyChanged = true; + break; + } + default: + break; + } + } + + const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); + if (m_lastPowerRefreshMs <= 0 || (nowMs - m_lastPowerRefreshMs) > 5000) { + m_lastPowerRefreshMs = nowMs; + for (auto it = m_controllers.begin(); it != m_controllers.end(); ++it) { + const int oldPercent = it.value().batteryPercent; + const int oldState = it.value().batteryState; + refreshControllerPower(it.value()); + if (it.value().batteryPercent != oldPercent || it.value().batteryState != oldState) { + Q_EMIT ControllerChanged(controllerToVariantMap(it.value())); + anyChanged = true; + } + } + } + + Q_UNUSED(anyChanged) +} diff --git a/src/input/inputdaemon.h b/src/input/inputdaemon.h new file mode 100644 index 0000000..776a0ab --- /dev/null +++ b/src/input/inputdaemon.h @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#pragma once + +#include +#include +#include +#include +#include +#include + +struct SDL_Gamepad; + +class InputDaemon : public QObject +{ + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.kde.ALaKarte.Input1") + +public: + explicit InputDaemon(QObject *parent = nullptr); + + bool init(); + +public Q_SLOTS: + QString Ping() const; + void Version(uint &major, uint &minor) const; + QVariantMap GetCapabilities() const; + + QVariantList ListControllers() const; + QVariantMap GetController(const QString &controllerId) const; + bool Rescan(); + + QVariantList ListProfiles() const; + QVariantMap GetProfile(const QString &profileId) const; + QString CreateProfile(const QString &name); + bool DeleteProfile(const QString &profileId); + + bool SetActiveProfile(const QString &controllerId, const QString &profileId); + QString GetActiveProfile(const QString &controllerId) const; + +Q_SIGNALS: + void ControllerAdded(const QVariantMap &controller); + void ControllerRemoved(const QString &controllerId); + void ControllerChanged(const QVariantMap &controller); + + void ProfilesChanged(); + void ControllerProfileChanged(const QString &controllerId, const QString &profileId); + +private: + struct Controller { + SDL_Gamepad *pad = nullptr; + int instanceId = 0; + QString controllerId; + QString guid; + QString name; + QString path; + uint vendorId = 0; + uint productId = 0; + QString type; + QString connectionState; + int batteryState = 0; + int batteryPercent = -1; + }; + + struct Profile { + QString id; + QString name; + QVariantMap mapping; + + QVariantMap toVariantMap() const; + static Profile fromVariantMap(const QVariantMap &map); + bool isValid() const; + }; + + bool initSdl(); + void shutdownSdl(); + + void pollEvents(); + + void openExistingControllers(); + void openController(int instanceId); + void closeController(int instanceId); + + void refreshControllerPower(Controller &c); + + QString controllerKeyForProfile(const Controller &c) const; + QVariantMap controllerToVariantMap(const Controller &c) const; + + QString profilesPath() const; + void loadProfiles(); + bool saveProfiles() const; + + Profile profileById(const QString &profileId) const; + + QHash m_controllers; + + QHash m_profiles; + QHash m_controllerKeyToProfileId; + + bool m_sdlInitialized = false; + QTimer m_pollTimer; + qint64 m_lastPowerRefreshMs = 0; +}; diff --git a/src/input/main.cpp b/src/input/main.cpp new file mode 100644 index 0000000..216f447 --- /dev/null +++ b/src/input/main.cpp @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include + +#include "inputdaemon.h" + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + QCoreApplication::setApplicationName(QStringLiteral("alakarte")); + + InputDaemon daemon; + if (!daemon.init()) { + return 1; + } + + return app.exec(); +} diff --git a/src/input/systemd/org.kde.ALaKarte.Input1.service.in b/src/input/systemd/org.kde.ALaKarte.Input1.service.in new file mode 100644 index 0000000..779aa27 --- /dev/null +++ b/src/input/systemd/org.kde.ALaKarte.Input1.service.in @@ -0,0 +1,9 @@ +[Unit] +Description=A-La-Karte Input Service +PartOf=graphical-session.target + +[Service] +ExecStart=@CMAKE_INSTALL_PREFIX@/@KDE_INSTALL_BINDIR@/alakarte-inputd +Type=dbus +BusName=org.kde.ALaKarte.Input1 +Restart=no diff --git a/src/inputserviceclient.cpp b/src/inputserviceclient.cpp new file mode 100644 index 0000000..48e9388 --- /dev/null +++ b/src/inputserviceclient.cpp @@ -0,0 +1,354 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#include "inputserviceclient.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +static const QString kInputService = QStringLiteral("org.kde.ALaKarte.Input1"); +static const QString kInputPath = QStringLiteral("/org/kde/ALaKarte/Input1"); +static const QString kInputInterface = QStringLiteral("org.kde.ALaKarte.Input1"); + +namespace +{ +static QVariant unwrapDbusVariant(QVariant v) +{ + if (v.canConvert()) { + v = v.value().variant(); + } + return v; +} + +static QVariantMap unwrapVariantMap(QVariant v) +{ + v = unwrapDbusVariant(v); + if (v.canConvert()) { + return v.toMap(); + } + return {}; +} + +static QVariantMap normalizeMap(QVariantMap m) +{ + for (auto it = m.begin(); it != m.end(); ++it) { + it.value() = unwrapDbusVariant(it.value()); + } + return m; +} +} + +InputServiceClient::InputServiceClient(QObject *parent) + : QObject(parent) +{ + QDBusConnection bus = QDBusConnection::sessionBus(); + if (bus.isConnected()) { + m_watcher = new QDBusServiceWatcher(kInputService, bus, QDBusServiceWatcher::WatchForRegistration | QDBusServiceWatcher::WatchForUnregistration, this); + connect(m_watcher, &QDBusServiceWatcher::serviceRegistered, this, [this]() { + setAvailable(true); + connectSignals(); + refresh(); + }); + connect(m_watcher, &QDBusServiceWatcher::serviceUnregistered, this, [this]() { + setAvailable(false); + m_controllers.clear(); + m_profiles.clear(); + Q_EMIT controllersChanged(); + Q_EMIT profilesChanged(); + }); + } + + if (QCoreApplication::instance()) { + connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, [this]() { + m_connectedSignals = false; + }); + } + + ensureService(); + refresh(); +} + +InputServiceClient::~InputServiceClient() = default; + +bool InputServiceClient::available() const +{ + return m_available; +} + +QString InputServiceClient::lastError() const +{ + return m_lastError; +} + +QVariantList InputServiceClient::controllers() const +{ + return m_controllers; +} + +QVariantList InputServiceClient::profiles() const +{ + return m_profiles; +} + +void InputServiceClient::setAvailable(bool available) +{ + if (m_available == available) { + return; + } + m_available = available; + Q_EMIT availableChanged(); +} + +void InputServiceClient::setLastError(const QString &error) +{ + if (m_lastError == error) { + return; + } + m_lastError = error; + Q_EMIT lastErrorChanged(); +} + +void InputServiceClient::ensureService() +{ + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected() || !bus.interface()) { + setAvailable(false); + setLastError(QStringLiteral("session bus not available")); + return; + } + + if (bus.interface()->isServiceRegistered(kInputService)) { + setAvailable(true); + connectSignals(); + return; + } + + bool activatable = false; + { + const QDBusReply names = bus.interface()->activatableServiceNames(); + if (names.isValid()) { + activatable = names.value().contains(kInputService); + } + } + + const QDBusReply reply = bus.interface()->startService(kInputService); + if (reply.isValid() || bus.interface()->isServiceRegistered(kInputService)) { + setAvailable(true); + connectSignals(); + return; + } + + setAvailable(false); + if (activatable) { + setLastError(reply.error().message()); + } else { + setLastError(QStringLiteral("Input service is not available (missing DBus activation for org.kde.ALaKarte.Input1)")); + } +} + +void InputServiceClient::connectSignals() +{ + if (m_connectedSignals) { + return; + } + + QDBusConnection bus = QDBusConnection::sessionBus(); + if (!bus.isConnected()) { + return; + } + + const bool ok1 = bus.connect(kInputService, kInputPath, kInputInterface, QStringLiteral("ControllerAdded"), this, SLOT(onControllerAdded(QVariantMap))); + + const bool ok2 = bus.connect(kInputService, kInputPath, kInputInterface, QStringLiteral("ControllerChanged"), this, SLOT(onControllerChanged(QVariantMap))); + + const bool ok3 = bus.connect(kInputService, kInputPath, kInputInterface, QStringLiteral("ControllerRemoved"), this, SLOT(onControllerRemoved(QString))); + + const bool ok4 = bus.connect(kInputService, kInputPath, kInputInterface, QStringLiteral("ProfilesChanged"), this, SLOT(onProfilesChanged())); + + m_connectedSignals = ok1 && ok2 && ok3 && ok4; +} + +void InputServiceClient::onControllerAdded(QVariantMap controller) +{ + upsertController(normalizeMap(controller)); +} + +void InputServiceClient::onControllerChanged(QVariantMap controller) +{ + upsertController(normalizeMap(controller)); +} + +void InputServiceClient::onControllerRemoved(const QString &controllerId) +{ + removeControllerById(controllerId); +} + +void InputServiceClient::onProfilesChanged() +{ + refreshProfiles(); +} + +void InputServiceClient::upsertController(const QVariantMap &controller) +{ + const QString id = controller.value(QStringLiteral("controllerId")).toString(); + if (id.isEmpty()) { + return; + } + + for (int i = 0; i < m_controllers.size(); ++i) { + const QVariantMap existing = m_controllers.at(i).toMap(); + if (existing.value(QStringLiteral("controllerId")).toString() == id) { + m_controllers[i] = controller; + Q_EMIT controllersChanged(); + return; + } + } + + m_controllers.push_back(controller); + Q_EMIT controllersChanged(); +} + +void InputServiceClient::removeControllerById(const QString &controllerId) +{ + if (controllerId.isEmpty()) { + return; + } + + for (int i = 0; i < m_controllers.size(); ++i) { + const QVariantMap existing = m_controllers.at(i).toMap(); + if (existing.value(QStringLiteral("controllerId")).toString() == controllerId) { + m_controllers.removeAt(i); + Q_EMIT controllersChanged(); + return; + } + } +} + +void InputServiceClient::refresh() +{ + refreshControllers(); + refreshProfiles(); +} + +void InputServiceClient::refreshControllers() +{ + ensureService(); + if (!m_available) { + return; + } + + QDBusInterface i(kInputService, kInputPath, kInputInterface, QDBusConnection::sessionBus()); + i.setTimeout(2000); + + const QDBusReply rescanReply = i.call(QStringLiteral("Rescan")); + if (!rescanReply.isValid()) { + setLastError(rescanReply.error().message()); + } + + const QDBusReply reply = i.call(QStringLiteral("ListControllers")); + + if (!reply.isValid()) { + setLastError(reply.error().message()); + return; + } + + m_controllers.clear(); + const QVariantList list = reply.value(); + m_controllers.reserve(list.size()); + for (const QVariant &v : list) { + m_controllers.push_back(unwrapVariantMap(v)); + } + Q_EMIT controllersChanged(); +} + +void InputServiceClient::refreshProfiles() +{ + ensureService(); + if (!m_available) { + return; + } + + QDBusInterface i(kInputService, kInputPath, kInputInterface, QDBusConnection::sessionBus()); + i.setTimeout(2000); + const QDBusReply reply = i.call(QStringLiteral("ListProfiles")); + + if (!reply.isValid()) { + setLastError(reply.error().message()); + return; + } + + m_profiles.clear(); + const QVariantList list = reply.value(); + m_profiles.reserve(list.size()); + for (const QVariant &v : list) { + m_profiles.push_back(unwrapVariantMap(v)); + } + + Q_EMIT profilesChanged(); +} + +bool InputServiceClient::setActiveProfile(const QString &controllerId, const QString &profileId) +{ + ensureService(); + if (!m_available) { + return false; + } + + QDBusInterface i(kInputService, kInputPath, kInputInterface, QDBusConnection::sessionBus()); + i.setTimeout(2000); + const QDBusReply reply = i.call(QStringLiteral("SetActiveProfile"), controllerId, profileId); + + if (!reply.isValid()) { + setLastError(reply.error().message()); + return false; + } + + return reply.value(); +} + +QString InputServiceClient::createProfile(const QString &name) +{ + ensureService(); + if (!m_available) { + return {}; + } + + QDBusInterface i(kInputService, kInputPath, kInputInterface, QDBusConnection::sessionBus()); + i.setTimeout(2000); + const QDBusReply reply = i.call(QStringLiteral("CreateProfile"), name); + + if (!reply.isValid()) { + setLastError(reply.error().message()); + return {}; + } + + refreshProfiles(); + return reply.value(); +} + +bool InputServiceClient::deleteProfile(const QString &profileId) +{ + ensureService(); + if (!m_available) { + return false; + } + + QDBusInterface i(kInputService, kInputPath, kInputInterface, QDBusConnection::sessionBus()); + i.setTimeout(2000); + const QDBusReply reply = i.call(QStringLiteral("DeleteProfile"), profileId); + + if (!reply.isValid()) { + setLastError(reply.error().message()); + return false; + } + + refreshProfiles(); + refreshControllers(); + return reply.value(); +} diff --git a/src/inputserviceclient.h b/src/inputserviceclient.h new file mode 100644 index 0000000..6c2e2f5 --- /dev/null +++ b/src/inputserviceclient.h @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 A-La-Karte Contributors + +#pragma once + +#include +#include +#include +#include + +class QDBusServiceWatcher; + +class InputServiceClient : public QObject +{ + Q_OBJECT + Q_PROPERTY(bool available READ available NOTIFY availableChanged) + Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) + Q_PROPERTY(QVariantList controllers READ controllers NOTIFY controllersChanged) + Q_PROPERTY(QVariantList profiles READ profiles NOTIFY profilesChanged) + +public: + explicit InputServiceClient(QObject *parent = nullptr); + ~InputServiceClient() override; + + bool available() const; + QString lastError() const; + + QVariantList controllers() const; + QVariantList profiles() const; + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void refreshControllers(); + Q_INVOKABLE void refreshProfiles(); + + Q_INVOKABLE bool setActiveProfile(const QString &controllerId, const QString &profileId); + Q_INVOKABLE QString createProfile(const QString &name); + Q_INVOKABLE bool deleteProfile(const QString &profileId); + +Q_SIGNALS: + void availableChanged(); + void lastErrorChanged(); + void controllersChanged(); + void profilesChanged(); + +private Q_SLOTS: + void onControllerAdded(QVariantMap controller); + void onControllerChanged(QVariantMap controller); + void onControllerRemoved(const QString &controllerId); + void onProfilesChanged(); + +private: + void ensureService(); + void connectSignals(); + void setAvailable(bool available); + void setLastError(const QString &error); + + void upsertController(const QVariantMap &controller); + void removeControllerById(const QString &controllerId); + + bool m_available = false; + QString m_lastError; + + QVariantList m_controllers; + QVariantList m_profiles; + + QDBusServiceWatcher *m_watcher = nullptr; + bool m_connectedSignals = false; +};