#include "ptt_portal.h" #include "../../lib/debug_config.h" #include #include #include #include #include #include #include #include static const QString kService = "org.freedesktop.portal.Desktop"; static const QString kPath = "/org/freedesktop/portal/desktop"; static const QString kInterface = "org.freedesktop.portal.GlobalShortcuts"; static const QString kRequestInterface = "org.freedesktop.portal.Request"; static const QString kSessionInterface = "org.freedesktop.portal.Session"; QDBusArgument& operator<<(QDBusArgument& arg, const PttPortalShortcut& shortcut) { arg.beginStructure(); arg << shortcut.id << shortcut.properties; arg.endStructure(); return arg; } const QDBusArgument& operator>>(const QDBusArgument& arg, PttPortalShortcut& shortcut) { arg.beginStructure(); arg >> shortcut.id >> shortcut.properties; arg.endStructure(); return arg; } PttPortal::PttPortal(QObject* parent, const QString& sessionToken) : QObject(parent), m_sessionToken(sessionToken) { qDBusRegisterMetaType(); qDBusRegisterMetaType(); m_requestTimer.setSingleShot(true); connect(&m_requestTimer, &QTimer::timeout, this, [this]() { fail("request timed out"); }); auto* watcher = new QDBusServiceWatcher(kService, QDBusConnection::sessionBus(), QDBusServiceWatcher::WatchForUnregistration, this); connect(watcher, &QDBusServiceWatcher::serviceUnregistered, this, [this]() { fail("portal service disappeared"); }); auto bus = QDBusConnection::sessionBus(); bool ok = bus.connect(kService, kPath, kInterface, "Activated", this, SLOT(activated(QDBusObjectPath,QString,qulonglong,QVariantMap))); ok &= bus.connect(kService, kPath, kInterface, "Deactivated", this, SLOT(deactivated(QDBusObjectPath,QString,qulonglong,QVariantMap))); ok &= bus.connect(kService, kPath, kInterface, "ShortcutsChanged", this, SLOT(shortcutsChanged(QDBusObjectPath,PttPortalShortcuts))); if (!ok) fail("cannot subscribe to portal signals"); } PttPortal::~PttPortal() { closeSession(); } bool PttPortal::busy() const { return m_state == State::Checking || m_state == State::Creating || m_state == State::Listing || m_state == State::Binding; } void PttPortal::setStatus(const QString& text) { m_status = text; emit statusChanged(); } void PttPortal::initialize() { if (busy() || m_state == State::Ready || m_state == State::Unbound) return; m_state = State::Checking; setStatus("Подключение глобальных хоткеев…"); auto message = QDBusMessage::createMethodCall(kService, kPath, "org.freedesktop.DBus.Properties", "Get"); message.setArguments({kInterface, "version"}); auto* watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(message), this); uint epoch = m_epoch; connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher, epoch]() { QDBusPendingReply reply = *watcher; watcher->deleteLater(); if (epoch != m_epoch || m_state != State::Checking) return; if (reply.isError()) { fail(reply.error().message()); return; } uint version = reply.value().variant().toUInt(); if (version < 2) { fail(QString("GlobalShortcuts version %1; version 2 required").arg(version)); return; } DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT portal: connected version=%u", version); m_state = State::Creating; // Стабильный session token сохраняет идентичность обычного приложения в KDE без sandbox app_id. request("CreateSession", {}, {{"session_handle_token", m_sessionToken}}); }); } void PttPortal::request(const QString& method, const QVariantList& arguments, QVariantMap options) { auto bus = QDBusConnection::sessionBus(); QString sender = bus.baseService().mid(1).replace('.', '_'); QString token = "utun_ptt_" + QUuid::createUuid().toString(QUuid::Id128); m_request = kPath + "/request/" + sender + "/" + token; if (!bus.connect(kService, m_request, kRequestInterface, "Response", this, SLOT(response(uint,QVariantMap,QDBusMessage)))) { fail("cannot subscribe to request response"); return; } options.insert("handle_token", token); auto message = QDBusMessage::createMethodCall(kService, kPath, kInterface, method); QVariantList args = arguments; args << options; message.setArguments(args); QString expected = m_request; DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "PTT portal: %s request=%s session=%s", qPrintable(method), qPrintable(expected), qPrintable(m_session)); // Binding может ждать действий пользователя; остальные операции ограничены 30 секундами. if (m_state != State::Binding) m_requestTimer.start(30000); auto* watcher = new QDBusPendingCallWatcher(bus.asyncCall(message), this); connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher, expected]() { QDBusPendingReply reply = *watcher; watcher->deleteLater(); if (m_request != expected) return; // Response уже обработан или сессия закрыта if (reply.isError()) { fail(reply.error().message()); return; } if (reply.value().path() != expected) fail("portal returned unexpected request path"); }); } void PttPortal::disconnectRequest() { m_requestTimer.stop(); if (m_request.isEmpty()) return; QDBusConnection::sessionBus().disconnect(kService, m_request, kRequestInterface, "Response", this, SLOT(response(uint,QVariantMap,QDBusMessage))); m_request.clear(); } void PttPortal::response(uint code, const QVariantMap& results, const QDBusMessage& message) { if (m_request.isEmpty() || message.path() != m_request) return; State state = m_state; disconnectRequest(); DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "PTT portal: response state=%d code=%u", int(state), code); if (code != 0) { fail(QString("request %1: response=%2%3").arg(int(state)).arg(code).arg(code == 1 ? " (cancelled)" : "")); if (code == 1) setStatus("Назначение отменено. Нажмите кнопку, чтобы повторить."); return; } if (state == State::Creating) { m_session = results.value("session_handle").toString(); if (!m_session.startsWith(kPath + "/session/")) { fail("invalid session handle"); return; } if (!QDBusConnection::sessionBus().connect(kService, m_session, kSessionInterface, "Closed", this, SLOT(sessionClosed()))) { fail("cannot subscribe to session closure"); return; } m_state = State::Listing; request("ListShortcuts", {QVariant::fromValue(QDBusObjectPath(m_session))}, {}); } else if (state == State::Listing) { auto shortcuts = qdbus_cast(results.value("shortcuts")); updateDescriptions(shortcuts); QSet known; for (const auto& shortcut : shortcuts) if (isPttId(shortcut.id)) known.insert(shortcut.id); m_configureAfterBind = known.size() == 3 && m_configureRequested; m_state = State::Unbound; if (!known.isEmpty() || m_configureRequested) bind(); else setStatus("Назначьте сочетание в системных настройках."); } else if (state == State::Binding) { m_state = State::Ready; updateDescriptions(qdbus_cast(results.value("shortcuts"))); setStatus("Глобальные хоткеи подключены."); DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT portal: bindings ready session=%s", qPrintable(m_session)); // Первое BindShortcuts само открывает окно назначения, повторное Configure — отдельный вызов. m_configureRequested = false; if (m_configureAfterBind) { m_configureAfterBind = false; openConfiguration(); } } else fail("unexpected request response"); } bool PttPortal::isPttId(const QString& id) { return id == "ptt1" || id == "ptt2" || id == "ptt3"; } void PttPortal::bind() { PttPortalShortcuts shortcuts; for (int i = 1; i <= 3; i++) shortcuts << PttPortalShortcut{QString("ptt%1").arg(i), {{"description", QString("Рация: передача %1").arg(i)}}}; m_state = State::Binding; setStatus("Назначение глобальных хоткеев…"); request("BindShortcuts", {QVariant::fromValue(QDBusObjectPath(m_session)), QVariant::fromValue(shortcuts), m_parentWindow}, {}); } void PttPortal::configure(const QString& parentWindow) { m_parentWindow = parentWindow; if (m_state == State::Ready) { openConfiguration(); return; } m_configureRequested = true; if (m_state == State::Unbound) bind(); else initialize(); } void PttPortal::openConfiguration() { auto message = QDBusMessage::createMethodCall(kService, kPath, kInterface, "ConfigureShortcuts"); message.setArguments({QVariant::fromValue(QDBusObjectPath(m_session)), m_parentWindow, QVariantMap{}}); auto* watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(message), this); uint epoch = m_epoch; connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher, epoch]() { QDBusPendingReply<> reply = *watcher; watcher->deleteLater(); if (epoch != m_epoch) return; if (reply.isError()) { fail(reply.error().message()); return; } DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT portal: system configuration opened"); }); } void PttPortal::updateDescriptions(const PttPortalShortcuts& shortcuts) { m_descriptions = {"", "", ""}; for (const auto& shortcut : shortcuts) { if (!isPttId(shortcut.id)) continue; int index = shortcut.id.right(1).toInt() - 1; m_descriptions[index] = shortcut.properties.value("trigger_description").toString(); DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "PTT portal: %s trigger=%s", qPrintable(shortcut.id), qPrintable(m_descriptions[index])); } emit descriptionsChanged(); } void PttPortal::activated(const QDBusObjectPath& session, const QString& id, qulonglong, const QVariantMap&) { if (session.path() != m_session || m_state != State::Ready || !isPttId(id) || m_inhibited) return; m_active.insert(id); updateTalking(); } void PttPortal::deactivated(const QDBusObjectPath& session, const QString& id, qulonglong, const QVariantMap&) { if (session.path() != m_session || !isPttId(id)) return; m_active.remove(id); updateTalking(); } void PttPortal::shortcutsChanged(const QDBusObjectPath& session, const PttPortalShortcuts& shortcuts) { if (session.path() != m_session) return; m_active.clear(); updateTalking(); updateDescriptions(shortcuts); } void PttPortal::setInhibited(bool inhibited) { m_inhibited = inhibited; if (inhibited) { m_active.clear(); updateTalking(); } } void PttPortal::updateTalking() { bool talking = !m_inhibited && !m_active.isEmpty(); if (m_talking == talking) return; m_talking = talking; DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "PTT portal: transmission %s active_shortcuts=%d", talking ? "started" : "stopped", int(m_active.size())); emit talkingChanged(talking); } void PttPortal::closeSession() { m_epoch++; auto bus = QDBusConnection::sessionBus(); if (!m_request.isEmpty()) { auto close = QDBusMessage::createMethodCall(kService, m_request, kRequestInterface, "Close"); close.setAutoStartService(false); bus.asyncCall(close); disconnectRequest(); } if (!m_session.isEmpty()) { bus.disconnect(kService, m_session, kSessionInterface, "Closed", this, SLOT(sessionClosed())); auto close = QDBusMessage::createMethodCall(kService, m_session, kSessionInterface, "Close"); close.setAutoStartService(false); bus.asyncCall(close); m_session.clear(); } } void PttPortal::fail(const QString& detail) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "PTT portal: %s", qPrintable(detail)); closeSession(); m_active.clear(); updateTalking(); m_configureRequested = m_configureAfterBind = false; m_state = State::Failed; m_descriptions = {"", "", ""}; emit descriptionsChanged(); setStatus("Глобальные хоткеи недоступны. Повторите назначение; подробности в логе radio."); } void PttPortal::sessionClosed() { fail("portal session closed"); }