diff --git a/CMakeLists.txt b/CMakeLists.txt index 11565a1f2..14bd2528d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -564,6 +564,7 @@ if(BUILD_TESTING) src/WallpaperEngine/Testing/Harnesses/RenderHarness.cpp src/WallpaperEngine/Testing/Harnesses/RenderHarness.h src/WallpaperEngine/Testing/Cases/MouseCoordinates.cpp + src/WallpaperEngine/Testing/Cases/MediaSource.cpp src/WallpaperEngine/Testing/Cases/PropertyParser.cpp) endif() diff --git a/src/WallpaperEngine/Media/DBusMediaSource.cpp b/src/WallpaperEngine/Media/DBusMediaSource.cpp index 83a50f349..9a6a7a464 100644 --- a/src/WallpaperEngine/Media/DBusMediaSource.cpp +++ b/src/WallpaperEngine/Media/DBusMediaSource.cpp @@ -3,88 +3,130 @@ #include "WallpaperEngine/Data/Utils/ScopeGuard.h" #include "WallpaperEngine/Logging/Log.h" +#include +#include +#include + using namespace WallpaperEngine::Media; -DBusHandlerResult dbus_message_filter (DBusConnection* connection, DBusMessage* message, void* user_data) { - const auto mediaSource = static_cast (user_data); +namespace { +constexpr auto requestTimeout = std::chrono::milliseconds (250); +constexpr auto failedPlayerCooldown = std::chrono::seconds (10); + +bool hasSignature (DBusMessageIter& value, const char* signature) { + char* actual = dbus_message_iter_get_signature (&value); + WallpaperEngine::Data::Utils::ScopeGuard guard ([actual] { dbus_free (actual); }); + return actual && std::strcmp (actual, signature) == 0; +} - if (!dbus_message_is_signal (message, "org.freedesktop.DBus.Properties", "PropertiesChanged")) { - return DBUS_HANDLER_RESULT_NOT_YET_HANDLED; +bool propertyReply (DBusMessage* reply, const char* signature, DBusMessageIter& value) { + if (!reply || dbus_message_get_type (reply) != DBUS_MESSAGE_TYPE_METHOD_RETURN + || !dbus_message_has_signature (reply, "v")) { + return false; } + DBusMessageIter body; + dbus_message_iter_init (reply, &body); + dbus_message_iter_recurse (&body, &value); + return hasSignature (value, signature); +} +} +DBusHandlerResult DBusMediaSource::messageFilter (DBusConnection*, DBusMessage* message, void* userData) { + auto& source = *static_cast (userData); + if (dbus_message_is_signal (message, DBUS_INTERFACE_DBUS, "NameOwnerChanged") + && dbus_message_has_sender (message, DBUS_SERVICE_DBUS) + && dbus_message_has_path (message, DBUS_PATH_DBUS) + && dbus_message_has_signature (message, "sss")) { + const char* name = nullptr; + const char* previous = nullptr; + const char* owner = nullptr; + dbus_message_get_args (message, nullptr, DBUS_TYPE_STRING, &name, + DBUS_TYPE_STRING, &previous, DBUS_TYPE_STRING, &owner, DBUS_TYPE_INVALID); + if (std::string_view (name).starts_with ("org.mpris.MediaPlayer2.")) { + source.updateOwner (name, owner); + source.m_failedPlayers.erase (name); + } + return DBUS_HANDLER_RESULT_HANDLED; + } + if (!dbus_message_is_signal (message, "org.freedesktop.DBus.Properties", "PropertiesChanged") + || !dbus_message_has_path (message, "/org/mpris/MediaPlayer2") + || !dbus_message_has_signature (message, "sa{sv}as")) { + return DBUS_HANDLER_RESULT_NOT_YET_HANDLED; + } + const char* sender = dbus_message_get_sender (message); + if (!sender || source.isExcluded (sender) || source.serviceForOwner (sender).empty ()) { + return DBUS_HANDLER_RESULT_NOT_YET_HANDLED; + } + DBusMessageIter body; + dbus_message_iter_init (message, &body); const char* interface = nullptr; - DBusMessageIter iter; - - dbus_message_iter_init (message, &iter); - dbus_message_iter_get_basic (&iter, &interface); - - std::string iface = interface ?: ""; - - if (iface != "org.mpris.MediaPlayer2.Player") { - return DBUS_HANDLER_RESULT_NOT_YET_HANDLED; + dbus_message_iter_get_basic (&body, &interface); + if (std::strcmp (interface, "org.mpris.MediaPlayer2.Player") != 0) { + return DBUS_HANDLER_RESULT_NOT_YET_HANDLED; } - + dbus_message_iter_next (&body); DBusMessageIter changed; - - dbus_message_iter_next (&iter); - dbus_message_iter_recurse (&iter, &changed); - + dbus_message_iter_recurse (&body, &changed); while (dbus_message_iter_get_arg_type (&changed) == DBUS_TYPE_DICT_ENTRY) { - DBusMessageIter entry; - dbus_message_iter_recurse (&changed, &entry); - - const char* key = nullptr; - dbus_message_iter_get_basic (&entry, &key); - - std::string keyStr = key ?: ""; - - DBusMessageIter value; - dbus_message_iter_next (&entry); - dbus_message_iter_recurse (&entry, &value); - - if (keyStr == "Metadata") { - mediaSource->parseMetadata (value); - } else if (keyStr == "PlaybackStatus") { - mediaSource->parsePlaybackStatus (value, dbus_message_get_sender (message)); - } - - dbus_message_iter_next (&changed); + DBusMessageIter entry, value; + dbus_message_iter_recurse (&changed, &entry); + const char* key = nullptr; + dbus_message_iter_get_basic (&entry, &key); + dbus_message_iter_next (&entry); + dbus_message_iter_recurse (&entry, &value); + if (std::strcmp (key, "PlaybackStatus") == 0) { + source.parsePlaybackStatus (value, sender); + } else if (std::strcmp (key, "Metadata") == 0 + && source.m_currentPlayer == sender + && hasSignature (value, "a{sv}")) { + if (source.m_request == Request::Metadata) { + source.cancelPending (); + } + source.parseMetadata (value); + source.m_metadataNeeded = false; + } + dbus_message_iter_next (&changed); } - return DBUS_HANDLER_RESULT_HANDLED; } DBusMediaSource::DBusMediaSource (std::chrono::milliseconds updateInterval) : MediaSource (updateInterval) { DBusError err; - dbus_error_init (&err); + Data::Utils::ScopeGuard errorGuard ([&err] { dbus_error_free (&err); }); - this->m_connection = dbus_bus_get (DBUS_BUS_SESSION, &err); - - if (!this->m_connection) { - sLog.exception ("Could not connect to DBus: ", err.message); + // Registration is startup-only. Private ownership prevents other users of + // libdbus in this process from blocking this connection's I/O mutex. + m_connection = dbus_bus_get_private (DBUS_BUS_SESSION, &err); + if (!m_connection) { + sLog.exception ("Could not connect to DBus: ", err.message); } - - dbus_connection_add_filter (this->m_connection, dbus_message_filter, this, nullptr); - - dbus_bus_add_match ( - this->m_connection, "type='signal',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged'", - nullptr - ); - - dbus_connection_flush (this->m_connection); - - this->detectPlayer (); - this->initialStatusFetch (); + dbus_connection_set_exit_on_disconnect (m_connection, false); + if (!dbus_connection_add_filter (m_connection, messageFilter, this, nullptr)) { + dbus_connection_close (m_connection); + dbus_connection_unref (m_connection); + m_connection = nullptr; + sLog.exception ("Cannot allocate DBus media filter"); + } + // A null error queues AddMatch without waiting. update() pumps transmission. + dbus_bus_add_match (m_connection, + "type='signal',path='/org/mpris/MediaPlayer2',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged'", nullptr); + dbus_bus_add_match (m_connection, + "type='signal',sender='org.freedesktop.DBus',path='/org/freedesktop/DBus'," + "interface='org.freedesktop.DBus',member='NameOwnerChanged',arg0namespace='org.mpris.MediaPlayer2'", nullptr); } DBusMediaSource::~DBusMediaSource () { - dbus_connection_remove_filter (this->m_connection, dbus_message_filter, this); - - dbus_connection_unref (this->m_connection); + cancelPending (); + dbus_connection_remove_filter (m_connection, messageFilter, this); + dbus_connection_close (m_connection); + dbus_connection_unref (m_connection); } void DBusMediaSource::parseMetadata (DBusMessageIter& variant) { + if (!hasSignature (variant, "a{sv}")) { + return; + } DBusMessageIter dict; dbus_message_iter_recurse (&variant, &dict); @@ -104,7 +146,7 @@ void DBusMediaSource::parseMetadata (DBusMessageIter& variant) { dbus_message_iter_next (&entry); dbus_message_iter_recurse (&entry, &value); - if (keyStr == "xesam:title") { + if (keyStr == "xesam:title" && dbus_message_iter_get_arg_type (&value) == DBUS_TYPE_STRING) { const char* title = nullptr; dbus_message_iter_get_basic (&value, &title); @@ -114,7 +156,7 @@ void DBusMediaSource::parseMetadata (DBusMessageIter& variant) { this->m_mediaInfo.title = titleStr; metadataUpdate = true; } - } else if (keyStr == "xesam:artist") { + } else if (keyStr == "xesam:artist" && hasSignature (value, "as")) { DBusMessageIter arr; dbus_message_iter_recurse (&value, &arr); @@ -130,7 +172,7 @@ void DBusMediaSource::parseMetadata (DBusMessageIter& variant) { metadataUpdate = true; } } - } else if (keyStr == "xesam:album") { + } else if (keyStr == "xesam:album" && dbus_message_iter_get_arg_type (&value) == DBUS_TYPE_STRING) { const char* album = nullptr; dbus_message_iter_get_basic (&value, &album); @@ -140,7 +182,7 @@ void DBusMediaSource::parseMetadata (DBusMessageIter& variant) { this->m_mediaInfo.album = albumStr; albumUpdate = true; } - } else if (keyStr == "mpris:artUrl") { + } else if (keyStr == "mpris:artUrl" && dbus_message_iter_get_arg_type (&value) == DBUS_TYPE_STRING) { const char* artUrl = nullptr; dbus_message_iter_get_basic (&value, &artUrl); @@ -156,7 +198,7 @@ void DBusMediaSource::parseMetadata (DBusMessageIter& variant) { this->m_mediaInfo.url = artUrlStr; albumUpdate = true; } - } else if (keyStr == "mpris:length") { + } else if (keyStr == "mpris:length" && dbus_message_iter_get_arg_type (&value) == DBUS_TYPE_INT64) { int64_t length = 0; dbus_message_iter_get_basic (&value, &length); @@ -184,187 +226,332 @@ void DBusMediaSource::parseMetadata (DBusMessageIter& variant) { } void DBusMediaSource::parsePlaybackStatus (DBusMessageIter& variant, const char* sender) { + if (!sender || isExcluded (sender) || dbus_message_iter_get_arg_type (&variant) != DBUS_TYPE_STRING) { + return; + } + const auto service = serviceForOwner (sender); + if (service.empty ()) { + return; + } const char* status = nullptr; dbus_message_iter_get_basic (&variant, &status); - std::string statusStr = status ?: ""; - PlaybackState newState = this->m_mediaInfo.playbackState; - - if (statusStr == "Playing") { - if (sender != nullptr) { - this->m_currentPlayer = sender; - } - - newState = PlaybackState::Playing; - } else if (statusStr == "Paused") { - newState = PlaybackState::Paused; + PlaybackState newState; + if (std::strcmp (status, "Playing") == 0) { + selectPlayer (sender, service); + newState = PlaybackState::Playing; + } else if (m_currentPlayer != sender) { + return; + } else if (std::strcmp (status, "Paused") == 0) { + newState = PlaybackState::Paused; + } else if (std::strcmp (status, "Stopped") == 0) { + newState = PlaybackState::Stopped; } else { - newState = PlaybackState::Stopped; + return; } - - if (newState != this->m_mediaInfo.playbackState) { - this->m_mediaInfo.playbackState = newState; - this->fireMetadataListeners (); + if (newState != m_mediaInfo.playbackState) { + m_mediaInfo.playbackState = newState; + fireMetadataListeners (); } } void DBusMediaSource::parsePosition (DBusMessageIter& variant) { - int64_t position = 0; + if (dbus_message_iter_get_arg_type (&variant) != DBUS_TYPE_INT64) { + return; + } + dbus_int64_t position = 0; dbus_message_iter_get_basic (&variant, &position); - - if (this->m_mediaInfo.position != position) { - this->m_mediaInfo.position = position; - this->fireMetadataListeners (); + if (m_mediaInfo.position != position) { + m_mediaInfo.position = position; + fireMetadataListeners (); } } void DBusMediaSource::update () { - this->MediaSource::update (); - - // drain any dbus events - dbus_connection_read_write (this->m_connection, 0); + const auto now = Clock::now (); + for (auto it = m_failedPlayers.begin (); it != m_failedPlayers.end ();) { + if (it->second <= now) { + it = m_failedPlayers.erase (it); + } else { + ++it; + } + } - while (dbus_connection_dispatch (this->m_connection) == DBUS_DISPATCH_DATA_REMAINS) - ; + // A frame pause can outlast the deadline while a reply is waiting in the socket. + dbus_connection_read_write (m_connection, 0); + for (int dispatched = 0; dispatched < 16 + && dbus_connection_get_dispatch_status (m_connection) == DBUS_DISPATCH_DATA_REMAINS; ++dispatched) { + dbus_connection_dispatch (m_connection); + // Apply replies before later signals, but let queued replies precede timeout handling. + if (m_pending && dbus_pending_call_get_completed (m_pending)) { + pollPending (); + } + } + pollPending (); + MediaSource::update (); } -DBusMessage* DBusMediaSource::dbusMessage ( - const char* bus_name, const char* path, const char* interface, const char* method, const char* iface, - const char* prop -) { - DBusError err; - dbus_error_init (&err); - - DBusMessage* msg = dbus_message_new_method_call (bus_name, path, interface, method); - Data::Utils::ScopeGuard guard ([msg] { dbus_message_unref (msg); }); - - if (iface != nullptr && prop != nullptr) { - dbus_message_append_args (msg, DBUS_TYPE_STRING, &iface, DBUS_TYPE_STRING, &prop, DBUS_TYPE_INVALID); +bool DBusMediaSource::startRequest (Request request, const std::string& player, const char* property) { + // Canceling a pending call does not remove its queued message. Do not build + // another queue of requests if the bus stops reading. + if (m_pending || !dbus_connection_get_is_connected (m_connection) + || dbus_connection_has_messages_to_send (m_connection)) { + return false; } - DBusMessage* reply = dbus_connection_send_with_reply_and_block (m_connection, msg, -1, &err); - - if (reply == nullptr) { - sLog.error ("DBus error: ", err.message, " (", err.name, ")"); - return nullptr; + const bool busRequest = request == Request::Players || request == Request::Owner; + DBusMessage* message = busRequest + ? dbus_message_new_method_call (DBUS_SERVICE_DBUS, DBUS_PATH_DBUS, DBUS_INTERFACE_DBUS, + request == Request::Players ? "ListNames" : "GetNameOwner") + : dbus_message_new_method_call (player.c_str (), "/org/mpris/MediaPlayer2", + "org.freedesktop.DBus.Properties", "Get"); + if (!message) { + return false; + } + Data::Utils::ScopeGuard messageGuard ([message] { dbus_message_unref (message); }); + if (request == Request::Owner) { + const char* name = player.c_str (); + if (!dbus_message_append_args (message, DBUS_TYPE_STRING, &name, DBUS_TYPE_INVALID)) { + return false; + } + } else if (!busRequest) { + const char* interface = "org.mpris.MediaPlayer2.Player"; + if (!property || !dbus_message_append_args (message, DBUS_TYPE_STRING, &interface, + DBUS_TYPE_STRING, &property, DBUS_TYPE_INVALID)) { + return false; + } } - return reply; + const auto started = Clock::now (); + if (!dbus_connection_send_with_reply (m_connection, message, &m_pending, requestTimeout.count ()) + || !m_pending) { + cancelPending (); + return false; + } + m_request = request; + m_requestPlayer = player; + m_deadline = started + requestTimeout; + dbus_connection_read_write (m_connection, 0); + return true; } -void DBusMediaSource::detectPlayer () { - DBusMessage* reply - = this->dbusMessage ("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ListNames"); - - if (reply == nullptr) { - return; +void DBusMediaSource::cancelPending () { + if (m_pending) { + dbus_pending_call_cancel (m_pending); + dbus_pending_call_unref (m_pending); + m_pending = nullptr; } + m_request = Request::None; + m_requestPlayer.clear (); +} - Data::Utils::ScopeGuard guard ([reply] { dbus_message_unref (reply); }); - std::vector players; - DBusMessageIter iter; - dbus_message_iter_init (reply, &iter); - - if (dbus_message_iter_get_arg_type (&iter) != DBUS_TYPE_ARRAY) { - return; +void DBusMediaSource::pollPending () { + if (!m_pending) { + return; + } + const bool completed = dbus_pending_call_get_completed (m_pending); + if (!completed && Clock::now () < m_deadline) { + return; } - DBusMessageIter array; - dbus_message_iter_recurse (&iter, &array); - - while (dbus_message_iter_get_arg_type (&array) != DBUS_TYPE_INVALID) { - char* name; - dbus_message_iter_get_basic (&array, &name); - - std::string service = name; - - if (service.starts_with ("org.mpris.MediaPlayer2.")) { - players.push_back (service); - } + const auto request = m_request; + const auto player = m_requestPlayer; + DBusMessage* reply = completed ? dbus_pending_call_steal_reply (m_pending) : nullptr; + cancelPending (); + Data::Utils::ScopeGuard replyGuard ([reply] { + if (reply) { + dbus_message_unref (reply); + } + }); + completeRequest (request, player, reply); +} - dbus_message_iter_next (&array); +void DBusMediaSource::completeRequest (Request request, const std::string& player, DBusMessage* reply) { + DBusMessageIter value; + if (request == Request::Players) { + m_candidates.clear (); + m_candidateIndex = 0; + if (!reply || dbus_message_get_type (reply) != DBUS_MESSAGE_TYPE_METHOD_RETURN + || !dbus_message_has_signature (reply, "as")) { + return; + } + DBusMessageIter body; + dbus_message_iter_init (reply, &body); + dbus_message_iter_recurse (&body, &value); + while (dbus_message_iter_get_arg_type (&value) == DBUS_TYPE_STRING) { + const char* name = nullptr; + dbus_message_iter_get_basic (&value, &name); + if (std::string_view (name).starts_with ("org.mpris.MediaPlayer2.")) { + m_candidates.emplace_back (name); + } + dbus_message_iter_next (&value); + } + std::erase_if (m_playerOwners, [this] (const auto& entry) { + return std::find (m_candidates.begin (), m_candidates.end (), entry.first) == m_candidates.end (); + }); + resetUnownedPlayer (); + nextOwner (); + } else if (request == Request::Owner) { + if (reply && dbus_message_has_sender (reply, DBUS_SERVICE_DBUS)) { + if (dbus_message_get_type (reply) == DBUS_MESSAGE_TYPE_METHOD_RETURN + && dbus_message_has_signature (reply, "s")) { + const char* owner = nullptr; + dbus_message_get_args (reply, nullptr, DBUS_TYPE_STRING, &owner, DBUS_TYPE_INVALID); + updateOwner (player, owner); + } else if (dbus_message_is_error (reply, DBUS_ERROR_NAME_HAS_NO_OWNER)) { + updateOwner (player, {}); + } + } + nextOwner (); + } else if (request == Request::Status) { + if (propertyReply (reply, "s", value)) { + const char* status = nullptr; + dbus_message_iter_get_basic (&value, &status); + const char* owner = dbus_message_get_sender (reply); + const auto known = m_playerOwners.find (player); + if (std::strcmp (status, "Playing") == 0 && owner && known != m_playerOwners.end () + && known->second == owner + && !isExcluded (player) && !isExcluded (owner)) { + selectPlayer (owner, player); + parsePlaybackStatus (value, owner); + pollPlayer (); + return; + } + } else { + m_failedPlayers[player] = Clock::now () + failedPlayerCooldown; + } + nextCandidate (); + } else if (m_currentPlayer == player) { + if (request == Request::Metadata) { + if (propertyReply (reply, "a{sv}", value)) { + parseMetadata (value); + m_metadataNeeded = false; + } + // Keep metadata pending after an error, but also check player health. + startRequest (Request::Position, player, "Position"); + } else if (request == Request::Position) { + if (propertyReply (reply, "x", value)) { + parsePosition (value); + } else { + failPlayer (); + } + } } +} - if (players.empty ()) { - return; +void DBusMediaSource::nextOwner () { + if (m_candidateIndex < m_candidates.size ()) { + if (!startRequest (Request::Owner, m_candidates[m_candidateIndex++])) { + m_candidates.clear (); + } + return; } - - for (const auto& player : players) { - // also get playback status - reply = this->dbusMessage ( - player.c_str (), "/org/mpris/MediaPlayer2", "org.freedesktop.DBus.Properties", "Get", - "org.mpris.MediaPlayer2.Player", "PlaybackStatus" - ); - - if (reply == nullptr) { - return; - } - - Data::Utils::ScopeGuard guard2 ([reply] { dbus_message_unref (reply); }); - - DBusMessageIter outer; - dbus_message_iter_init (reply, &outer); - DBusMessageIter variant; - dbus_message_iter_recurse (&outer, &variant); - - this->parsePlaybackStatus (variant, player.c_str ()); - - if (this->m_currentPlayer.has_value ()) { - break; - } + m_candidateIndex = 0; + if (m_currentPlayer) { + m_candidates.clear (); + pollPlayer (); + } else { + nextCandidate (); } } -void DBusMediaSource::initialStatusFetch () { - if (this->m_currentPlayer.has_value () == false) { - return; +void DBusMediaSource::nextCandidate () { + while (m_candidateIndex < m_candidates.size ()) { + const auto player = m_candidates[m_candidateIndex++]; + const auto owner = m_playerOwners.find (player); + if (isExcluded (player) || owner == m_playerOwners.end () || isExcluded (owner->second)) { + continue; + } + if (!startRequest (Request::Status, player, "PlaybackStatus")) { + m_candidates.clear (); + } + return; } + m_candidates.clear (); +} - DBusMessage* reply = this->dbusMessage ( - this->m_currentPlayer.value ().c_str (), "/org/mpris/MediaPlayer2", "org.freedesktop.DBus.Properties", "Get", - "org.mpris.MediaPlayer2.Player", "Metadata" - ); - - if (reply == nullptr) { - return; +void DBusMediaSource::pollPlayer () { + if (m_currentPlayer) { + startRequest (m_metadataNeeded ? Request::Metadata : Request::Position, *m_currentPlayer, + m_metadataNeeded ? "Metadata" : "Position"); } +} - Data::Utils::ScopeGuard guard ([reply] { dbus_message_unref (reply); }); - DBusMessageIter outer; - dbus_message_iter_init (reply, &outer); - - DBusMessageIter variant; - dbus_message_iter_recurse (&outer, &variant); - - this->parseMetadata (variant); +void DBusMediaSource::selectPlayer (const std::string& player, const std::string& service) { + if (m_currentPlayer == player) { + return; + } + // A valid signal may select a player while the initial owner inventory is still being read. + if (m_request != Request::Players && m_request != Request::Owner) { + cancelPending (); + m_candidates.clear (); + } + m_currentPlayer = player; + m_currentService = service; + m_metadataNeeded = true; } -void DBusMediaSource::performUpdate () { - // nothing to do if no player is detected - if (!this->m_currentPlayer.has_value ()) { - return; +void DBusMediaSource::failPlayer () { + const auto retryAt = Clock::now () + failedPlayerCooldown; + if (m_currentPlayer) { + m_failedPlayers[*m_currentPlayer] = retryAt; + } + if (!m_currentService.empty ()) { + m_failedPlayers[m_currentService] = retryAt; } + m_currentPlayer.reset (); + m_currentService.clear (); + m_metadataNeeded = true; +} - DBusMessage* reply = this->dbusMessage ( - this->m_currentPlayer.value ().c_str (), "/org/mpris/MediaPlayer2", "org.freedesktop.DBus.Properties", "Get", - "org.mpris.MediaPlayer2.Player", "Position" - ); +bool DBusMediaSource::isExcluded (const std::string& player) const { + const auto it = m_failedPlayers.find (player); + return it != m_failedPlayers.end () && Clock::now () < it->second; +} - if (reply == nullptr) { - return; +std::string DBusMediaSource::serviceForOwner (const std::string& owner) const { + for (const auto& [service, unique] : m_playerOwners) { + if (unique == owner) { + return service; + } } + return {}; +} - Data::Utils::ScopeGuard guard ([reply] { dbus_message_unref (reply); }); - DBusMessageIter outer; - dbus_message_iter_init (reply, &outer); - - DBusMessageIter variant; - dbus_message_iter_recurse (&outer, &variant); +void DBusMediaSource::updateOwner (const std::string& service, const std::string& owner) { + if (!owner.empty () && owner.front () == ':') { + m_playerOwners[service] = owner; + } else { + m_playerOwners.erase (service); + } + resetUnownedPlayer (); +} - dbus_int64_t position = 0; - dbus_message_iter_get_basic (&variant, &position); +void DBusMediaSource::resetUnownedPlayer () { + if (!m_currentPlayer) { + return; + } + m_currentService = serviceForOwner (*m_currentPlayer); + if (!m_currentService.empty ()) { + return; + } + if (m_request == Request::Metadata || m_request == Request::Position) { + cancelPending (); + } + m_currentPlayer.reset (); + m_metadataNeeded = true; + if (m_mediaInfo.playbackState != PlaybackState::Stopped) { + m_mediaInfo.playbackState = PlaybackState::Stopped; + fireMetadataListeners (); + } +} - if (this->m_mediaInfo.position != position) { - this->m_mediaInfo.position = position; - this->fireMetadataListeners (); +void DBusMediaSource::performUpdate () { + if (m_pending) { + return; } -} \ No newline at end of file + if (m_currentPlayer) { + pollPlayer (); + } else { + startRequest (Request::Players, {}); + } +} diff --git a/src/WallpaperEngine/Media/DBusMediaSource.h b/src/WallpaperEngine/Media/DBusMediaSource.h index 8bc6a1809..86075fcc5 100644 --- a/src/WallpaperEngine/Media/DBusMediaSource.h +++ b/src/WallpaperEngine/Media/DBusMediaSource.h @@ -2,6 +2,7 @@ #include "MediaSource.h" #include +#include namespace WallpaperEngine::Media { class DBusMediaSource : public MediaSource { @@ -12,21 +13,43 @@ class DBusMediaSource : public MediaSource { void parseMetadata (DBusMessageIter& variant); void parsePlaybackStatus (DBusMessageIter& variant, const char* sender); void parsePosition (DBusMessageIter& variant); - void update () override; protected: - void performUpdate () override; - void initialStatusFetch (); - void detectPlayer (); - - DBusMessage* dbusMessage ( - const char* bus_name, const char* path, const char* interface, const char* method, const char* iface = nullptr, - const char* prop = nullptr - ); + enum class Request { None, Players, Owner, Status, Metadata, Position }; + using Clock = std::chrono::steady_clock; - std::optional m_currentPlayer = std::nullopt; - - DBusConnection* m_connection; + void performUpdate () override; + /** Queue one request without flushing; its deadline starts before enqueueing. */ + bool startRequest (Request request, const std::string& player, const char* property = nullptr); + /** Complete or cancel work from update(); never wait for a reply. */ + void pollPending (); + void cancelPending (); + void completeRequest (Request request, const std::string& player, DBusMessage* reply); + void nextOwner (); + void nextCandidate (); + void pollPlayer (); + void selectPlayer (const std::string& player, const std::string& service = {}); + void failPlayer (); + bool isExcluded (const std::string& player) const; + std::string serviceForOwner (const std::string& owner) const; + void updateOwner (const std::string& service, const std::string& owner); + void resetUnownedPlayer (); + + static DBusHandlerResult messageFilter (DBusConnection*, DBusMessage*, void*); + + std::optional m_currentPlayer; + std::string m_currentService; + bool m_metadataNeeded = true; + std::map m_failedPlayers; + std::map m_playerOwners; + std::vector m_candidates; + std::size_t m_candidateIndex = 0; + + DBusConnection* m_connection = nullptr; + DBusPendingCall* m_pending = nullptr; + Request m_request = Request::None; + std::string m_requestPlayer; + Clock::time_point m_deadline; }; -} \ No newline at end of file +} diff --git a/src/WallpaperEngine/Media/MediaSource.cpp b/src/WallpaperEngine/Media/MediaSource.cpp index f3c612691..14da7b27a 100644 --- a/src/WallpaperEngine/Media/MediaSource.cpp +++ b/src/WallpaperEngine/Media/MediaSource.cpp @@ -28,10 +28,12 @@ MediaSource::~MediaSource () { } void MediaSource::update () { - if (std::chrono::steady_clock::now () <= m_nextUpdate) { + const auto now = std::chrono::steady_clock::now (); + if (now < m_nextUpdate) { return; } + m_nextUpdate = now + m_updateInterval; this->performUpdate (); } @@ -77,4 +79,4 @@ void MediaSource::fireAlbumArtListeners () { for (auto& listener : m_albumArtListeners | std::views::values) { listener (m_mediaInfo); } -} \ No newline at end of file +} diff --git a/src/WallpaperEngine/Media/MediaSource.h b/src/WallpaperEngine/Media/MediaSource.h index 300a30caa..b6866e46a 100644 --- a/src/WallpaperEngine/Media/MediaSource.h +++ b/src/WallpaperEngine/Media/MediaSource.h @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include namespace WallpaperEngine::Media { @@ -59,4 +61,4 @@ class MediaSource { std::chrono::milliseconds m_updateInterval; std::chrono::time_point m_nextUpdate; }; -} \ No newline at end of file +} diff --git a/src/WallpaperEngine/Testing/Cases/MediaSource.cpp b/src/WallpaperEngine/Testing/Cases/MediaSource.cpp new file mode 100644 index 000000000..d780a0f32 --- /dev/null +++ b/src/WallpaperEngine/Testing/Cases/MediaSource.cpp @@ -0,0 +1,392 @@ +#include + +#include "WallpaperEngine/Media/MediaSource.h" +#include "WallpaperEngine/Media/DBusMediaSource.h" + +#include +#include +#include + +namespace { +class CountingSource : public WallpaperEngine::Media::MediaSource { +public: + CountingSource () : MediaSource (std::chrono::hours (1)) { } + + int calls = 0; + + void expireInterval () { m_nextUpdate = std::chrono::steady_clock::now () - std::chrono::seconds (1); } + +protected: + void performUpdate () override { ++calls; } +}; +} + +TEST_CASE ("Media sources poll once per interval and resume after it expires") { + CountingSource source; + source.update (); + REQUIRE (source.calls == 1); + + for (int frame = 0; frame < 1000; ++frame) { + source.update (); + } + CHECK (source.calls == 1); + + source.expireInterval (); + source.update (); + CHECK (source.calls == 2); + source.update (); + CHECK (source.calls == 2); +} + +namespace { +class DBusTestSource : public WallpaperEngine::Media::DBusMediaSource { +public: + DBusTestSource () : DBusMediaSource (std::chrono::hours (1)) { } + using DBusMediaSource::Request; + using DBusMediaSource::m_candidates; + using DBusMediaSource::m_currentPlayer; + using DBusMediaSource::m_deadline; + using DBusMediaSource::m_failedPlayers; + using DBusMediaSource::m_playerOwners; + using DBusMediaSource::m_metadataNeeded; + using DBusMediaSource::m_pending; + using DBusMediaSource::m_request; + using DBusMediaSource::m_requestPlayer; + using DBusMediaSource::isExcluded; + using DBusMediaSource::nextCandidate; + using DBusMediaSource::pollPending; + using DBusMediaSource::pollPlayer; + using DBusMediaSource::selectPlayer; + using DBusMediaSource::serviceForOwner; + using DBusMediaSource::startRequest; + + void pumpOutgoing () { + dbus_connection_read_write (m_connection, 0); + REQUIRE_FALSE (dbus_connection_has_messages_to_send (m_connection)); + } + + void prepareReplyTest () { + // A private-bus round trip drains startup traffic before polling for the test reply. + char* id = dbus_bus_get_id (m_connection, nullptr); + REQUIRE (id); + dbus_free (id); + while (dbus_connection_get_dispatch_status (m_connection) == DBUS_DISPATCH_DATA_REMAINS) { + dbus_connection_dispatch (m_connection); + } + m_nextUpdate = std::chrono::steady_clock::now () + std::chrono::hours (1); + } + + void waitForReply () { + int socket = -1; + REQUIRE (dbus_connection_get_unix_fd (m_connection, &socket)); + pollfd descriptor { socket, POLLIN, 0 }; + REQUIRE (poll (&descriptor, 1, 2000) == 1); + REQUIRE ((descriptor.revents & POLLIN) != 0); + } + + void receiveReply () { + dbus_connection_read_write (m_connection, 0); + for (int i = 0; i < 16 + && dbus_connection_get_dispatch_status (m_connection) == DBUS_DISPATCH_DATA_REMAINS; ++i) { + dbus_connection_dispatch (m_connection); + } + REQUIRE (dbus_pending_call_get_completed (m_pending)); + } + + void finish (Request request, const std::string& player, DBusMessage* reply) { + cancelPending (); + completeRequest (request, player, reply); + } +}; + +using Message = std::unique_ptr; + +Message propertyMessage (const char* signature, const char* sender) { + Message message (dbus_message_new (DBUS_MESSAGE_TYPE_METHOD_RETURN), dbus_message_unref); + REQUIRE (message); + REQUIRE (dbus_message_set_sender (message.get (), sender)); + DBusMessageIter body, value; + dbus_message_iter_init_append (message.get (), &body); + REQUIRE (dbus_message_iter_open_container (&body, DBUS_TYPE_VARIANT, signature, &value)); + if (std::strcmp (signature, "s") == 0) { + const char* status = "Playing"; + REQUIRE (dbus_message_iter_append_basic (&value, DBUS_TYPE_STRING, &status)); + } else if (std::strcmp (signature, "x") == 0) { + dbus_int64_t position = 42000000; + REQUIRE (dbus_message_iter_append_basic (&value, DBUS_TYPE_INT64, &position)); + } else { + DBusMessageIter metadata; + REQUIRE (dbus_message_iter_open_container (&value, DBUS_TYPE_ARRAY, "{sv}", &metadata)); + REQUIRE (dbus_message_iter_close_container (&value, &metadata)); + } + REQUIRE (dbus_message_iter_close_container (&body, &value)); + return message; +} + +using Connection = std::unique_ptr; + +Connection newPeer () { + Connection peer (dbus_bus_get_private (DBUS_BUS_SESSION, nullptr), [] (DBusConnection* connection) { + dbus_connection_close (connection); + dbus_connection_unref (connection); + }); + REQUIRE (peer); + dbus_connection_set_exit_on_disconnect (peer.get (), false); + return peer; +} + +void waitForDelivery (DBusTestSource& source, DBusConnection* peer) { + // Test-only barrier: the bus routes previous messages before answering this round trip. + char* id = dbus_bus_get_id (peer, nullptr); + REQUIRE (id); + dbus_free (id); + source.waitForReply (); +} + +Message playbackSignal (const char* status) { + Message message (dbus_message_new_signal ("/org/mpris/MediaPlayer2", + "org.freedesktop.DBus.Properties", "PropertiesChanged"), dbus_message_unref); + REQUIRE (message); + DBusMessageIter body, changed, entry, value, invalidated; + dbus_message_iter_init_append (message.get (), &body); + const char* interface = "org.mpris.MediaPlayer2.Player"; + const char* key = "PlaybackStatus"; + REQUIRE (dbus_message_iter_append_basic (&body, DBUS_TYPE_STRING, &interface)); + REQUIRE (dbus_message_iter_open_container (&body, DBUS_TYPE_ARRAY, "{sv}", &changed)); + REQUIRE (dbus_message_iter_open_container (&changed, DBUS_TYPE_DICT_ENTRY, nullptr, &entry)); + REQUIRE (dbus_message_iter_append_basic (&entry, DBUS_TYPE_STRING, &key)); + REQUIRE (dbus_message_iter_open_container (&entry, DBUS_TYPE_VARIANT, "s", &value)); + REQUIRE (dbus_message_iter_append_basic (&value, DBUS_TYPE_STRING, &status)); + REQUIRE (dbus_message_iter_close_container (&entry, &value)); + REQUIRE (dbus_message_iter_close_container (&changed, &entry)); + REQUIRE (dbus_message_iter_close_container (&body, &changed)); + REQUIRE (dbus_message_iter_open_container (&body, DBUS_TYPE_ARRAY, "s", &invalidated)); + REQUIRE (dbus_message_iter_close_container (&body, &invalidated)); + return message; +} + +Connection readyPropertyReply (DBusTestSource& source, DBusTestSource::Request requestKind, + const char* status = nullptr, bool signalFirst = false) { + auto player = newPeer (); + source.prepareReplyTest (); + const char* owner = dbus_bus_get_unique_name (player.get ()); + static unsigned int sequence = 0; + const auto service = "org.mpris.MediaPlayer2.regression" + std::to_string (++sequence); + REQUIRE (dbus_bus_request_name (player.get (), service.c_str (), DBUS_NAME_FLAG_DO_NOT_QUEUE, nullptr) + == DBUS_REQUEST_NAME_REPLY_PRIMARY_OWNER); + if (requestKind == DBusTestSource::Request::Position) { + source.selectPlayer (owner, service); + source.m_metadataNeeded = false; + source.pollPlayer (); + } else if (requestKind == DBusTestSource::Request::Players) { + source.prepareReplyTest (); + source.m_playerOwners.clear (); // Require startup GetNameOwner discovery, not the earlier signal. + REQUIRE (source.startRequest (requestKind, {})); + } else { + REQUIRE (source.startRequest (requestKind, service, "PlaybackStatus")); + } + REQUIRE (source.m_pending); + + Message request (nullptr, dbus_message_unref); + const auto until = std::chrono::steady_clock::now () + std::chrono::seconds (2); + while (std::chrono::steady_clock::now () < until) { + source.update (); + dbus_connection_read_write (player.get (), 50); + request.reset (dbus_connection_pop_message (player.get ())); + if (request && dbus_message_is_method_call (request.get (), "org.freedesktop.DBus.Properties", "Get")) { + break; + } + request.reset (); + } + REQUIRE (request); + auto reply = propertyMessage (requestKind == DBusTestSource::Request::Position ? "x" : "s", owner); + REQUIRE (dbus_message_set_reply_serial (reply.get (), dbus_message_get_serial (request.get ()))); + REQUIRE (dbus_message_set_destination (reply.get (), dbus_message_get_sender (request.get ()))); + if (status && signalFirst) { + auto signal = playbackSignal (status); + REQUIRE (dbus_connection_send (player.get (), signal.get (), nullptr)); + } + REQUIRE (dbus_connection_send (player.get (), reply.get (), nullptr)); + if (status && !signalFirst) { + auto signal = playbackSignal (status); + REQUIRE (dbus_connection_send (player.get (), signal.get (), nullptr)); + } + waitForDelivery (source, player.get ()); + return player; +} +} + +// Explicit opt-in prevents the fixture from connecting to a desktop session bus. +// Run with: dbus-run-session -- env LWE_TEST_PRIVATE_BUS=1 ./tests "[dbus-media]" +TEST_CASE ("D-Bus media requests recover without waiting for replies", "[.][dbus-media]") { + const char* isolated = std::getenv ("LWE_TEST_PRIVATE_BUS"); + REQUIRE (isolated); + REQUIRE (std::strcmp (isolated, "1") == 0); + DBusTestSource source; + source.pumpOutgoing (); + using Request = DBusTestSource::Request; + const std::string playerA = ":1.101"; + const std::string playerB = ":1.102"; + const std::string serviceA = "org.mpris.MediaPlayer2.testA"; + const std::string serviceB = "org.mpris.MediaPlayer2.testB"; + + SECTION ("An incomplete request expires without waiting for its reply") { + REQUIRE (source.startRequest (Request::Players, {})); + REQUIRE (source.m_pending); + CHECK_FALSE (source.startRequest (Request::Players, {})); + source.m_deadline = std::chrono::steady_clock::now () - std::chrono::seconds (1); + source.pollPending (); + CHECK (source.m_pending == nullptr); + CHECK (source.m_request == Request::None); + } + + SECTION ("An unrelated peer cannot select itself or invent a bus ownership announcement") { + auto peer = newPeer (); + source.prepareReplyTest (); + Message forged (dbus_message_new_signal (DBUS_PATH_DBUS, DBUS_INTERFACE_DBUS, "NameOwnerChanged"), + dbus_message_unref); + REQUIRE (forged); + const char* service = "org.mpris.MediaPlayer2.forged"; + const char* previous = ""; + const char* owner = dbus_bus_get_unique_name (peer.get ()); + REQUIRE (dbus_message_append_args (forged.get (), DBUS_TYPE_STRING, &service, + DBUS_TYPE_STRING, &previous, DBUS_TYPE_STRING, &owner, DBUS_TYPE_INVALID)); + REQUIRE (dbus_connection_send (peer.get (), forged.get (), nullptr)); + auto signal = playbackSignal ("Playing"); + REQUIRE (dbus_connection_send (peer.get (), signal.get (), nullptr)); + waitForDelivery (source, peer.get ()); + source.update (); + CHECK_FALSE (source.m_currentPlayer.has_value ()); + CHECK (source.m_playerOwners.empty ()); + CHECK (source.m_pending == nullptr); + CHECK (source.getMediaInfo ().playbackState == WallpaperEngine::Media::MediaSource::Stopped); + } + + SECTION ("Startup discovery resolves the owner before accepting its status reply") { + auto player = readyPropertyReply (source, Request::Players); + const char* owner = dbus_bus_get_unique_name (player.get ()); + REQUIRE_FALSE (source.serviceForOwner (owner).empty ()); + REQUIRE_FALSE (source.m_currentPlayer.has_value ()); + source.update (); + CHECK (source.m_currentPlayer == owner); + CHECK (source.getMediaInfo ().playbackState == WallpaperEngine::Media::MediaSource::Playing); + } + + SECTION ("Losing an MPRIS name revokes the old peer and accepts its replacement") { + auto player = readyPropertyReply (source, Request::Status); + source.update (); + const char* oldOwner = dbus_bus_get_unique_name (player.get ()); + REQUIRE (source.m_currentPlayer == oldOwner); + const auto service = source.serviceForOwner (oldOwner); + REQUIRE_FALSE (service.empty ()); + REQUIRE (dbus_bus_release_name (player.get (), service.c_str (), nullptr) + == DBUS_RELEASE_NAME_REPLY_RELEASED); + auto signal = playbackSignal ("Playing"); + REQUIRE (dbus_connection_send (player.get (), signal.get (), nullptr)); + waitForDelivery (source, player.get ()); + source.update (); + CHECK_FALSE (source.m_currentPlayer.has_value ()); + CHECK (source.serviceForOwner (oldOwner).empty ()); + CHECK (source.m_pending == nullptr); + CHECK (source.getMediaInfo ().playbackState == WallpaperEngine::Media::MediaSource::Stopped); + + auto replacement = newPeer (); + REQUIRE (dbus_bus_request_name (replacement.get (), service.c_str (), DBUS_NAME_FLAG_DO_NOT_QUEUE, nullptr) + == DBUS_REQUEST_NAME_REPLY_PRIMARY_OWNER); + auto replacementSignal = playbackSignal ("Playing"); + REQUIRE (dbus_connection_send (replacement.get (), replacementSignal.get (), nullptr)); + waitForDelivery (source, replacement.get ()); + source.update (); + CHECK (source.m_currentPlayer == dbus_bus_get_unique_name (replacement.get ())); + CHECK (source.getMediaInfo ().playbackState == WallpaperEngine::Media::MediaSource::Playing); + } + + SECTION ("A completed Position reply survives an expired application deadline") { + auto player = readyPropertyReply (source, Request::Position); + source.receiveReply (); + source.m_deadline = std::chrono::steady_clock::now () - std::chrono::seconds (1); + source.pollPending (); + CHECK (source.getMediaInfo ().position == 42000000); + CHECK (source.m_currentPlayer.has_value ()); + CHECK (source.m_failedPlayers.empty ()); + CHECK (source.m_pending == nullptr); + } + + SECTION ("A buffered Position reply is read before a delayed frame applies its deadline") { + auto player = readyPropertyReply (source, Request::Position); + REQUIRE_FALSE (dbus_pending_call_get_completed (source.m_pending)); + source.m_deadline = std::chrono::steady_clock::now () - std::chrono::seconds (1); + source.update (); + CHECK (source.getMediaInfo ().position == 42000000); + CHECK (source.m_currentPlayer.has_value ()); + CHECK (source.m_failedPlayers.empty ()); + CHECK (source.m_pending == nullptr); + } + + SECTION ("Metadata is retried after failure even when Position succeeds") { + source.selectPlayer (playerA, serviceA); + source.finish (Request::Metadata, playerA, nullptr); + REQUIRE (source.m_metadataNeeded); + auto position = propertyMessage ("x", playerA.c_str ()); + source.finish (Request::Position, playerA, position.get ()); + CHECK (source.getMediaInfo ().position == 42000000); + source.pumpOutgoing (); + source.pollPlayer (); + REQUIRE (source.m_request == Request::Metadata); + auto metadata = propertyMessage ("a{sv}", playerA.c_str ()); + source.finish (Request::Metadata, playerA, metadata.get ()); + CHECK_FALSE (source.m_metadataNeeded); + CHECK (source.m_currentPlayer == playerA); + } + + SECTION ("A discovery reply is applied before a following Paused or Stopped signal") { + for (const char* status : { "Paused", "Stopped" }) { + DBusTestSource ordered; + auto player = readyPropertyReply (ordered, Request::Status, status); + ordered.update (); + CHECK (ordered.m_currentPlayer == dbus_bus_get_unique_name (player.get ())); + CHECK (ordered.getMediaInfo ().playbackState == (std::strcmp (status, "Paused") == 0 + ? WallpaperEngine::Media::MediaSource::Paused : WallpaperEngine::Media::MediaSource::Stopped)); + CHECK (ordered.m_failedPlayers.empty ()); + } + } + + SECTION ("An earlier signal does not expire a Position reply still queued behind it") { + auto player = readyPropertyReply (source, Request::Position, "Paused", true); + source.m_deadline = std::chrono::steady_clock::now () - std::chrono::seconds (1); + source.update (); + CHECK (source.getMediaInfo ().position == 42000000); + CHECK (source.getMediaInfo ().playbackState == WallpaperEngine::Media::MediaSource::Paused); + CHECK (source.m_currentPlayer.has_value ()); + CHECK (source.m_failedPlayers.empty ()); + } + + SECTION ("A failed Position excludes A and discovery reaches healthy B") { + source.m_playerOwners = { { serviceA, playerA }, { serviceB, playerB } }; + source.selectPlayer (playerA, serviceA); + source.finish (Request::Position, playerA, nullptr); + CHECK_FALSE (source.m_currentPlayer.has_value ()); + REQUIRE (source.isExcluded (playerA)); + REQUIRE (source.isExcluded (serviceA)); + source.m_candidates = { serviceA, serviceB }; + source.nextCandidate (); + REQUIRE (source.m_requestPlayer == serviceB); + auto status = propertyMessage ("s", playerB.c_str ()); + source.finish (Request::Status, serviceB, status.get ()); + CHECK (source.m_currentPlayer == playerB); + source.m_failedPlayers[playerA] = std::chrono::steady_clock::now () - std::chrono::seconds (1); + CHECK_FALSE (source.isExcluded (playerA)); + } + + SECTION ("Switching players cancels work and ignores the old metadata reply") { + source.selectPlayer (playerA, serviceA); + source.pollPlayer (); + REQUIRE (source.m_pending); + source.selectPlayer (playerB, serviceB); + CHECK (source.m_pending == nullptr); + auto metadata = propertyMessage ("a{sv}", playerA.c_str ()); + source.finish (Request::Metadata, playerA, metadata.get ()); + CHECK (source.m_currentPlayer == playerB); + CHECK (source.m_metadataNeeded); + } +}