diff --git a/lib/wp/collection-manager.c b/lib/wp/collection-manager.c new file mode 100644 index 00000000..a7e76534 --- /dev/null +++ b/lib/wp/collection-manager.c @@ -0,0 +1,907 @@ +/* WirePlumber + * + * Copyright © 2025 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#include + +#include "error.h" +#include "core.h" +#include "collection-manager.h" +#include "metadata.h" +#include "log.h" +#include "object-manager.h" + +#include "private/collection.h" + +WP_DEFINE_LOCAL_LOG_TOPIC ("wp-collection-manager") + +/*! \defgroup wpcollectionmanager WpCollectionManager */ +/*! + * \struct WpCollectionManager + * + * The WpCollectionManager class is the object charged to create and destroy + * the collections in WirePlumber. + * + * WpCollection API. + */ + +struct _WpCollectionManager +{ + WpObject parent; + + /* Props */ + gchar *metadata_name; + + /* Metadata */ + WpObjectManager *metadata_om; + GWeakRef metadata; + + /* Collections info */ + guint pending_collections; + guint failed_collections; + GHashTable *collections; + GHashTable *create_collection_tasks; + + /* Globals info */ + WpObjectManager *global_proxy_om; + GHashTable *collected_globals; +}; + +enum { + PROP_0, + PROP_METADATA_NAME, +}; + +enum { + SIGNAL_GLOBAL_COLLECTED, + SIGNAL_GLOBAL_DROPPED, + LAST_SIGNAL, +}; + +static guint signals[LAST_SIGNAL] = { 0 }; + +G_DEFINE_TYPE (WpCollectionManager, wp_collection_manager, WP_TYPE_OBJECT) + +static void +wp_collection_manager_init (WpCollectionManager * self) +{ + self->collections = g_hash_table_new_full (g_str_hash, g_str_equal, + g_free, g_object_unref); + self->create_collection_tasks = g_hash_table_new_full (g_str_hash, + g_str_equal, g_free, g_object_unref); + self->collected_globals = g_hash_table_new_full (g_direct_hash, + g_direct_equal, NULL, g_free); +} + +static void +wp_collection_manager_set_property (GObject * object, guint property_id, + const GValue * value, GParamSpec * pspec) +{ + WpCollectionManager *self = WP_COLLECTION_MANAGER (object); + + switch (property_id) { + case PROP_METADATA_NAME: + g_clear_pointer (&self->metadata_name, g_free); + self->metadata_name = g_value_dup_string (value); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); + break; + } +} + +static void +wp_collection_manager_get_property (GObject * object, guint property_id, + GValue * value, GParamSpec * pspec) +{ + WpCollectionManager *self = WP_COLLECTION_MANAGER (object); + + switch (property_id) { + case PROP_METADATA_NAME: + g_value_set_string (value, self->metadata_name); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); + break; + } +} + +enum { + STEP_LOAD = WP_TRANSITION_STEP_CUSTOM_START, +}; + +static WpObjectFeatures +wp_collection_manager_get_supported_features (WpObject * self) +{ + return WP_COLLECTION_MANAGER_LOADED; +} + +static guint +wp_collection_manager_activate_get_next_step (WpObject * self, + WpFeatureActivationTransition * transition, guint step, + WpObjectFeatures missing) +{ + g_return_val_if_fail (missing == WP_COLLECTION_MANAGER_LOADED, + WP_TRANSITION_STEP_ERROR); + + return STEP_LOAD; +} + +static void +on_global_proxy_removed (WpObjectManager *om, WpGlobalProxy *global, gpointer d) +{ + WpCollectionManager * self = WP_COLLECTION_MANAGER (d); + + /* Drop global from the collection if global is removed */ + wp_collection_manager_drop_global (self, global); +} + +static void +on_collection_activated (GObject * o, GAsyncResult *res, + WpTransition * transition) +{ + WpCollectionManager * self = wp_transition_get_source_object (transition); + g_autoptr (WpCollection) collection = WP_COLLECTION (o); + g_autoptr (GError) error = NULL; + const gchar *name; + + /* Get the task name */ + name = wp_collection_get_name (collection); + g_return_if_fail (name); + + /* Add collection to hash table if it was activated successfully */ + if (!wp_object_activate_finish (WP_OBJECT (collection), res, &error)) { + self->failed_collections++; + wp_warning_object (self, "Failed to activate collection '%s': %s", name, + error->message); + } else { + g_autoptr (WpMetadata) m = wp_collection_get_metadata (collection); + g_autoptr (WpIterator) it = NULL; + g_auto (GValue) item = G_VALUE_INIT; + + /* Collect the globals */ + it = wp_metadata_new_iterator (m, PW_ID_ANY); + for (; wp_iterator_next (it, &item); g_value_unset (&item)) { + WpMetadataItem *mi = g_value_get_boxed (&item); + const gchar *key = wp_metadata_item_get_key (mi); + guint32 global_id = SPA_ID_INVALID; + + /* Parse global Id */ + if (!spa_atou32 (key, &global_id, 10)) { + wp_warning_object (self, + "Failed to parse global Id from metadata key '%s'. Ignoring...", + key); + continue; + } + + g_autoptr (WpGlobalProxy) global = wp_object_manager_lookup ( + self->global_proxy_om, WP_TYPE_GLOBAL_PROXY, + WP_CONSTRAINT_TYPE_G_PROPERTY, "bound-id", "=u", global_id, NULL); + if (global) + g_hash_table_insert (self->collected_globals, global, g_strdup (name)); + else + wp_warning_object (self, + "Could not find global with Id %u for collection %s. Not adding...", + global_id, name); + } + + /* Add the collection */ + g_hash_table_insert (self->collections, g_strdup (name), + g_object_ref (collection)); + wp_info_object (self, "Added collection '%s' successfully", name); + } + + g_return_if_fail (self->pending_collections > 0); + self->pending_collections--; + + /* Set loaded feature if no pending collections */ + if (self->pending_collections == 0) { + /* Check for errors */ + if (self->failed_collections > 0) { + wp_transition_return_error (transition, g_error_new ( + WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_OPERATION_FAILED, + "%d collections failed to activate", + self->failed_collections)); + } else { + wp_object_update_features (WP_OBJECT (self), WP_COLLECTION_MANAGER_LOADED, 0); + } + } +} + +static void +on_collection_ready (GObject * o, GAsyncResult *res, gpointer d) +{ + WpCollectionManager * self = WP_COLLECTION_MANAGER (d); + g_autoptr (WpCollection) collection = WP_COLLECTION (o); + g_autoptr (GError) error = NULL; + g_autoptr (GTask) t = NULL; + g_autofree gchar *k = NULL; + const gchar *name; + + /* Get the task name */ + name = wp_collection_get_name (collection); + g_return_if_fail (name); + + /* Get the pending task if any */ + g_hash_table_steal_extended (self->create_collection_tasks, name, + (gpointer *)&k, (gpointer *)&t); + + /* Add collection to hash table if it was activated successfully */ + name = wp_collection_get_name (collection); + if (!wp_object_activate_finish (WP_OBJECT (collection), res, &error)) { + wp_warning_object (self, "Failed to activate collection '%s': %s", name, + error->message); + if (t) + g_task_return_new_error (t, WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_INVARIANT, + "Could not add collection '%s' as its activation failed: %s", name, + error->message); + return; + } + + /* Add collection to the hash table */ + g_hash_table_insert (self->collections, g_strdup (name), + g_object_ref (collection)); + wp_info_object (self, "Added collection '%s' successfully", name); + + /* Notify pending task */ + if (t) + g_task_return_pointer (t, collection, NULL); +} + +static void +on_metadata_changed (WpMetadata *m, guint32 subject, + const gchar *key, const gchar *type, const gchar *value, gpointer d) +{ + WpCollectionManager *self = WP_COLLECTION_MANAGER (d); + g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self)); + + if (key) { + if (value) { + WpCollection *collection; + wp_info_object (self, "Adding collection '%s'", key); + collection = wp_collection_new (core, key); + wp_object_activate (WP_OBJECT (collection), WP_OBJECT_FEATURES_ALL, NULL, + on_collection_ready, self); + } else { + g_hash_table_remove (self->collections, key); + wp_info_object (self, "Removed collection '%s'", key); + } + } else { + g_hash_table_remove_all (self->collections); + wp_info_object (self, "Removed all collections"); + } +} + +static void +on_metadata_added (WpObjectManager *om, WpMetadata *m, gpointer d) +{ + WpTransition * transition = WP_TRANSITION (d); + WpCollectionManager * self = wp_transition_get_source_object (transition); + g_autoptr (WpProperties) props = NULL; + const gchar *metadata_name = NULL; + g_autoptr (WpCore) core = NULL; + g_autoptr (WpIterator) it = NULL; + g_auto (GValue) item = G_VALUE_INIT; + + /* Make sure the metadata has a name */ + props = wp_global_proxy_get_global_properties (WP_GLOBAL_PROXY (m)); + g_return_if_fail (props); + metadata_name = wp_properties_get (props, "metadata.name"); + g_return_if_fail (metadata_name); + g_return_if_fail (g_str_equal (metadata_name, self->metadata_name)); + + /* Set the metadata weak ref */ + g_weak_ref_set (&self->metadata, m); + + /* Listen for metadata changes */ + g_signal_connect_object (m, "changed", G_CALLBACK (on_metadata_changed), + self, 0); + + /* Check if there are collections, and activate them if so */ + self->pending_collections = 0; + self->failed_collections = 0; + core = wp_object_get_core (WP_OBJECT (self)); + it = wp_metadata_new_iterator (m, 0); + for (; wp_iterator_next (it, &item); g_value_unset (&item)) { + WpMetadataItem *mi = g_value_get_boxed (&item); + const gchar *key = wp_metadata_item_get_key (mi); + WpCollection *collection = wp_collection_new (core, key); + self->pending_collections++; + wp_object_activate_closure (WP_OBJECT (collection), + WP_OBJECT_FEATURES_ALL, NULL, + g_cclosure_new_object ( + (GCallback) on_collection_activated, G_OBJECT (transition))); + } + + /* Otherwise just set loaded feature */ + if (self->pending_collections == 0) + wp_object_update_features (WP_OBJECT (self), WP_COLLECTION_MANAGER_LOADED, 0); +} + +static void +on_global_proxy_om_installed (WpObjectManager *om, gpointer d) +{ + WpTransition * transition = WP_TRANSITION (d); + WpCollectionManager * self = wp_transition_get_source_object (transition); + g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self)); + + /* Install metadata object manager */ + g_clear_object (&self->metadata_om); + self->metadata_om = wp_object_manager_new (); + wp_object_manager_add_interest (self->metadata_om, WP_TYPE_METADATA, + WP_CONSTRAINT_TYPE_PW_GLOBAL_PROPERTY, + "metadata.name", "=s", self->metadata_name, + WP_CONSTRAINT_TYPE_PW_GLOBAL_PROPERTY, + "wireplumber.collection-manager", "=b", TRUE, + NULL); + wp_object_manager_request_object_features (self->metadata_om, + WP_TYPE_METADATA, WP_OBJECT_FEATURES_ALL); + g_signal_connect_object (self->metadata_om, "object-added", + G_CALLBACK (on_metadata_added), transition, 0); + wp_core_install_object_manager (core, self->metadata_om); + + wp_info_object (self, "looking for metadata object named %s", + self->metadata_name); +} + +static void +wp_collection_manager_activate_execute_step (WpObject * object, + WpFeatureActivationTransition * transition, guint step, + WpObjectFeatures missing) +{ + WpCollectionManager *self = WP_COLLECTION_MANAGER (object); + g_autoptr (WpCore) core = wp_object_get_core (object); + + switch (step) { + case STEP_LOAD: { + /* Install global proxy object manager */ + g_clear_object (&self->global_proxy_om); + self->global_proxy_om = wp_object_manager_new (); + wp_object_manager_add_interest (self->global_proxy_om, + WP_TYPE_GLOBAL_PROXY, NULL); + wp_object_manager_request_object_features (self->global_proxy_om, + WP_TYPE_GLOBAL_PROXY, WP_PIPEWIRE_OBJECT_FEATURES_MINIMAL); + g_signal_connect_object (self->global_proxy_om, "object-removed", + G_CALLBACK (on_global_proxy_removed), self, 0); + g_signal_connect_object (self->global_proxy_om, "installed", + G_CALLBACK (on_global_proxy_om_installed), transition, 0); + wp_core_install_object_manager (core, self->global_proxy_om); + break; + } + case WP_TRANSITION_STEP_ERROR: + break; + default: + g_assert_not_reached (); + } +} + +static void +finish_task (gpointer key, gpointer value, gpointer user_data) +{ + const char *collection_name = key; + GTask *t = G_TASK (value); + g_task_return_new_error (t, WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_INVARIANT, + "Deactivated collection manager before collection '%s' was created", + collection_name); +} + +static void +wp_collection_manager_deactivate (WpObject * object, WpObjectFeatures features) +{ + WpCollectionManager *self = WP_COLLECTION_MANAGER (object); + + /* Globals info */ + g_hash_table_remove_all (self->collected_globals); + g_clear_object (&self->global_proxy_om); + + /* Collections info */ + g_hash_table_foreach (self->create_collection_tasks, finish_task, NULL); + g_hash_table_remove_all (self->create_collection_tasks); + g_hash_table_remove_all (self->collections); + + /* Metadata */ + g_weak_ref_set (&self->metadata, NULL); + g_clear_object (&self->metadata_om); + + wp_object_update_features (WP_OBJECT (self), 0, WP_OBJECT_FEATURES_ALL); +} + +static void +wp_collection_manager_finalize (GObject * object) +{ + WpCollectionManager *self = WP_COLLECTION_MANAGER (object); + + /* Globals info */ + g_clear_pointer (&self->collected_globals, g_hash_table_unref); + g_clear_object (&self->global_proxy_om); + + /* Collections info */ + g_hash_table_foreach (self->create_collection_tasks, finish_task, NULL); + g_clear_pointer (&self->create_collection_tasks, g_hash_table_unref); + g_clear_pointer (&self->collections, g_hash_table_unref); + + /* Metadata */ + g_weak_ref_clear (&self->metadata); + g_clear_object (&self->metadata_om); + + /* Props */ + g_clear_pointer (&self->metadata_name, g_free); + + G_OBJECT_CLASS (wp_collection_manager_parent_class)->finalize (object); +} + +static void +wp_collection_manager_class_init (WpCollectionManagerClass * klass) +{ + GObjectClass * object_class = (GObjectClass *) klass; + WpObjectClass *wpobject_class = (WpObjectClass *) klass; + + object_class->finalize = wp_collection_manager_finalize; + object_class->set_property = wp_collection_manager_set_property; + object_class->get_property = wp_collection_manager_get_property; + + wpobject_class->get_supported_features = + wp_collection_manager_get_supported_features; + wpobject_class->activate_get_next_step = + wp_collection_manager_activate_get_next_step; + wpobject_class->activate_execute_step = + wp_collection_manager_activate_execute_step; + wpobject_class->deactivate = wp_collection_manager_deactivate; + + g_object_class_install_property (object_class, PROP_METADATA_NAME, + g_param_spec_string ("metadata-name", "metadata-name", + "The metadata object to look after", NULL, + G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS)); + + signals[SIGNAL_GLOBAL_COLLECTED] = g_signal_new ( + "global-collected", G_TYPE_FROM_CLASS (klass), G_SIGNAL_RUN_FIRST, + 0, NULL, NULL, NULL, G_TYPE_NONE, 2, WP_TYPE_GLOBAL_PROXY, G_TYPE_STRING); + + signals[SIGNAL_GLOBAL_DROPPED] = g_signal_new ( + "global-dropped", G_TYPE_FROM_CLASS (klass), G_SIGNAL_RUN_FIRST, + 0, NULL, NULL, NULL, G_TYPE_NONE, 2, WP_TYPE_GLOBAL_PROXY, G_TYPE_STRING); +} + +/*! + * \brief Creates a new WpCollectionManager object + * + * \ingroup wpcollectionmanager + * \param core the WpCore + * \param metadata_name (nullable): the name of the metadata object to + * associate with the collection manager object; NULL means the default + * "sm-collection-manager" + * \returns (transfer full): a new WpCollectionManager object + */ +WpCollectionManager * +wp_collection_manager_new (WpCore * core, const gchar * metadata_name) +{ + return g_object_new (WP_TYPE_COLLECTION_MANAGER, + "core", core, + "metadata-name", metadata_name ? metadata_name : "sm-collection-manager", + NULL); +} + +static gboolean +find_collection_manager_func (gpointer g_object, gpointer metadata_name) +{ + if (!WP_IS_COLLECTION_MANAGER (g_object)) + return FALSE; + + if (metadata_name) + return g_str_equal (((WpCollectionManager *) g_object)->metadata_name, + (gchar *) metadata_name); + else + return TRUE; +} + +/*! + * \brief Finds a registered WpCollectionManager object by its metadata name + * + * \ingroup wpcollectionmanager + * \param core the WpCore + * \param metadata_name (nullable): the name of the metadata object that the + * collection manager object is associated with; NULL returns the first + * collection manager object that is found + * \returns (transfer full) (nullable): the WpCollectionManager object, or NULL + * if not found + */ +WpCollectionManager * +wp_collection_manager_find (WpCore * core, const gchar *metadata_name) +{ + g_return_val_if_fail (WP_IS_CORE (core), NULL); + + GObject *cm = wp_core_find_object (core, + (GEqualFunc) find_collection_manager_func, metadata_name); + return cm ? WP_COLLECTION_MANAGER (cm) : NULL; +} + +/*! + * \brief Callback version of wp_collection_manager_create_collection_closure() + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param collection_name the new collection name to create + * \param cancellable (nullable): a cancellable for the async operation + * \param callback (scope async)(closure user_data): a function to call when + * the collection was created + * \param user_data data for \a callback + */ +void +wp_collection_manager_create_collection (WpCollectionManager *self, + const gchar *collection_name, GCancellable * cancellable, + GAsyncReadyCallback callback, gpointer user_data) +{ + g_return_if_fail (WP_IS_COLLECTION_MANAGER (self)); + + GClosure *closure = g_cclosure_new (G_CALLBACK (callback), user_data, NULL); + + wp_collection_manager_create_collection_closure (self, collection_name, + cancellable, closure); +} + +static void +invoke_closure (GObject * obj, GAsyncResult * res, gpointer data) +{ + GClosure *closure = data; + GValue values[2] = { G_VALUE_INIT, G_VALUE_INIT }; + g_value_init (&values[0], G_TYPE_OBJECT); + g_value_init (&values[1], G_TYPE_OBJECT); + g_value_set_object (&values[0], obj); + g_value_set_object (&values[1], res); + g_closure_invoke (closure, NULL, 2, values, NULL); + g_value_unset (&values[0]); + g_value_unset (&values[1]); + g_closure_unref (closure); +} + +/*! + * \brief Creates a new collection into the manager + * + * \note \a closure may be invoked in sync while this method is being called, + * if the collection already exists. + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param collection_name the new collection name to create + * \param cancellable (nullable): a cancellable for the async operation + * \param closure (transfer full): the closure to use when the collection was + * created + */ +void +wp_collection_manager_create_collection_closure (WpCollectionManager *self, + const gchar *collection_name, GCancellable * cancellable, GClosure *closure) +{ + g_autoptr (GTask) t = NULL; + g_autoptr (WpMetadata) m = NULL; + WpCollection *c = NULL; + g_autoptr (WpSpaJson) json_value = NULL; + + g_return_if_fail (WP_IS_COLLECTION_MANAGER (self)); + + /* Create the task from the closure */ + if (G_CLOSURE_NEEDS_MARSHAL (closure)) + g_closure_set_marshal (closure, g_cclosure_marshal_VOID__OBJECT); + t = g_task_new (self, cancellable, invoke_closure, closure); + + /* Just return if the collection already exists */ + c = g_hash_table_lookup (self->collections, collection_name); + if (c) { + g_task_return_pointer (t, c, NULL); + return; + } + + /* Make sure the new collection is not being created already */ + if (g_hash_table_contains (self->create_collection_tasks, collection_name)) { + g_task_return_new_error (t, WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_INVARIANT, + "Cannot create collection '%s' as another task is already creating it", + collection_name); + return; + } + + /* Get the metadata */ + m = g_weak_ref_get (&self->metadata); + if (!m) { + g_task_return_new_error (t, WP_DOMAIN_LIBRARY, WP_LIBRARY_ERROR_INVARIANT, + "Collection manager metadata not found, cannot create collection '%s'", + collection_name); + return; + } + + /* Request new collection */ + g_hash_table_insert (self->create_collection_tasks, + g_strdup (collection_name), g_object_ref (t)); + json_value = wp_spa_json_new_boolean (TRUE); + wp_metadata_set (m, 0, collection_name, "Spa:String:JSON", + wp_spa_json_get_data (json_value)); +} + +/*! + * \brief Finishes the async operation that was started with + * wp_collection_manager_create_collection() + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param res the async operation result + * \param error (out) (optional): the error of the operation, if any + * \returns (nullable) (transfer none): The newly created WpCollection, or NULL + * if an error happened. + */ +WpCollection * +wp_collection_manager_create_collection_finish (WpCollectionManager * self, + GAsyncResult * res, GError ** error) +{ + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), FALSE); + g_return_val_if_fail (g_task_is_valid (res, self), FALSE); + + return g_task_propagate_pointer (G_TASK (res), error); +} + +/*! + * \brief Destroys a collection from the manager + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param collection_name the collection name to destroy + * \returns TRUE if the collection was destroyed, FALSE otherwise + */ +gboolean +wp_collection_manager_destroy_collection (WpCollectionManager *self, + const gchar *collection_name) +{ + g_autoptr (WpMetadata) m = NULL; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), FALSE); + + m = g_weak_ref_get (&self->metadata); + if (!m) + return FALSE; + + g_hash_table_remove (self->collections, collection_name); + wp_metadata_set (m, 0, collection_name, "Spa:String:JSON", NULL); + return TRUE; +} + +/*! + * \brief Checks whether the collection manager has a collection or not + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param collection_name the collection name to check + * \returns TRUE if the collection exists, FALSE otherwise + */ +gboolean +wp_collection_manager_has_collection (WpCollectionManager *self, + const gchar *collection_name) +{ + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), FALSE); + + return g_hash_table_contains (self->collections, collection_name); +} + +/*! + * \brief Get the collection for the given collection name + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param collection_name the collection name to get + * \returns (nullable) (transfer full): the collection for the given name + */ +WpCollection * +wp_collection_manager_get_collection (WpCollectionManager *self, + const gchar *collection_name) +{ + WpCollection *c = NULL; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), NULL); + + c = g_hash_table_lookup (self->collections, collection_name); + return c ? g_object_ref (c) : NULL; +} + +/*! + * \brief Gets the total number of collections the manager has + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \returns the total number of collections the manager has + */ +guint +wp_collection_manager_get_collection_count (WpCollectionManager *self) +{ + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), 0); + + return g_hash_table_size (self->collections); +} + +/*! + * \brief Iterates over all the collections that the manager has + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \returns (transfer full): an iterator that iterates over the collections + */ +WpIterator * +wp_collection_manager_new_collection_iterator (WpCollectionManager *self) +{ + GPtrArray *collections; + GHashTableIter iter; + WpCollection *c; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), NULL); + + collections = g_ptr_array_new_with_free_func (g_object_unref); + g_hash_table_iter_init (&iter, self->collections); + while (g_hash_table_iter_next (&iter, NULL, (gpointer *)&c)) + g_ptr_array_add (collections, g_object_ref (c)); + + return wp_iterator_new_ptr_array (collections, WP_TYPE_COLLECTION); +} + +/*! + * \brief Collects the bound global into a collection. + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param global (transfer none): the global to collect + * \param collection_name: the name of the collection to collect the global into + * \returns TRUE if the global could be collected, FALSE otherwise + */ +gboolean +wp_collection_manager_collect_global (WpCollectionManager *self, + WpGlobalProxy *global, const gchar *collection_name) +{ + const gchar *cn; + WpCollection *c; + g_autoptr (WpMetadata) c_meta = NULL; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), FALSE); + g_return_val_if_fail (wp_object_test_active_features ( + WP_OBJECT (global), WP_PROXY_FEATURE_BOUND), FALSE); + g_return_val_if_fail (collection_name, FALSE); + + /* Just return if this global is already collected */ + cn = g_hash_table_lookup (self->collected_globals, global); + if (cn) + return g_str_equal (cn, collection_name); + + /* Get the collection */ + c = g_hash_table_lookup (self->collections, collection_name); + if (!c) + return FALSE; + c_meta = wp_collection_get_metadata (c); + g_return_val_if_fail (c_meta, FALSE); + + /* Make sure we are not collecting a collection into itself */ + if (WP_GLOBAL_PROXY (c_meta) == global) { + wp_warning_object (self, "Cannot collect a collection into itself"); + return FALSE; + } + + if (!wp_collection_collect_global (c, global)) + return FALSE; + + g_hash_table_insert (self->collected_globals, global, + g_strdup (collection_name)); + + g_signal_emit (self, signals[SIGNAL_GLOBAL_COLLECTED], 0, global, + collection_name); + return TRUE; +} + +/*! + * \brief Drops the bound global from its collection + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param global (transfer none): the global to drop + * \returns TRUE if the global could be dropped, FALSE otherwise + */ +gboolean +wp_collection_manager_drop_global (WpCollectionManager *self, + WpGlobalProxy *global) +{ + g_autofree gchar *cn = NULL; + WpCollection *c; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), FALSE); + g_return_val_if_fail (wp_object_test_active_features ( + WP_OBJECT (global), WP_PROXY_FEATURE_BOUND), FALSE); + + /* Just return if this global is not collected */ + if (!g_hash_table_steal_extended (self->collected_globals, global, NULL, + (gpointer *)&cn)) + return TRUE; + + /* Get the collection */ + c = g_hash_table_lookup (self->collections, cn); + g_return_val_if_fail (c, FALSE); + + if (!wp_collection_drop_global (c, global)) + return FALSE; + + /* Destroy the collection if it is empty */ + if (wp_collection_get_global_count (c) == 0) + wp_collection_manager_destroy_collection (self, cn); + + g_signal_emit (self, signals[SIGNAL_GLOBAL_DROPPED], 0, global, cn); + return TRUE; +} + +/*! + * \brief Gets the collection that a given global belongs to + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param global (transfer none): the global to get the collection from + * \returns (nullable) (transfer full): the collection that the given global + * belongs too + */ +WpCollection * +wp_collection_manager_get_global_collection (WpCollectionManager *self, + WpGlobalProxy *global) +{ + const gchar *cn; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), NULL); + + cn = g_hash_table_lookup (self->collected_globals, global); + return cn ? wp_collection_manager_get_collection (self, cn) : NULL; +} + +/*! + * \brief Convenience method to checks whether a global is collected into a + * collection or not + * + * \ingroup wpcollectionmanager + * \param self the WpCollectionManager + * \param global (transfer none): the global to check if it is collected or not + * \param collection_name (nullable): the collection name + * \returns TRUE if the global is collected, FALSE otherwise + * belongs too + */ +gboolean +wp_collection_manager_is_global_collected (WpCollectionManager *self, + WpGlobalProxy *global, const gchar *collection_name) +{ + const gchar *cn; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), FALSE); + + cn = g_hash_table_lookup (self->collected_globals, global); + if (!cn) + return FALSE; + + return collection_name ? g_str_equal (cn, collection_name) : TRUE; +} + +/*! + * \brief Iterates over all globals that a collection has + * + * \ingroup wpcollection + * \param self the WpCollection + * \param collection_name (nullable): the collection name + * \returns (transfer full): an iterator that iterates over the globals. + */ +WpIterator * +wp_collection_manager_new_global_iterator (WpCollectionManager *self, + const gchar *collection_name) +{ + GPtrArray *globals; + GHashTableIter iter; + WpGlobalProxy *g = NULL; + const gchar *cn = NULL; + + g_return_val_if_fail (WP_IS_COLLECTION_MANAGER (self), NULL); + + globals = g_ptr_array_new_with_free_func (g_object_unref); + g_hash_table_iter_init (&iter, self->collected_globals); + while (g_hash_table_iter_next (&iter, (gpointer *)&g, (gpointer *)&cn)) + if (!collection_name || g_str_equal (cn, collection_name)) + g_ptr_array_add (globals, g_object_ref (g)); + + return wp_iterator_new_ptr_array (globals, WP_TYPE_GLOBAL_PROXY); +} diff --git a/lib/wp/collection-manager.h b/lib/wp/collection-manager.h new file mode 100644 index 00000000..e30294b2 --- /dev/null +++ b/lib/wp/collection-manager.h @@ -0,0 +1,105 @@ +/* WirePlumber + * + * Copyright © 2025 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#ifndef __WIREPLUMBER_COLLECTION_MANAGER_H__ +#define __WIREPLUMBER_COLLECTION_MANAGER_H__ + +#include "object.h" +#include "collection.h" + +G_BEGIN_DECLS + +/*! + * \brief Flags to be used as WpObjectFeatures for WpCollectionManager. + * \ingroup wpcollectionmanager + */ +typedef enum { /*< flags >*/ + /*! Loads the collection manager */ + WP_COLLECTION_MANAGER_LOADED = (1 << 0), +} WpCollectionManagerFeatures; + +/*! + * \brief The WpCollectionManager GType + * \ingroup wpcollectionmanager + */ +#define WP_TYPE_COLLECTION_MANAGER (wp_collection_manager_get_type ()) + +WP_API +G_DECLARE_FINAL_TYPE (WpCollectionManager, wp_collection_manager, WP, + COLLECTION_MANAGER, WpObject) + +WP_API +WpCollectionManager * wp_collection_manager_new (WpCore * core, + const gchar * metadata_name); + +WP_API +WpCollectionManager * wp_collection_manager_find (WpCore * core, + const gchar *metadata_name); + + +/* Collection */ + +WP_API +void wp_collection_manager_create_collection (WpCollectionManager *self, + const gchar *collection_name, GCancellable * cancellable, + GAsyncReadyCallback callback, gpointer user_data); + +WP_API +void wp_collection_manager_create_collection_closure (WpCollectionManager *self, + const gchar *collection_name, GCancellable * cancellable, + GClosure *closure); + +WP_API +WpCollection *wp_collection_manager_create_collection_finish ( + WpCollectionManager * self, GAsyncResult * res, GError ** error); + +WP_API +gboolean wp_collection_manager_destroy_collection (WpCollectionManager *self, + const gchar *collection_name); + +WP_API +gboolean wp_collection_manager_has_collection (WpCollectionManager *self, + const gchar *collection_name); + +WP_API +WpCollection * wp_collection_manager_get_collection ( + WpCollectionManager *self, const gchar *collection_name); + +WP_API +guint wp_collection_manager_get_collection_count (WpCollectionManager *self); + +WP_API +WpIterator * wp_collection_manager_new_collection_iterator ( + WpCollectionManager *self); + + +/* Global */ + +WP_API +gboolean wp_collection_manager_collect_global (WpCollectionManager *self, + WpGlobalProxy *global, const gchar *collection_name); + +WP_API +gboolean wp_collection_manager_drop_global (WpCollectionManager *self, + WpGlobalProxy *global); + +WP_API +WpCollection * wp_collection_manager_get_global_collection ( + WpCollectionManager *self, WpGlobalProxy *global); + +WP_API +gboolean wp_collection_manager_is_global_collected (WpCollectionManager *self, + WpGlobalProxy *global, const gchar *collection_name); + +WP_API +WpIterator * wp_collection_manager_new_global_iterator ( + WpCollectionManager *self, const gchar *collection_name); + +G_END_DECLS + +#endif diff --git a/lib/wp/collection.c b/lib/wp/collection.c new file mode 100644 index 00000000..12569f33 --- /dev/null +++ b/lib/wp/collection.c @@ -0,0 +1,462 @@ +/* WirePlumber + * + * Copyright © 2025 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#include + +#include "core.h" +#include "collection.h" +#include "metadata.h" +#include "log.h" +#include "object-manager.h" + +#include "private/collection.h" + +WP_DEFINE_LOCAL_LOG_TOPIC ("wp-collection") + +/*! \defgroup wpcollection WpCollection */ +/*! + * \struct WpCollection + * + * The WpCollection class allows grouping multiple WirePlumber global proxies + * together so they can be treated as a single unit. + * + * WpCollection API. + */ + +struct _WpCollection +{ + WpObject parent; + + gchar *name; + + WpObjectManager *metadata_om; + GWeakRef metadata; + GHashTable *globals_info; +}; + +enum { + PROP_0, + PROP_NAME, +}; + +G_DEFINE_TYPE (WpCollection, wp_collection, WP_TYPE_OBJECT) + +static void +wp_collection_init (WpCollection * self) +{ + g_weak_ref_init (&self->metadata, NULL); + + self->globals_info = g_hash_table_new_full (g_direct_hash, g_direct_equal, + NULL, (GDestroyNotify) wp_spa_json_unref); +} + +static void +wp_collection_set_property (GObject * object, guint property_id, + const GValue * value, GParamSpec * pspec) +{ + WpCollection *self = WP_COLLECTION (object); + + switch (property_id) { + case PROP_NAME: + g_clear_pointer (&self->name, g_free); + self->name = g_value_dup_string (value); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); + break; + } +} + +static void +wp_collection_get_property (GObject * object, guint property_id, + GValue * value, GParamSpec * pspec) +{ + WpCollection *self = WP_COLLECTION (object); + + switch (property_id) { + case PROP_NAME: + g_value_set_string (value, self->name); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); + break; + } +} + +enum { + STEP_LOAD = WP_TRANSITION_STEP_CUSTOM_START, +}; + +static WpObjectFeatures +wp_collection_get_supported_features (WpObject * self) +{ + return WP_COLLECTION_LOADED; +} + +static guint +wp_collection_activate_get_next_step (WpObject * self, + WpFeatureActivationTransition * transition, guint step, + WpObjectFeatures missing) +{ + g_return_val_if_fail (missing == WP_COLLECTION_LOADED, + WP_TRANSITION_STEP_ERROR); + + return STEP_LOAD; +} + +static void +on_metadata_changed (WpMetadata *m, guint32 subject, + const gchar *key, const gchar *type, const gchar *value, gpointer d) +{ + WpCollection *self = WP_COLLECTION (d); + + if (key) { + WpProperties *info; + guint32 global_id = SPA_ID_INVALID; + + /* Parse global Id */ + if (!spa_atou32 (key, &global_id, 10)) { + wp_warning_object (self, + "Failed to parse global Id from metadata key '%s'. Ignoring...", key); + return; + } + + /* Check if value is valid */ + if (value) { + g_autoptr (WpSpaJson) json_val = wp_spa_json_new_from_string (value); + gboolean val = FALSE; + if (!wp_spa_json_is_boolean (json_val) || + !wp_spa_json_parse_boolean (json_val, &val) || !val) { + wp_warning_object (self, + "Metadata value '%s' is not a JSON boolean value set to TRUE. " + "Ignoring change...", value); + return; + } + + info = g_hash_table_lookup (self->globals_info, + GUINT_TO_POINTER (global_id)); + if (!info) { + WpSpaJson *new_info = wp_spa_json_new_boolean (TRUE); + g_hash_table_insert (self->globals_info, GUINT_TO_POINTER (global_id), + new_info); + wp_info_object (self, "Added global Id %u", global_id); + } + } else { + g_hash_table_remove (self->globals_info, GUINT_TO_POINTER (global_id)); + wp_info_object (self, "Removed global Id %u", global_id); + } + } else { + g_hash_table_remove_all (self->globals_info); + wp_info_object (self, "Removed all global Ids"); + } +} + +static void +on_metadata_added (WpObjectManager *om, WpMetadata *m, gpointer d) +{ + WpTransition * transition = WP_TRANSITION (d); + WpCollection * self = wp_transition_get_source_object (transition); + g_autoptr (WpProperties) props = NULL; + const gchar *metadata_name = NULL; + g_autoptr (WpIterator) it = NULL; + g_auto (GValue) item = G_VALUE_INIT; + + /* Make sure the metadata has a name */ + props = wp_global_proxy_get_global_properties (WP_GLOBAL_PROXY (m)); + g_return_if_fail (props); + metadata_name = wp_properties_get (props, "metadata.name"); + g_return_if_fail (metadata_name); + g_return_if_fail (g_str_equal (metadata_name, self->name)); + + /* Listen for metadata changes */ + g_signal_connect_object (m, "changed", G_CALLBACK (on_metadata_changed), + self, 0); + + /* Update the globals info table with the information from metadata */ + it = wp_metadata_new_iterator (m, 0); + for (; wp_iterator_next (it, &item); g_value_unset (&item)) { + WpMetadataItem *mi = g_value_get_boxed (&item); + const gchar *key = wp_metadata_item_get_key (mi); + const gchar *value = wp_metadata_item_get_value (mi); + guint32 global_id = SPA_ID_INVALID; + WpSpaJson *info = NULL; + + /* Parse global Id */ + if (!spa_atou32 (key, &global_id, 10)) { + wp_warning_object (self, + "Failed to parse global Id from metadata key '%s'. Ignoring...", + key); + continue; + } + + /* Check if value is valid */ + if (value) { + g_autoptr (WpSpaJson) json_val = wp_spa_json_new_from_string (value); + gboolean val = FALSE; + if (!wp_spa_json_is_boolean (json_val) || + !wp_spa_json_parse_boolean (json_val, &val) || !val) { + wp_warning_object (self, + "Metadata value '%s' is not a JSON boolean value set to TRUE. " + "Ignoring change...", value); + continue; + } + } + + info = g_hash_table_lookup (self->globals_info, + GUINT_TO_POINTER (global_id)); + if (!info) { + WpSpaJson *new_info = wp_spa_json_new_boolean (TRUE); + g_hash_table_insert (self->globals_info, GUINT_TO_POINTER (global_id), + new_info); + } + } + + /* Set the metadata weak ref */ + g_weak_ref_set (&self->metadata, m); + + /* Set the loaded feature */ + wp_object_update_features (WP_OBJECT (self), WP_COLLECTION_LOADED, 0); +} + +static void +wp_collection_activate_execute_step (WpObject * object, + WpFeatureActivationTransition * transition, guint step, + WpObjectFeatures missing) +{ + WpCollection *self = WP_COLLECTION (object); + g_autoptr (WpCore) core = wp_object_get_core (object); + + switch (step) { + case STEP_LOAD: { + g_clear_object (&self->metadata_om); + self->metadata_om = wp_object_manager_new (); + + wp_object_manager_add_interest (self->metadata_om, WP_TYPE_METADATA, + WP_CONSTRAINT_TYPE_PW_GLOBAL_PROPERTY, + "metadata.name", "=s", self->name, + WP_CONSTRAINT_TYPE_PW_GLOBAL_PROPERTY, + "wireplumber.collection", "=b", TRUE, + NULL); + wp_object_manager_request_object_features (self->metadata_om, + WP_TYPE_METADATA, WP_OBJECT_FEATURES_ALL); + g_signal_connect_object (self->metadata_om, "object-added", + G_CALLBACK (on_metadata_added), transition, 0); + wp_core_install_object_manager (core, self->metadata_om); + + wp_info_object (self, "looking for metadata object named %s", self->name); + break; + } + + case WP_TRANSITION_STEP_ERROR: + break; + default: + g_assert_not_reached (); + } +} + +static void +wp_collection_deactivate (WpObject * object, WpObjectFeatures features) +{ + WpCollection *self = WP_COLLECTION (object); + + g_clear_object (&self->metadata_om); + g_weak_ref_set (&self->metadata, NULL); + g_hash_table_remove_all (self->globals_info); + + wp_object_update_features (WP_OBJECT (self), 0, WP_OBJECT_FEATURES_ALL); +} + +static void +wp_collection_finalize (GObject * object) +{ + WpCollection *self = WP_COLLECTION (object); + + g_clear_object (&self->metadata_om); + g_weak_ref_clear (&self->metadata); + g_clear_pointer (&self->globals_info, g_hash_table_unref); + g_clear_pointer (&self->name, g_free); + + G_OBJECT_CLASS (wp_collection_parent_class)->finalize (object); +} + +static void +wp_collection_class_init (WpCollectionClass * klass) +{ + GObjectClass * object_class = (GObjectClass *) klass; + WpObjectClass *wpobject_class = (WpObjectClass *) klass; + + object_class->finalize = wp_collection_finalize; + object_class->set_property = wp_collection_set_property; + object_class->get_property = wp_collection_get_property; + + wpobject_class->get_supported_features = wp_collection_get_supported_features; + wpobject_class->activate_get_next_step = wp_collection_activate_get_next_step; + wpobject_class->activate_execute_step = wp_collection_activate_execute_step; + wpobject_class->deactivate = wp_collection_deactivate; + + g_object_class_install_property (object_class, PROP_NAME, + g_param_spec_string ("name", "name", + "The collection name", NULL, + G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS)); +} + +/*! + * \brief Creates a new WpCollection object + * + * \ingroup wpcollection + * \param core the WpCore + * \param name: the name of the collection + * \returns (transfer full): a new WpCollection object + */ +WpCollection * +wp_collection_new (WpCore * core, const gchar * name) +{ + g_return_val_if_fail (core, NULL); + g_return_val_if_fail (name, NULL); + + return g_object_new (WP_TYPE_COLLECTION, "core", core, "name", name, NULL); +} + +/*! + * \brief Gets the name of the collection + * + * \ingroup wpcollection + * \param self the WpCollection + * \returns (transfer none): the name of the collection + */ +const gchar * +wp_collection_get_name (WpCollection *self) +{ + g_return_val_if_fail (WP_IS_COLLECTION (self), NULL); + + return self->name; +} + +/*! + * \brief Gets the metadata of the collection + * + * \ingroup wpcollection + * \param self the WpCollection + * \returns (nullable) (transfer full): the metadata of the collection + */ +WpMetadata * +wp_collection_get_metadata (WpCollection *self) +{ + g_return_val_if_fail (WP_IS_COLLECTION (self), NULL); + + return g_weak_ref_get (&self->metadata); +} + +/*! + * \brief Adds a global into the collection + * + * \ingroup wpcollection + * \param self the WpCollection + * \param global (transfer none): the global to collect into the collection + * \returns TRUE if the global was collected, FALSE otherwise. + */ +gboolean +wp_collection_collect_global (WpCollection *self, WpGlobalProxy *global) +{ + g_autoptr (WpMetadata) m = NULL; + g_autofree gchar *key = NULL; + g_autoptr (WpSpaJson) json_value = NULL; + guint32 bound_id; + + g_return_val_if_fail (WP_IS_COLLECTION (self), FALSE); + g_return_val_if_fail (WP_IS_GLOBAL_PROXY (global), FALSE); + g_return_val_if_fail (wp_object_test_active_features (WP_OBJECT (global), + WP_PROXY_FEATURE_BOUND), FALSE); + + m = g_weak_ref_get (&self->metadata); + if (!m) + return FALSE; + + if (wp_collection_contains_global (self, global)) + return TRUE; + + bound_id = wp_proxy_get_bound_id (WP_PROXY (global)); + json_value = wp_spa_json_new_boolean (TRUE); + key = g_strdup_printf ("%u", bound_id); + + g_hash_table_insert (self->globals_info, GUINT_TO_POINTER (bound_id), + wp_spa_json_ref (json_value)); + wp_metadata_set (m, 0, key, "Spa:String:JSON", + wp_spa_json_get_data (json_value)); + return TRUE; +} + +/*! + * \brief Drops a global from the collection + * + * \ingroup wpcollection + * \param self the WpCollection + * \param global (transfer none): the global to drop from the collection + * \returns TRUE if the global was dropped, FALSE otherwise. + */ +gboolean +wp_collection_drop_global (WpCollection *self, WpGlobalProxy *global) +{ + g_autoptr (WpMetadata) m = NULL; + g_autofree gchar *key = NULL; + guint32 bound_id; + + g_return_val_if_fail (WP_IS_COLLECTION (self), FALSE); + g_return_val_if_fail (WP_IS_GLOBAL_PROXY (global), FALSE); + g_return_val_if_fail (wp_object_test_active_features (WP_OBJECT (global), + WP_PROXY_FEATURE_BOUND), FALSE); + + bound_id = wp_proxy_get_bound_id (WP_PROXY (global)); + + m = g_weak_ref_get (&self->metadata); + if (!m) + return FALSE; + + g_hash_table_remove (self->globals_info, GUINT_TO_POINTER (bound_id)); + key = g_strdup_printf ("%u", bound_id); + wp_metadata_set (m, 0, key, NULL, NULL); + return TRUE; +} + +/*! + * \brief Checks whether the collection contains a global or not + * + * \ingroup wpcollection + * \param self the WpCollection + * \param global (transfer none): the global to check + * \returns TRUE if the collection contains the global, FALSE otherwise. + */ +gboolean +wp_collection_contains_global (WpCollection *self, WpGlobalProxy *global) +{ + guint32 bound_id; + + g_return_val_if_fail (WP_IS_COLLECTION (self), FALSE); + g_return_val_if_fail (WP_IS_GLOBAL_PROXY (global), FALSE); + g_return_val_if_fail (wp_object_test_active_features (WP_OBJECT (global), + WP_PROXY_FEATURE_BOUND), FALSE); + + bound_id = wp_proxy_get_bound_id (WP_PROXY (global)); + return g_hash_table_contains (self->globals_info, + GUINT_TO_POINTER (bound_id)); +} + +/*! + * \brief Gets the total number of globals that the collection has + * + * \ingroup wpcollection + * \param self the WpCollection + * \returns the total number of globals the collection has + */ +guint +wp_collection_get_global_count (WpCollection *self) +{ + g_return_val_if_fail (WP_IS_COLLECTION (self), 0); + + return g_hash_table_size (self->globals_info); +} diff --git a/lib/wp/collection.h b/lib/wp/collection.h new file mode 100644 index 00000000..84d667f9 --- /dev/null +++ b/lib/wp/collection.h @@ -0,0 +1,49 @@ +/* WirePlumber + * + * Copyright © 2025 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#ifndef __WIREPLUMBER_COLLECTION_H__ +#define __WIREPLUMBER_COLLECTION_H__ + +#include "metadata.h" + +G_BEGIN_DECLS + +/*! + * \brief Flags to be used as WpObjectFeatures for WpCollection. + * \ingroup wpcollection + */ +typedef enum { /*< flags >*/ + /*! Loads the collection */ + WP_COLLECTION_LOADED = (1 << 0), +} WpCollectionFeatures; + +/*! + * \brief The WpCollection GType + * \ingroup wpcollection + */ +#define WP_TYPE_COLLECTION (wp_collection_get_type ()) + +WP_API +G_DECLARE_FINAL_TYPE (WpCollection, wp_collection, WP, COLLECTION, WpObject) + +WP_API +const gchar * wp_collection_get_name (WpCollection *self); + +WP_API +WpMetadata *wp_collection_get_metadata (WpCollection *self); + +WP_API +gboolean wp_collection_contains_global (WpCollection *self, + WpGlobalProxy *global); + +WP_API +guint wp_collection_get_global_count (WpCollection *self); + +G_END_DECLS + +#endif diff --git a/lib/wp/event.c b/lib/wp/event.c index cd3db905..0c489983 100644 --- a/lib/wp/event.c +++ b/lib/wp/event.c @@ -11,6 +11,7 @@ #include "event-hook.h" #include "log.h" #include "proxy.h" +#include "collection-manager.h" #include #include @@ -104,6 +105,23 @@ wp_event_new (const gchar * type, gint priority, WpProperties * properties, wp_properties_update (self->properties, subj_props); } } + + /* Add collection name */ + if (WP_IS_GLOBAL_PROXY (self->subject)) { + g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self->subject)); + if (core) { + g_autoptr (WpCollectionManager) cm = NULL; + cm = wp_collection_manager_find (core, NULL); + if (cm) { + g_autoptr (WpCollection) c = + wp_collection_manager_get_global_collection (cm, + WP_GLOBAL_PROXY (self->subject)); + if (c) + wp_properties_set (self->properties, "collection.name", + wp_collection_get_name (c)); + } + } + } } wp_properties_set (self->properties, "event.type", type); diff --git a/lib/wp/meson.build b/lib/wp/meson.build index 9aacc624..ecadda0d 100644 --- a/lib/wp/meson.build +++ b/lib/wp/meson.build @@ -1,6 +1,8 @@ wp_lib_sources = files( 'base-dirs.c', 'client.c', + 'collection.c', + 'collection-manager.c', 'component-loader.c', 'conf.c', 'core.c', @@ -48,6 +50,8 @@ wp_lib_priv_sources = files( wp_lib_headers = files( 'base-dirs.h', 'client.h', + 'collection.h', + 'collection-manager.h', 'component-loader.h', 'conf.h', 'core.h', diff --git a/lib/wp/private/collection.h b/lib/wp/private/collection.h new file mode 100644 index 00000000..1a09b839 --- /dev/null +++ b/lib/wp/private/collection.h @@ -0,0 +1,27 @@ +/* WirePlumber + * + * Copyright © 2025 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#ifndef __WIREPLUMBER_PRIVATE_COLLECTION_H__ +#define __WIREPLUMBER_PRIVATE_COLLECTION_H__ + +#include "global-proxy.h" + +G_BEGIN_DECLS + +typedef struct _WpCollection WpCollection; + +WpCollection * wp_collection_new (WpCore * core, const gchar * name); + +gboolean wp_collection_collect_global (WpCollection *self, + WpGlobalProxy *global); + +gboolean wp_collection_drop_global (WpCollection *self, WpGlobalProxy *global); + +G_END_DECLS + +#endif diff --git a/lib/wp/private/internal-comp-loader.c b/lib/wp/private/internal-comp-loader.c index b6435335..0a997da6 100644 --- a/lib/wp/private/internal-comp-loader.c +++ b/lib/wp/private/internal-comp-loader.c @@ -780,6 +780,20 @@ load_settings_instance (GTask * task, WpCore * core, WpSpaJson * args) g_task_return_pointer (task, settings, g_object_unref); } +static void +load_collection_manager_instance (GTask * task, WpCore * core, WpSpaJson * args) +{ + g_autofree gchar *metadata_name = NULL; + if (args) + wp_spa_json_object_get (args, "metadata.name", "s", &metadata_name, NULL); + + wp_info_object (core, "loading collection manager instance '%s'...", + metadata_name ? metadata_name : "(default: sm-collection-manager)"); + + WpCollectionManager *cm = wp_collection_manager_new (core, metadata_name); + g_task_return_pointer (task, cm, g_object_unref); +} + static const struct { const gchar * name; void (*load) (GTask *, WpCore *, WpSpaJson *); @@ -787,6 +801,7 @@ static const struct { { "ensure-no-media-session", ensure_no_media_session }, { "export-core", load_export_core }, { "settings-instance", load_settings_instance }, + { "collection-manager-instance", load_collection_manager_instance }, }; /*** WpInternalCompLoader ***/ diff --git a/lib/wp/wp.h b/lib/wp/wp.h index 3e010f96..abc0b317 100644 --- a/lib/wp/wp.h +++ b/lib/wp/wp.h @@ -47,6 +47,8 @@ #include "wpversion.h" #include "factory.h" #include "settings.h" +#include "collection.h" +#include "collection-manager.h" G_BEGIN_DECLS diff --git a/modules/meson.build b/modules/meson.build index 7308aa4e..3203f641 100644 --- a/modules/meson.build +++ b/modules/meson.build @@ -191,3 +191,13 @@ shared_library( install_dir : wireplumber_module_dir, dependencies : [wp_dep], ) + +shared_library( + 'wireplumber-module-collection-manager', + [ + 'module-collection-manager.c', + ], + install : true, + install_dir : wireplumber_module_dir, + dependencies : [wp_dep, pipewire_dep], +) diff --git a/modules/module-collection-manager.c b/modules/module-collection-manager.c new file mode 100644 index 00000000..1326b16f --- /dev/null +++ b/modules/module-collection-manager.c @@ -0,0 +1,217 @@ +/* WirePlumber + * + * Copyright © 2025 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#include +#include + +WP_DEFINE_LOCAL_LOG_TOPIC ("m-collection-manager") + +/* + * This module creates the "sm-collection-manager" metadata and also each + * individual collection metadata. + */ + +struct _WpCollectionManagerPlugin +{ + WpPlugin parent; + + /* Props */ + gchar *metadata_name; + + WpImplMetadata *impl_metadata; + GHashTable *collection_metadatas; +}; + +enum { + PROP_0, + PROP_METADATA_NAME, +}; + +G_DECLARE_FINAL_TYPE (WpCollectionManagerPlugin, wp_collection_manager_plugin, + WP, COLLECTION_MANAGER_PLUGIN, WpPlugin) +G_DEFINE_TYPE (WpCollectionManagerPlugin, wp_collection_manager_plugin, + WP_TYPE_PLUGIN) + +static void +wp_collection_manager_plugin_init (WpCollectionManagerPlugin * self) +{ +} + +static void +on_collection_metadata_activated (GObject * o, GAsyncResult * res, gpointer d) +{ + WpCollectionManagerPlugin *self = WP_COLLECTION_MANAGER_PLUGIN (d); + g_autoptr (WpImplMetadata) m = WP_IMPL_METADATA (o); + g_autoptr (WpProperties) props = NULL; + const gchar *name = NULL; + g_autoptr (GError) error = NULL; + + /* Get the metadata name */ + props = wp_global_proxy_get_global_properties (WP_GLOBAL_PROXY (m)); + g_return_if_fail (props); + name = wp_properties_get (props, "metadata.name"); + g_return_if_fail (name); + + /* make sure activation was successful */ + if (!wp_object_activate_finish (WP_OBJECT (m), res, &error)) { + wp_warning_object (self, "Failed to activate collection metadata: %s", + name); + return; + } + + /* Add the metadata */ + g_hash_table_insert (self->collection_metadatas, g_strdup (name), + g_object_ref (m)); + wp_info_object (self, "Added collection metadata: %s", name); +} + +static void +on_metadata_changed (WpMetadata *m, guint32 subject, const gchar *key, + const gchar *type, const gchar *value, gpointer d) +{ + WpCollectionManagerPlugin *self = WP_COLLECTION_MANAGER_PLUGIN (d); + g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self)); + + if (key) { + WpImplMetadata *collection_m = g_hash_table_lookup ( + self->collection_metadatas, key); + if (collection_m && !value) { + /* Remove collection metadata */ + g_hash_table_remove (self->collection_metadatas, key); + wp_info_object (self, "Removed collection metadata: %s", key); + } else if (!collection_m && value) { + /* Create new collection metadata */ + wp_info_object (self, "Creating collection metadata: %s", key); + WpImplMetadata *new_collection_m = wp_impl_metadata_new_full (core, key, + wp_properties_new ("wireplumber.collection", "true", NULL)); + wp_object_activate (WP_OBJECT (new_collection_m), WP_OBJECT_FEATURES_ALL, + NULL, on_collection_metadata_activated, self); + } + } else { + /* Remove all collection metadatas */ + g_hash_table_remove_all (self->collection_metadatas); + wp_info_object (self, "Removed all collection metadatas"); + } +} + +static void +on_metadata_activated (WpMetadata * m, GAsyncResult * res, + gpointer user_data) +{ + WpTransition *trans = WP_TRANSITION (user_data); + WpCollectionManagerPlugin *self = wp_transition_get_source_object (trans); + g_autoptr (GError) error = NULL; + + if (!wp_object_activate_finish (WP_OBJECT (m), res, &error)) { + wp_transition_return_error (trans, g_error_new (WP_DOMAIN_LIBRARY, + WP_LIBRARY_ERROR_OPERATION_FAILED, + "Failed to activate metadata object %s", self->metadata_name)); + return; + } + + /* monitor changes in metadata */ + g_signal_connect_object (m, "changed", G_CALLBACK (on_metadata_changed), self, + 0); + + wp_object_update_features (WP_OBJECT (self), WP_PLUGIN_FEATURE_ENABLED, 0); +} + +static void +wp_collection_manager_plugin_enable (WpPlugin * plugin, + WpTransition * transition) +{ + WpCollectionManagerPlugin * self = WP_COLLECTION_MANAGER_PLUGIN (plugin); + g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (plugin)); + + /* Create the collection metadatas table */ + self->collection_metadatas = g_hash_table_new_full (g_str_hash, g_str_equal, + g_free, g_object_unref); + + /* Create the collection manager metadata */ + self->impl_metadata = wp_impl_metadata_new_full (core, self->metadata_name, + wp_properties_new ("wireplumber.collection-manager", "true", NULL)); + wp_object_activate (WP_OBJECT (self->impl_metadata), WP_OBJECT_FEATURES_ALL, + NULL, + (GAsyncReadyCallback)on_metadata_activated, transition); +} + +static void +wp_collection_manager_plugin_disable (WpPlugin * plugin) +{ + WpCollectionManagerPlugin * self = WP_COLLECTION_MANAGER_PLUGIN (plugin); + + g_clear_object (&self->impl_metadata); + g_clear_pointer (&self->collection_metadatas, g_hash_table_unref); + + g_clear_pointer (&self->metadata_name, g_free); +} + +static void +wp_collection_manager_plugin_set_property (GObject * object, guint property_id, + const GValue * value, GParamSpec * pspec) +{ + WpCollectionManagerPlugin *self = WP_COLLECTION_MANAGER_PLUGIN (object); + + switch (property_id) { + case PROP_METADATA_NAME: + g_clear_pointer (&self->metadata_name, g_free); + self->metadata_name = g_value_dup_string (value); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); + break; + } +} + +static void +wp_collection_manager_plugin_get_property (GObject * object, guint property_id, + GValue * value, GParamSpec * pspec) +{ + WpCollectionManagerPlugin *self = WP_COLLECTION_MANAGER_PLUGIN (object); + + switch (property_id) { + case PROP_METADATA_NAME: + g_value_set_string (value, self->metadata_name); + break; + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec); + break; + } +} + +static void +wp_collection_manager_plugin_class_init (WpCollectionManagerPluginClass * klass) +{ + WpPluginClass *plugin_class = (WpPluginClass *) klass; + GObjectClass *object_class = (GObjectClass *) klass; + + plugin_class->enable = wp_collection_manager_plugin_enable; + plugin_class->disable = wp_collection_manager_plugin_disable; + + object_class->set_property = wp_collection_manager_plugin_set_property; + object_class->get_property = wp_collection_manager_plugin_get_property; + + g_object_class_install_property (object_class, PROP_METADATA_NAME, + g_param_spec_string ("metadata-name", "metadata-name", + "The metadata object to look after", NULL, + G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS)); +} + +WP_PLUGIN_EXPORT GObject * +wireplumber__module_init (WpCore * core, WpSpaJson * args, GError ** error) +{ + g_autofree gchar *metadata_name = NULL; + if (args) + wp_spa_json_object_get (args, "metadata.name", "s", &metadata_name, NULL); + + return G_OBJECT (g_object_new (wp_collection_manager_plugin_get_type (), + "name", "collection-manager", + "core", core, + "metadata-name", metadata_name ? metadata_name : "sm-collection-manager", + NULL)); +} diff --git a/modules/module-lua-scripting/api/api.c b/modules/module-lua-scripting/api/api.c index 019f354f..8108b9cb 100644 --- a/modules/module-lua-scripting/api/api.c +++ b/modules/module-lua-scripting/api/api.c @@ -2167,6 +2167,53 @@ static const luaL_Reg properties_funcs[] = { { NULL, NULL } }; +/* WpCollection */ + +static int +collection_get_name (lua_State *L) +{ + WpCollection *c = wplua_checkobject (L, 1, WP_TYPE_COLLECTION); + lua_pushstring (L, wp_collection_get_name (c)); + return 1; +} + +static int +collection_get_metadata (lua_State *L) +{ + WpCollection *c = wplua_checkobject (L, 1, WP_TYPE_COLLECTION); + WpMetadata *m = wp_collection_get_metadata (c); + if (m) + wplua_pushobject (L, m); + else + lua_pushnil (L); + return 1; +} + +static int +collection_contains_global (lua_State *L) +{ + WpCollection *c = wplua_checkobject (L, 1, WP_TYPE_COLLECTION); + WpGlobalProxy *g = wplua_checkobject (L, 2, WP_TYPE_GLOBAL_PROXY); + lua_pushboolean (L, wp_collection_contains_global (c, g)); + return 1; +} + +static int +collection_get_global_count (lua_State *L) +{ + WpCollection *c = wplua_checkobject (L, 1, WP_TYPE_COLLECTION); + lua_pushinteger (L, wp_collection_get_global_count (c)); + return 1; +} + +static const luaL_Reg collection_funcs[] = { + { "get_name", collection_get_name }, + { "get_metadata", collection_get_metadata }, + { "contains_global", collection_contains_global }, + { "get_global_count", collection_get_global_count }, + { NULL, NULL } +}; + /* WpSettings */ static int @@ -2617,6 +2664,252 @@ static const luaL_Reg event_dispatcher_funcs[] = { { NULL, NULL } }; +/* WpCollectionManager */ + +static void +on_collection_created (WpCollectionManager *cm, GAsyncResult * res, + GClosure * closure) +{ + g_autoptr (GError) error = NULL; + WpCollection *c; + GValue val[2] = { G_VALUE_INIT, G_VALUE_INIT }; + int n_vals = 1; + + c = wp_collection_manager_create_collection_finish (cm, res, &error); + if (!c) { + g_value_init (&val[1], G_TYPE_STRING); + g_value_set_string (&val[1], error->message); + n_vals = 2; + } + + g_value_init (&val[0], WP_TYPE_COLLECTION); + g_value_set_object (&val[0], c); + g_closure_invoke (closure, NULL, n_vals, val, NULL); + g_value_unset (&val[0]); + g_value_unset (&val[1]); + g_closure_invalidate (closure); + g_closure_unref (closure); +} + +static int +collection_manager_create_collection (lua_State *L) +{ + const char *name = luaL_checkstring (L, 1); + GClosure * closure = wplua_function_to_closure (L, 2); + g_autoptr (WpCollectionManager) cm = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + GValue val[2] = { G_VALUE_INIT, G_VALUE_INIT }; + g_value_init (&val[0], WP_TYPE_METADATA); + g_value_set_object (&val[0], NULL); + g_value_init (&val[1], G_TYPE_STRING); + g_value_set_string (&val[1], "Could not find collection manager"); + g_closure_invoke (closure, NULL, 2, val, NULL); + g_value_unset (&val[0]); + g_value_unset (&val[1]); + g_closure_invalidate (closure); + g_closure_unref (closure); + return 0; + } + + wp_collection_manager_create_collection (cm, name, NULL, + (GAsyncReadyCallback) on_collection_created, closure); + return 0; +} + +static int +collection_manager_destroy_collection (lua_State *L) +{ + const char *name = luaL_checkstring (L, 1); + g_autoptr (WpCollectionManager) cm = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushboolean (L, FALSE); + return 1; + } + + lua_pushboolean (L, wp_collection_manager_destroy_collection (cm, name)); + return 1; +} + +static int +collection_manager_has_collection (lua_State *L) +{ + const char *name = luaL_checkstring (L, 1); + g_autoptr (WpCollectionManager) cm = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushboolean (L, FALSE); + return 1; + } + + lua_pushboolean (L, wp_collection_manager_has_collection (cm, name)); + return 1; +} + +static int +collection_manager_get_collection (lua_State *L) +{ + const char *name = luaL_checkstring (L, 1); + g_autoptr (WpCollectionManager) cm = NULL; + WpCollection *c = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushnil (L); + return 1; + } + + c = wp_collection_manager_get_collection (cm, name); + if (c) + wplua_pushobject (L, c); + else + lua_pushnil (L); + return 1; +} + +static int +collection_manager_get_collection_count (lua_State *L) +{ + g_autoptr (WpCollectionManager) cm = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushinteger (L, 0); + return 1; + } + + lua_pushinteger (L, wp_collection_manager_get_collection_count (cm)); + return 1; +} + +static int +collection_manager_iterate_collections (lua_State *L) +{ + g_autoptr (WpCollectionManager) cm = NULL; + g_autoptr (WpIterator) it = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushnil (L); + return 1; + } + + it = wp_collection_manager_new_collection_iterator (cm); + return push_wpiterator (L, it); +} + +static int +collection_manager_collect_global (lua_State *L) +{ + WpGlobalProxy *global = wplua_checkobject (L, 1, WP_TYPE_GLOBAL_PROXY); + const char *name = luaL_checkstring (L, 2); + g_autoptr (WpCollectionManager) cm = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushboolean (L, FALSE); + return 1; + } + + lua_pushboolean (L, wp_collection_manager_collect_global (cm, global, name)); + return 1; +} + +static int +collection_manager_drop_global (lua_State *L) +{ + WpGlobalProxy *global = wplua_checkobject (L, 1, WP_TYPE_GLOBAL_PROXY); + g_autoptr (WpCollectionManager) cm = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushboolean (L, FALSE); + return 1; + } + + lua_pushboolean (L, wp_collection_manager_drop_global (cm, global)); + return 1; +} + +static int +collection_manager_get_global_collection (lua_State *L) +{ + WpGlobalProxy *global = wplua_checkobject (L, 1, WP_TYPE_GLOBAL_PROXY); + g_autoptr (WpCollectionManager) cm = NULL; + WpCollection *c = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushnil (L); + return 1; + } + + c = wp_collection_manager_get_global_collection (cm, global); + if (c) + wplua_pushobject (L, c); + else + lua_pushnil (L); + return 1; +} + +static int +collection_manager_is_global_collected (lua_State *L) +{ + WpGlobalProxy *global = wplua_checkobject (L, 1, WP_TYPE_GLOBAL_PROXY); + const gchar *collection_name = NULL; + if (!lua_isnone (L, 2) && !lua_isnil (L, 2)) + collection_name = luaL_checkstring (L, 2); + g_autoptr (WpCollectionManager) cm = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushboolean (L, FALSE); + return 1; + } + + lua_pushboolean (L, wp_collection_manager_is_global_collected (cm, global, + collection_name)); + return 1; +} + +static int +collection_manager_iterate_globals (lua_State *L) +{ + const gchar *collection_name = NULL; + if (!lua_isnone (L, 1) && !lua_isnil (L, 1)) + collection_name = luaL_checkstring (L, 1); + g_autoptr (WpCollectionManager) cm = NULL; + g_autoptr (WpIterator) it = NULL; + + cm = wp_collection_manager_find (get_wp_core (L), NULL); + if (!cm) { + lua_pushnil (L); + return 1; + } + + it = wp_collection_manager_new_global_iterator (cm, collection_name); + return push_wpiterator (L, it); +} + +static const luaL_Reg collection_manager_funcs[] = { + { "create_collection", collection_manager_create_collection }, + { "destroy_collection", collection_manager_destroy_collection }, + { "has_collection", collection_manager_has_collection }, + { "get_collection", collection_manager_get_collection }, + { "get_collection_count", collection_manager_get_collection_count }, + { "iterate_collections", collection_manager_iterate_collections }, + { "collect_global", collection_manager_collect_global }, + { "drop_global", collection_manager_drop_global }, + { "get_global_collection", collection_manager_get_global_collection }, + { "is_global_collected", collection_manager_is_global_collected }, + { "iterate_globals", collection_manager_iterate_globals }, + { NULL, NULL } +}; + /* WpEventHook */ static int @@ -3083,6 +3376,9 @@ wp_lua_scripting_api_init (lua_State *L) luaL_newlib (L, event_dispatcher_funcs); lua_setglobal (L, "WpEventDispatcher"); + luaL_newlib (L, collection_manager_funcs); + lua_setglobal (L, "WpCollectionManager"); + wp_lua_scripting_pod_init (L); wp_lua_scripting_json_init (L); @@ -3144,6 +3440,8 @@ wp_lua_scripting_api_init (lua_State *L) NULL, iterator_funcs); wplua_register_type_methods (L, WP_TYPE_PROPERTIES, properties_new, properties_funcs); + wplua_register_type_methods (L, WP_TYPE_COLLECTION, + NULL, collection_funcs); if (!wplua_load_uri (L, URI_API, &error) || !wplua_pcall (L, 0, 0, &error)) { diff --git a/modules/module-lua-scripting/api/api.lua b/modules/module-lua-scripting/api/api.lua index fe886bc3..db2b5d16 100644 --- a/modules/module-lua-scripting/api/api.lua +++ b/modules/module-lua-scripting/api/api.lua @@ -214,6 +214,7 @@ SANDBOX_EXPORT = { LocalModule = WpImplModule_new, ImplMetadata = WpImplMetadata_new, Settings = WpSettings, + CollectionManager = WpCollectionManager, Conf = WpConf, JsonUtils = JsonUtils, ProcUtils = ProcUtils, diff --git a/modules/module-standard-event-source.c b/modules/module-standard-event-source.c index fba868b8..c57cd427 100644 --- a/modules/module-standard-event-source.c +++ b/modules/module-standard-event-source.c @@ -69,6 +69,7 @@ struct _WpStandardEventSource WpEventHook *rescan_done_hook; gboolean rescan_scheduled[N_RESCAN_CONTEXTS]; gint n_oms_installed; + WpCollectionManager *cm; }; static guint signals[N_SIGNALS] = {0}; @@ -157,7 +158,8 @@ static gint get_default_event_priority (const gchar *event_type) { if (g_str_has_prefix(event_type, "select-") || - g_str_has_prefix(event_type, "create-")) + g_str_has_prefix(event_type, "create-") || + g_str_has_prefix(event_type, "destroy-")) return 500; else if (!g_strcmp0 (event_type, "rescan-for-default-nodes")) return -490; @@ -209,7 +211,8 @@ static gboolean is_it_local_event (const gchar *event_type) { if (g_str_has_prefix(event_type, "select-") || - g_str_has_prefix(event_type, "create-")) + g_str_has_prefix(event_type, "create-") || + g_str_has_prefix(event_type, "destroy-")) return TRUE; return FALSE; @@ -307,6 +310,26 @@ wp_standard_event_source_schedule_rescan (WpStandardEventSource *self, } } +static void +on_global_collected (WpObjectManager *om, WpGlobalProxy *global, + const gchar *collection_name, WpStandardEventSource *self) +{ + g_autoptr (WpProperties) properties = wp_properties_new_empty (); + wp_properties_set (properties, "event.subject.collection", collection_name); + + wp_standard_event_source_push_event (self, "collected", global, properties); +} + +static void +on_global_dropped (WpObjectManager *om, WpGlobalProxy *global, + const gchar *collection_name, WpStandardEventSource *self) +{ + g_autoptr (WpProperties) properties = wp_properties_new_empty (); + wp_properties_set (properties, "event.subject.collection", collection_name); + + wp_standard_event_source_push_event (self, "dropped", global, properties); +} + static void on_metadata_changed (WpMetadata *obj, guint32 subject, const gchar *key, const gchar *spa_type, const gchar *value, @@ -350,6 +373,7 @@ on_node_state_changed (WpNode *obj, WpNodeState old_state, static void on_object_added (WpObjectManager *om, WpObject *obj, WpStandardEventSource *self) { + wp_standard_event_source_push_event (self, "select-collection", obj, NULL); wp_standard_event_source_push_event (self, "added", obj, NULL); if (WP_IS_PIPEWIRE_OBJECT (obj)) { @@ -405,6 +429,15 @@ wp_standard_event_source_enable (WpPlugin * plugin, WpTransition * transition) wp_event_dispatcher_get_instance (core); g_return_if_fail (dispatcher); + /* Get the collection manager if any */ + self->cm = wp_collection_manager_find (core, NULL); + if (self->cm) { + g_signal_connect_object (self->cm, "global-collected", + G_CALLBACK (on_global_collected), self, 0); + g_signal_connect_object (self->cm, "global-dropped", + G_CALLBACK (on_global_dropped), self, 0); + } + /* install object managers */ self->n_oms_installed = 0; for (gint i = 0; i < N_OBJECT_TYPES; i++) { @@ -447,6 +480,8 @@ wp_standard_event_source_disable (WpPlugin * plugin) if (dispatcher) wp_event_dispatcher_unregister_hook (dispatcher, self->rescan_done_hook); g_clear_object (&self->rescan_done_hook); + + g_clear_object (&self->cm); } static void diff --git a/src/config/wireplumber.conf b/src/config/wireplumber.conf index ee1aa1d6..d4addf67 100644 --- a/src/config/wireplumber.conf +++ b/src/config/wireplumber.conf @@ -68,6 +68,7 @@ wireplumber.profiles = { inherits = [ base ] metadata.sm-settings = required + metadata.sm-collection-manager = required metadata.sm-objects = required policy.standard = required @@ -101,6 +102,7 @@ wireplumber.profiles = { policy = { inherits = [ base ] metadata.sm-settings = required + metadata.sm-collection-manager = required metadata.sm-objects = required policy.standard = required } @@ -128,6 +130,7 @@ wireplumber.profiles = { base = { check.no-media-session = required support.settings = required + support.collection-manager = required support.log-settings = required support.session-services = required } @@ -233,6 +236,13 @@ wireplumber.components = [ provides = metadata.sm-settings } + ## Provides the "sm-collection-manager" metadata object + { + name = libwireplumber-module-collection-manager, type = module + arguments = { metadata.name = sm-collection-manager } + provides = metadata.sm-collection-manager + } + ## Activates a global WpSettings instance, providing settings from ## the sm-settings metadata object. Note that this blocks and waits for the ## sm-settings metadata object to become available, so one instance must @@ -244,6 +254,18 @@ wireplumber.components = [ after = [ metadata.sm-settings ] } + ## Activates a global WpCollectionManager instance, allowing creating + ## collections for global objects. Note that this blocks and waits for the + ## sm-collection-manager metadata object to become available, so one instance + ## must provide that, while others should only load this to access the + ## collection manager. + { + name = collection-manager-instance, type = built-in + arguments = { metadata.name = sm-collection-manager } + provides = support.collection-manager + after = [ metadata.sm-collection-manager ] + } + ## Log level settings { name = libwireplumber-module-log-settings, type = module @@ -534,10 +556,15 @@ wireplumber.components = [ name = device/autoswitch-bluetooth-profile.lua, type = script/lua provides = hooks.device.profile.autoswitch-bluetooth } + { + name = device/create-alsa-loopback.lua, type = script/lua + provides = hooks.device.profile.create-alsa-loopback + } { type = virtual, provides = policy.device.profile requires = [ hooks.device.profile.select, hooks.device.profile.autoswitch-bluetooth, + hooks.device.profile.create-alsa-loopback, hooks.device.profile.apply ] wants = [ hooks.device.profile.find-voice-call, hooks.device.profile.find-best, @@ -654,38 +681,46 @@ wireplumber.components = [ provides = hooks.linking.rescan } { - name = linking/find-media-role-target.lua, type = script/lua - provides = hooks.linking.target.find-media-role + name = linking/default/find-media-role-target.lua, type = script/lua + provides = hooks.linking.default.target.find-media-role } { - name = linking/find-defined-target.lua, type = script/lua - provides = hooks.linking.target.find-defined + name = linking/default/find-defined-target.lua, type = script/lua + provides = hooks.linking.default.target.find-defined } { - name = linking/find-audio-group-target.lua, type = script/lua - provides = hooks.linking.target.find-audio-group + name = linking/default/find-audio-group-target.lua, type = script/lua + provides = hooks.linking.default.target.find-audio-group requires = [ node.audio-group ] } { - name = linking/find-filter-target.lua, type = script/lua - provides = hooks.linking.target.find-filter + name = linking/default/find-filter-target.lua, type = script/lua + provides = hooks.linking.default.target.find-filter requires = [ metadata.filters ] } { - name = linking/find-default-target.lua, type = script/lua - provides = hooks.linking.target.find-default + name = linking/default/find-default-target.lua, type = script/lua + provides = hooks.linking.default.target.find-default requires = [ api.default-nodes ] } { - name = linking/find-best-target.lua, type = script/lua - provides = hooks.linking.target.find-best + name = linking/default/find-best-target.lua, type = script/lua + provides = hooks.linking.default.target.find-best requires = [ metadata.filters ] } { - name = linking/get-filter-from-target.lua, type = script/lua - provides = hooks.linking.target.get-filter-from + name = linking/default/get-filter-from-target.lua, type = script/lua + provides = hooks.linking.default.target.get-filter-from requires = [ metadata.filters ] } + { + name = linking/alsa-loopback/find-filter-target.lua, type = script/lua + provides = hooks.linking.alsa-loopback.target.find-filter + } + { + name = linking/alsa-loopback/find-alsa-target.lua, type = script/lua + provides = hooks.linking.alsa-loopback.target.find-alsa + } { name = linking/prepare-link.lua, type = script/lua provides = hooks.linking.target.prepare-link @@ -706,13 +741,15 @@ wireplumber.components = [ requires = [ hooks.linking.rescan, hooks.linking.target.prepare-link, hooks.linking.target.link ] - wants = [ hooks.linking.target.find-media-role, - hooks.linking.target.find-defined, - hooks.linking.target.find-audio-group, - hooks.linking.target.find-filter, - hooks.linking.target.find-default, - hooks.linking.target.find-best, - hooks.linking.target.get-filter-from, + wants = [ hooks.linking.default.target.find-media-role, + hooks.linking.default.target.find-defined, + hooks.linking.default.target.find-audio-group, + hooks.linking.default.target.find-filter, + hooks.linking.default.target.find-default, + hooks.linking.default.target.find-best, + hooks.linking.default.target.get-filter-from, + hooks.linking.alsa-loopback.target.find-filter, + hooks.linking.alsa-loopback.target.find-alsa, hooks.linking.pause-playback ] } @@ -728,15 +765,15 @@ wireplumber.components = [ requires = [ hooks.linking.role-based.rescan ] } { - name = linking/find-media-role-sink-target.lua, type = script/lua - provides = hooks.linking.target.find-media-role-sink + name = linking/default/find-media-role-sink-target.lua, type = script/lua + provides = hooks.linking.default.target.find-media-role-sink } { type = virtual, provides = policy.linking.role-based requires = [ policy.linking.standard, hooks.linking.role-based.rescan, hooks.node.role-based.default-volume, - hooks.linking.target.find-media-role-sink ] + hooks.linking.default.target.find-media-role-sink ] } ## Standard policy definition diff --git a/src/config/wireplumber.conf.d.examples/alsa.conf b/src/config/wireplumber.conf.d.examples/alsa.conf index 4eed6d66..d2930da1 100644 --- a/src/config/wireplumber.conf.d.examples/alsa.conf +++ b/src/config/wireplumber.conf.d.examples/alsa.conf @@ -129,3 +129,71 @@ monitor.alsa.rules = [ # } # } ] + +loopback.alsa.rules = [ + ## The list of ALSA loopback rules + + ## This rule example allows creating a loopback for the route device 3 of + ## all ALSA devices. + # { + # matches = [ + # { + # ## This matches all ALSA cards + # device.name = "~alsa_card.*" + # } + # ] + # actions = { + # ## MANDATORY: This action is used to create a loopback for a particular + # ## route device number. An ALSA loopback collection for this route + # ## device will also be created so that it has its own policy. + # create-loopback = { + # ## This creates a loopback for sub-device 3, which is the ALSA node + # ## that represents the route with device number 3. + # [ + # ## MANDATORY: The route device this loopback will be used with + # route-device = 3 + # + # ## OPTIONAL: Whether the loopback is dynamic or not. Dynamic loopbacks + # ## are only available if the current ALSA profile supports the given + # ## route device. + # dynamic = false + # + # ## OPTIONAL: Extra ALSA loopback node properties + # device-node-props = { + # card.profile.device = 3 + # node.virtual = false + # priority.session = 1000 + # } + # + # ## OPTIONAL: Extra stream loopback node properties + # stream-node-props = { + # node.virtual = false + # } + # ] + # } + # + # ## OPTIONAL: This action is used to collect extra nodes into the ALSA + # ## loopback collection, which can be useful if we want to include extra + # ## filter nodes. + # collect-nodes = [ + # { + # matches = [ + # { + # ## This matches an ALSA device filter node + # node.name = "filter-evice-node-name" + # } + # { + # ## This matches an ALSA stream filter node + # node.name = "filter-stream-node-name" + # } + # ] + # actions = { + # ## MANDATORY: This action is used to specify where to collect the + # ## matched nodes + # select-route-device = 3 + # } + # } + # ] + # } + # } +] diff --git a/src/scripts/default-nodes/apply-default-node.lua b/src/scripts/default-nodes/apply-default-node.lua index 608118b7..56ec8d77 100644 --- a/src/scripts/default-nodes/apply-default-node.lua +++ b/src/scripts/default-nodes/apply-default-node.lua @@ -23,7 +23,10 @@ SimpleEventHook { local selected_node = event:get_data ("selected-node") local om = source:call ("get-object-manager", "metadata") - local metadata = om:lookup { Constraint { "metadata.name", "=", "default" } } + local metadata = om:lookup { + Constraint { "metadata.name", "=", "default" }, + Constraint { "wireplumber.collection", "-" }, + } if metadata == nil then return end diff --git a/src/scripts/default-nodes/find-selected-default-node.lua b/src/scripts/default-nodes/find-selected-default-node.lua index 8ece52cd..e9b60d12 100644 --- a/src/scripts/default-nodes/find-selected-default-node.lua +++ b/src/scripts/default-nodes/find-selected-default-node.lua @@ -37,7 +37,10 @@ SimpleEventHook { local props = event:get_properties () local def_node_type = props ["default-node.type"] local metadata_om = source:call ("get-object-manager", "metadata") - local metadata = metadata_om:lookup { Constraint { "metadata.name", "=", "default" } } + local metadata = metadata_om:lookup { + Constraint { "metadata.name", "=", "default" }, + Constraint { "wireplumber.collection", "-" }, + } local obj = metadata:find (0, "default.configured." .. def_node_type) if not obj then diff --git a/src/scripts/default-nodes/rescan.lua b/src/scripts/default-nodes/rescan.lua index 5ac30c1b..98a2bd09 100644 --- a/src/scripts/default-nodes/rescan.lua +++ b/src/scripts/default-nodes/rescan.lua @@ -20,11 +20,13 @@ SimpleEventHook { Constraint { "event.type", "c", "session-item-added", "session-item-removed" }, Constraint { "event.session-item.interface", "=", "linkable" }, Constraint { "media.class", "#", "Audio/*" }, + Constraint { "collection.name", "-" }, }, EventInterest { Constraint { "event.type", "c", "session-item-added", "session-item-removed" }, Constraint { "event.session-item.interface", "=", "linkable" }, Constraint { "media.class", "#", "Video/*" }, + Constraint { "collection.name", "-" }, }, EventInterest { Constraint { "event.type", "=", "metadata-changed" }, @@ -32,6 +34,7 @@ SimpleEventHook { Constraint { "event.subject.key", "c", "default.configured.audio.sink", "default.configured.audio.source", "default.configured.video.source" }, + Constraint { "wireplumber.collection", "-" }, }, EventInterest { Constraint { "event.type", "=", "device-params-changed"}, @@ -96,13 +99,14 @@ function collectAvailableNodes (si_om, devices_om, port_direction, media_classes for linkable in si_om:iterate { type = "SiLinkable", Constraint { "media.class", "c", table.unpack (media_classes) }, + Constraint { "collection.name", "-" }, } do local node = linkable:get_associated_proxy ("node") local node_props = node.properties -- check that the node has ports in the requested direction if not node:lookup_port { - Constraint { "port.direction", "=", port_direction } + Constraint { "port.direction", "=", port_direction }, } then goto next_linkable end diff --git a/src/scripts/default-nodes/state-default-nodes.lua b/src/scripts/default-nodes/state-default-nodes.lua index 1f379daf..2fd021b7 100644 --- a/src/scripts/default-nodes/state-default-nodes.lua +++ b/src/scripts/default-nodes/state-default-nodes.lua @@ -73,6 +73,7 @@ store_configured_default_nodes_hook = SimpleEventHook { Constraint { "event.subject.key", "c", "default.configured.audio.sink", "default.configured.audio.source", "default.configured.video.source" }, + Constraint { "wireplumber.collection", "-" }, }, }, execute = function (event) @@ -119,6 +120,7 @@ metadata_added_hook = SimpleEventHook { EventInterest { Constraint { "event.type", "=", "metadata-added" }, Constraint { "metadata.name", "=", "default" }, + Constraint { "wireplumber.collection", "-" }, }, }, execute = function (event) diff --git a/src/scripts/device/create-alsa-loopback.lua b/src/scripts/device/create-alsa-loopback.lua new file mode 100644 index 00000000..bc3066fa --- /dev/null +++ b/src/scripts/device/create-alsa-loopback.lua @@ -0,0 +1,478 @@ +-- WirePlumber +-- +-- Copyright © 2025 Collabora Ltd. +-- @author Julian Bouzas +-- +-- SPDX-License-Identifier: MIT + +-- This collect-global.lua script collects globals into collections. + +cutils = require ("common-utils") +log = Log.open_topic ("s-device") + +config = {} +config.rules = Conf.get_section_as_json ("loopback.alsa.rules", Json.Array {}) + +local alsa_loopback_ids = {} +local alsa_loopbacks = {} + +function getProfileLoopbackIds (profile) + local loopback_ids = {} + if type (profile.classes) == "table" and profile.classes.pod_type == "Struct" then + for _, p in ipairs (profile.classes) do + if type (p) == "table" and p.pod_type == "Struct" then + local i = 1 + while true do + local k, v = p [i], p [i+1] + i = i + 2 + if not k or not v then + break + end + if k == "card.profile.devices" and + type (v) == "table" and v.pod_type == "Array" then + for _, dev_id in ipairs (v) do + loopback_ids [dev_id + 1] = true + end + end + end + end + end + end + return loopback_ids +end + +AsyncEventHook { + name = "device/evaluate-loopbacks", + interests = { + EventInterest { + Constraint { "event.type", "=", "device-params-changed" }, + Constraint { "event.subject.param-id", "=", "Profile" }, + Constraint { "device.api", "=", "alsa" }, + }, + }, + steps = { + start = { + next = "none", + execute = function (event, transition) + local source = event:get_source () + local device = event:get_subject () + local create_loopback_info = nil + local collect_node_rules = Json.Array {} + local loopback_info = {} + + -- Check if there are matching rules for this device + JsonUtils.match_rules (config.rules, device.properties, function (action, value) + if action == "create-loopback" then + create_loopback_info = value:parse () + elseif action == "collect-nodes" then + collect_node_rules = value + end + return true + end) + if create_loopback_info == nil then + transition:advance () + return + end + + -- Populate the loopback_info table from the create_loopback_info table + for _, info in pairs(create_loopback_info) do + local id = info ["route-device"] + if id ~= nil and tonumber (id) >= 0 then + loopback_info [tonumber (id) + 1] = {} + loopback_info [tonumber (id) + 1].dynamic = info ["dynamic"] or false + loopback_info [tonumber (id) + 1].device_node_props = info ["device-node-props"] or {} + loopback_info [tonumber (id) + 1].stream_node_props = info ["stream-node-props"] or {} + else + log:warning (device, + "Found create loopback info without valid route-device property. Ignoring...") + end + end + + -- Get routes information + device:enum_params ("EnumRoute", function (enum_route_it, e) + -- check for error + if e then + transition:return_error ("failed to enum routes: " + .. tostring (e)); + return + end + + -- Make sure the device is still valid + if (device:get_active_features() & Feature.Proxy.BOUND) == 0 then + transition:advance () + return + end + + -- Get device routes info + local routes_info = {} + for p in enum_route_it:iterate() do + local route = cutils.parseParam (p, "EnumRoute") + if not route then + goto skip_enum_route + end + + for _, id in ipairs(route.devices) do + if routes_info [id + 1] == nil then + routes_info [id + 1] = {} + end + routes_info [id + 1].available = route.available + routes_info [id + 1].priority = route.priority + routes_info [id + 1].direction = route.direction + routes_info [id + 1].name = route.name + routes_info [id + 1].media_class = + route.direction == "Input" and "Audio/Source" or "Audio/Sink" + end + + ::skip_enum_route:: + end + + -- Make sure we found route info for all configured loopbacks + local all_loopback_info_found = true + for id, _ in pairs(loopback_info) do + if routes_info [id] == nil then + all_loopback_info_found = false + break + end + end + if not all_loopback_info_found then + transition:return_error ( + "Could not find route info for configured ALSA loopback IDs"); + return + end + + -- Get the current profile + local profile = nil + for p in device:iterate_params ("Profile") do + profile = cutils.parseParam (p, "Profile") + break + end + assert (profile) + + -- Get current and old loopback Ids + local new_loopback_ids = getProfileLoopbackIds (profile) + local old_loopback_ids = alsa_loopback_ids [device.id] ~= nil and + alsa_loopback_ids [device.id] or {} + alsa_loopback_ids [device.id] = new_loopback_ids + + -- Create dynamic loopbacks + for id, _ in ipairs(new_loopback_ids) do + local info = loopback_info [id] + if old_loopback_ids [id] == nil and info ~= nil and info.dynamic then + local e = source:call ("create-event", "create-loopback", device, nil) + e:set_data ("loopback-id", id - 1) + e:set_data ("media-class", routes_info [id].media_class) + e:set_data ("device-node-props", info.device_node_props) + e:set_data ("stream-node-props", info.stream_node_props) + e:set_data ("collect-node-rules", collect_node_rules) + EventDispatcher.push_event (e) + end + end + + -- Destroy dynamic loopbacks + for id, _ in ipairs(old_loopback_ids) do + local info = loopback_info [id] + if new_loopback_ids [id] == nil and info ~= nil and info.dynamic then + local e = source:call ("create-event", "destroy-loopback", device, nil) + e:set_data ("loopback-id", id - 1) + e:set_data ("media-class", routes_info [id].media_class) + e:set_data ("device-node-props", info.device_node_props) + e:set_data ("stream-node-props", info.stream_node_props) + e:set_data ("collect-node-rules", collect_node_rules) + EventDispatcher.push_event (e) + end + end + + -- Create static loopbacks if never created before + for id, info in pairs(loopback_info) do + if not info.dynamic and + (alsa_loopbacks [device.id] == nil or alsa_loopbacks [device.id][id] == nil) then + local e = source:call ("create-event", "create-loopback", device, nil) + e:set_data ("loopback-id", id - 1) + e:set_data ("media-class", routes_info [id].media_class) + e:set_data ("device-node-props", info.device_node_props) + e:set_data ("stream-node-props", info.stream_node_props) + e:set_data ("collect-node-rules", collect_node_rules) + EventDispatcher.push_event (e) + end + end + + transition:advance () + end) + end + } + } +}:register () + +function CreateLoopback (device, device_media_class, loopback_id, conf_device_node_props, conf_stream_node_props) + local devide_id = device["bound-id"] + local device_props = device.properties + local loopback_name = device_props ["device.name"] .. "." .. tostring (loopback_id) + local stream_media_class = nil + local args = nil + + -- Set stream media class + if device_media_class == "Audio/Sink" then + stream_media_class = "Stream/Output/Audio" + elseif device_media_class == "Audio/Source" then + stream_media_class = "Stream/Input/Audio" + else + log:warning (device, "Device media class '" .. device_media_class .. + "' is not valid") + return nil + end + + -- Set stream props + local stream_raw_props = { + ["node.name"] = string.format ("alsa_loopback_stream.%s", loopback_name), + ["node.description"] = string.format ("ALSA Loopback stream for %s", loopback_name), + ["media.class"] = stream_media_class, + ["alsa.loopback.id"] = loopback_id, + ["device.id"] = devide_id, + ["node.passive"] = true, + ["node.dont-fallback"] = true, + ["node.linger"] = true, + } + for k, v in pairs (conf_stream_node_props) do + stream_raw_props [k] = v + end + local stream_props = Json.Object (stream_raw_props) + + -- Set device props + local devices_raw_props = { + ["node.name"] = string.format ("alsa_loopback_device.%s", loopback_name), + ["node.description"] = string.format ("ALSA Loopback device for %s", loopback_name), + ["media.class"] = device_media_class, + ["alsa.loopback.id"] = loopback_id, + ["device.id"] = devide_id, + } + for k, v in pairs (conf_device_node_props) do + devices_raw_props [k] = v + end + local device_props = Json.Object (devices_raw_props) + + -- Set args + if device_media_class == "Audio/Sink" then + args = Json.Object { + ["playback.props"] = stream_props, + ["capture.props"] = device_props + } + elseif device_media_class == "Audio/Source" then + args = Json.Object { + ["playback.props"] = device_props, + ["capture.props"] = stream_props + } + else + return nil + end + + -- Load loopback + return LocalModule("libpipewire-module-loopback", args:get_data(), {}) +end + +function getCollectionName (device_name, loopback_id) + return "alsa_loopback." .. device_name .. "." .. tostring (loopback_id) +end + +AsyncEventHook { + name = "device/create-loopback", + interests = { + EventInterest { + Constraint { "event.type", "=", "create-loopback" }, + Constraint { "device.api", "=", "alsa" }, + }, + }, + steps = { + start = { + next = "none", + execute = function (event, transition) + local source = event:get_source () + local nodes_om = source:call ("get-object-manager", "node") + local device = event:get_subject () + local device_name = device:get_property ("device.name") + + -- Get the loopback ID + local loopback_id = event:get_data ("loopback-id") + if loopback_id == nil then + transition:return_error ("Event data does not have loopback ID"); + return + end + + -- Get the media class property + local media_class = event:get_data ("media-class") + if media_class == nil then + transition:return_error ("Event data does not have media class property"); + return + end + + -- Get the device properties + local device_node_props = event:get_data ("device-node-props") + if device_node_props == nil then + transition:return_error ("Event data does not have device properties"); + return + end + + -- Get the stream properties + local stream_node_props = event:get_data ("stream-node-props") + if stream_node_props == nil then + transition:return_error ("Event data does not have stream properties"); + return + end + + -- Get the collect node rules + local collect_node_rules = event:get_data ("collect-node-rules") + if collect_node_rules == nil then + transition:return_error ("Event data does not have collect node rules"); + return + end + + -- Create the collection for this loopback + local collection_name = getCollectionName (device_name, loopback_id) + CollectionManager.create_collection (collection_name, function (_, e) + -- Make sure the collection was created + if e ~= nil then + transition:return_error ("Failed to create collection '" .. + collection_name .. "': " .. tostring (e)); + return + end + log:info (device, "Created ALSA loopback collection for ID " .. + tostring (loopback_id) .. ": " .. collection_name) + + -- Create the loopback module + if alsa_loopbacks [device.id] == nil then + alsa_loopbacks [device.id] = {} + end + alsa_loopbacks [device.id][loopback_id] = CreateLoopback (device, + media_class, loopback_id, device_node_props, stream_node_props) + log:info (device, "Created loopback module for ID " .. + tostring (loopback_id)) + + -- Collect nodes to collection if any there are rules for them + for node in nodes_om:iterate () do + local node_name = node:get_property ("node.name") + JsonUtils.match_rules (collect_node_rules, node.properties, function (action, value) + if action == "select-route-device" and value:is_int () and value:parse () == loopback_id then + if not CollectionManager.collect_global (node, collection_name) then + log:warning (device, "Failed to collect node '" .. node_name .. + "' into '" .. collection_name .. "'") + else + log:info (device, "Collected node '" .. node_name .. "' into '" .. + collection_name .. "'") + end + end + end) + end + + transition:advance () + end) + end, + }, + }, +}:register () + +SimpleEventHook { + name = "device/destroy-loopback", + interests = { + EventInterest { + Constraint { "event.type", "=", "destroy-loopback" }, + Constraint { "device.api", "=", "alsa" }, + }, + }, + execute = function (event) + local device = event:get_subject () + + -- Get the loopback ID + local loopback_id = event:get_data ("loopback-id") + if loopback_id == nil then + log:warning (device, "Event data does not have loopback ID") + return + end + + -- Destroy loopback module + if alsa_loopbacks [device.id] == nil then + alsa_loopbacks [device.id] = {} + end + alsa_loopbacks [device.id][loopback_id] = nil + log:info (device, "Destroyed loopback module for ID " .. + tostring (loopback_id)) + end +}:register () + +SimpleEventHook { + name = "device/destroy-loopbacks", + interests = { + EventInterest { + Constraint { "event.type", "=", "device-removed" }, + Constraint { "device.api", "=", "alsa" }, + }, + }, + execute = function (event) + local device = event:get_subject () + + -- Remove all loopbacks associated with this device + alsa_loopbacks [device.id] = nil + end +}:register () + +SimpleEventHook { + name = "device/collect-node", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-collection" }, + Constraint { "event.subject.type", "=", "node" }, + }, + }, + execute = function (event, transition) + local source = event:get_source () + local node = event:get_subject () + + -- Get the node device Id + local device_id = node.properties:get_int ("device.id") + if device_id == nil then + return + end + + -- Get the associated device + local devices_om = source:call ("get-object-manager", "device") + local device = devices_om:lookup { + Constraint { "bound-id", "=", device_id, type = "gobject" }, + } + if device == nil then + return + end + + -- Get the loopback ID + local loopback_id = node.properties:get_int ("card.profile.device") + if loopback_id == nil then + loopback_id = node.properties:get_int ("alsa.loopback.id") + if loopback_id == nil then + return + end + end + + -- Don't collect the ALSA loopback device node + local link_group = node:get_property ("node.link-group") + local media_class = node:get_property ("media.class") + if link_group ~= nil and link_group:find ("^loopback") and + not media_class:find ("^Stream/") then + return + end + + -- Check if there is a collection for this ALSA loopback + local device_name = device:get_property ("device.name") + local collection_name = getCollectionName (device_name, loopback_id) + if not CollectionManager.has_collection (collection_name) then + return + end + + -- Collect the node into the device collection + local node_name = node:get_property ("node.name") + if not CollectionManager.collect_global (node, collection_name) then + log:warning (device, "Failed to collect node '" .. node_name .. + "' into '" .. collection_name .. "'") + return + end + + log:info (device, "Collected node '" .. node_name .. "' into '" .. + collection_name .. "'") + end, +}:register () diff --git a/src/scripts/lib/filter-utils.lua b/src/scripts/lib/filter-utils.lua index 3a638468..1e185f98 100644 --- a/src/scripts/lib/filter-utils.lua +++ b/src/scripts/lib/filter-utils.lua @@ -129,7 +129,10 @@ local function getFilterSmartTarget (metadata, node, om) -- Find target local target = nil - for si_target in om:iterate { type = "SiLinkable" } do + for si_target in om:iterate { + type = "SiLinkable", + Constraint { "collection.name", "-" }, + } do local n_target = si_target:get_associated_proxy ("node") if n_target == nil then goto skip_target @@ -291,7 +294,10 @@ local function rescanFilters (om, metadata_om) Log.info ("rescanning filters...") - for si in om:iterate { type = "SiLinkable" } do + for si in om:iterate { + type = "SiLinkable", + Constraint { "collection.name", "-" }, + } do local filter = {} local n = si:get_associated_proxy ("node") @@ -337,7 +343,8 @@ local function rescanFilters (om, metadata_om) filter.stream_si = om:lookup { type = "SiLinkable", Constraint { "node.link-group", "=", filter.link_group }, - Constraint { "media.class", "#", "Stream/*", type = "pw-global" } + Constraint { "media.class", "#", "Stream/*", type = "pw-global" }, + Constraint { "collection.name", "-" }, } -- Add the filter to the list sorted by before and after diff --git a/src/scripts/linking/alsa-loopback/find-alsa-target.lua b/src/scripts/linking/alsa-loopback/find-alsa-target.lua new file mode 100644 index 00000000..051ee561 --- /dev/null +++ b/src/scripts/linking/alsa-loopback/find-alsa-target.lua @@ -0,0 +1,102 @@ +-- WirePlumber +-- +-- Copyright © 2025 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Finds the hardware target for a linkable in 'alsa_loopback.*' collection + +lutils = require ("linking-utils") +cutils = require ("common-utils") +log = Log.open_topic ("s-linking") + +SimpleEventHook { + name = "linking/alsa-loopback/find-alsa-target", + before = "linking/prepare-link", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "#", "alsa_loopback.*" }, + }, + }, + execute = function (event) + local source, om, si, si_props, si_flags, target = + lutils:unwrap_select_target_event (event) + + -- Bypass the hook if the target is already picked up + if target then + return + end + + -- Get the collection name + local collection_name = si_props["collection.name"] + assert (collection_name) + + local target_direction = cutils.getTargetDirection (si_props) + local target_picked = nil + local target_can_passthrough = false + local target_priority = 0 + + log:info (string.format ("handling item %d: %s (%s)", si.id, + si_props ["node.name"], si_props ["node.id"])) + + -- Find the hardware target (session items without node.link-group property) + -- matching the collection name + for target in om:iterate { + type = "SiLinkable", + Constraint { "item.node.type", "=", "device" }, + Constraint { "item.node.direction", "=", target_direction }, + Constraint { "media.type", "=", si_props ["media.type"] }, + Constraint { "collection.name", "=", collection_name }, + Constraint { "node.link-group", "-" }, + } do + local target_props = target.properties + local priority = tonumber (target_props ["priority.session"]) or 0 + + log:debug (string.format ("Looking at: %s (%s)", + target_props ["node.name"], target_props ["node.id"])) + + if not lutils.canLink (si_props, target) then + log:debug ("... cannot link, skip linkable") + goto skip_linkable + end + + local passthrough_compatible, can_passthrough = + lutils.checkPassthroughCompatibility (si, target) + if not passthrough_compatible then + log:debug ("... passthrough is not compatible, skip linkable") + goto skip_linkable + end + + if target_picked == nil or priority > target_priority then + log:debug ("... picked") + target_picked = target + target_can_passthrough = can_passthrough + target_priority = priority + end + + ::skip_linkable:: + end + + local can_passthrough, passthrough_compatible + if target then + passthrough_compatible, can_passthrough = + lutils.checkPassthroughCompatibility (si, target) + if lutils.canLink (si_props, target) and passthrough_compatible then + target_picked = true + end + end + + if target_picked then + log:info (si, + string.format ("... hardware target picked: %s (%s), can_passthrough:%s", + target_picked.properties ["node.name"], + target_picked.properties ["node.id"], + tostring (target_can_passthrough))) + si_flags.can_passthrough = target_can_passthrough + event:set_data ("target", target_picked) + else + event:stop_processing () + end + end +}:register () diff --git a/src/scripts/linking/alsa-loopback/find-filter-target.lua b/src/scripts/linking/alsa-loopback/find-filter-target.lua new file mode 100644 index 00000000..2296efe8 --- /dev/null +++ b/src/scripts/linking/alsa-loopback/find-filter-target.lua @@ -0,0 +1,97 @@ +-- WirePlumber +-- +-- Copyright © 2026 Collabora Ltd. +-- +-- SPDX-License-Identifier: MIT +-- +-- Finds the filter target for a linkable in 'alsa_loopback.*' collection + +lutils = require ("linking-utils") +cutils = require ("common-utils") +log = Log.open_topic ("s-linking") + +SimpleEventHook { + name = "linking/alsa-loopback/find-filter-target", + before = "linking/alsa-loopback/find-alsa-target", + interests = { + EventInterest { + Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "#", "alsa_loopback.*" }, + }, + }, + execute = function (event) + local source, om, si, si_props, si_flags, target = + lutils:unwrap_select_target_event (event) + + -- Bypass the hook if the target is already picked up + if target then + return + end + + -- Get the collection name + local collection_name = si_props["collection.name"] + assert (collection_name) + + local target_direction = cutils.getTargetDirection (si_props) + local target_picked = nil + local target_can_passthrough = false + + log:info (string.format ("handling item %d: %s (%s)", si.id, + si_props ["node.name"], si_props ["node.id"])) + + -- Find the first filter target (session items with node.link-group property + -- that is not an alsa loopback item) matching the collection name + for target in om:iterate { + type = "SiLinkable", + Constraint { "item.node.type", "=", "device" }, + Constraint { "item.node.direction", "=", target_direction }, + Constraint { "media.type", "=", si_props ["media.type"] }, + Constraint { "collection.name", "=", collection_name }, + Constraint { "node.link-group", "+" }, + Constraint { "alsa.loopback.id", "-" }, + } do + local target_props = target.properties + + log:debug (string.format ("Looking at: %s (%s)", + target_props ["node.name"], target_props ["node.id"])) + + if not lutils.canLink (si_props, target) then + log:debug ("... cannot link, skip linkable") + goto skip_linkable + end + + local passthrough_compatible, can_passthrough = + lutils.checkPassthroughCompatibility (si, target) + if not passthrough_compatible then + log:debug ("... passthrough is not compatible, skip linkable") + goto skip_linkable + end + + log:debug ("... picked") + target_picked = target + target_can_passthrough = can_passthrough + break + + ::skip_linkable:: + end + + local can_passthrough, passthrough_compatible + if target then + passthrough_compatible, can_passthrough = + lutils.checkPassthroughCompatibility (si, target) + if lutils.canLink (si_props, target) and passthrough_compatible then + target_picked = true + end + end + + if target_picked then + log:info (si, + string.format ("... filter target picked: %s (%s), can_passthrough:%s", + target_picked.properties ["node.name"], + target_picked.properties ["node.id"], + tostring (target_can_passthrough))) + si_flags.can_passthrough = target_can_passthrough + event:set_data ("target", target_picked) + end + end +}:register () diff --git a/src/scripts/linking/find-audio-group-target.lua b/src/scripts/linking/default/find-audio-group-target.lua similarity index 96% rename from src/scripts/linking/find-audio-group-target.lua rename to src/scripts/linking/default/find-audio-group-target.lua index ffd2e182..3233439d 100644 --- a/src/scripts/linking/find-audio-group-target.lua +++ b/src/scripts/linking/default/find-audio-group-target.lua @@ -18,6 +18,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) @@ -56,6 +57,7 @@ SimpleEventHook { Constraint { "item.node.type", "=", "device" }, Constraint { "item.node.direction", "=", target_direction }, Constraint { "media.type", "=", si_props ["media.type"] }, + Constraint { "collection.name", "-" }, } do target_node = target:get_associated_proxy ("node") target_node_props = target_node.properties diff --git a/src/scripts/linking/find-best-target.lua b/src/scripts/linking/default/find-best-target.lua similarity index 97% rename from src/scripts/linking/find-best-target.lua rename to src/scripts/linking/default/find-best-target.lua index 46b31507..4cc332ed 100644 --- a/src/scripts/linking/find-best-target.lua +++ b/src/scripts/linking/default/find-best-target.lua @@ -21,6 +21,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) @@ -46,6 +47,7 @@ SimpleEventHook { Constraint { "item.node.type", "=", "device" }, Constraint { "item.node.direction", "=", target_direction }, Constraint { "media.type", "=", si_props ["media.type"] }, + Constraint { "collection.name", "-" }, } do local target_props = target.properties local target_node_id = target_props ["node.id"] diff --git a/src/scripts/linking/find-default-target.lua b/src/scripts/linking/default/find-default-target.lua similarity index 97% rename from src/scripts/linking/find-default-target.lua rename to src/scripts/linking/default/find-default-target.lua index 3caaf4f6..ce721f15 100644 --- a/src/scripts/linking/find-default-target.lua +++ b/src/scripts/linking/default/find-default-target.lua @@ -19,6 +19,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) diff --git a/src/scripts/linking/find-defined-target.lua b/src/scripts/linking/default/find-defined-target.lua similarity index 95% rename from src/scripts/linking/find-defined-target.lua rename to src/scripts/linking/default/find-defined-target.lua index 1dfb6d4b..dcd53e43 100644 --- a/src/scripts/linking/find-defined-target.lua +++ b/src/scripts/linking/default/find-defined-target.lua @@ -19,6 +19,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) @@ -75,12 +76,16 @@ SimpleEventHook { target = om:lookup { type = "SiLinkable", Constraint { target_key, "=", target_value }, + Constraint { "collection.name", "-" }, } if target and lutils.canLink (si_props, target) then target_picked = true end elseif target_value then - for lnkbl in om:iterate { type = "SiLinkable" } do + for lnkbl in om:iterate { + type = "SiLinkable", + Constraint { "collection.name", "-" }, + } do local target_props = lnkbl.properties if (target_props ["node.name"] == target_value or target_props ["object.path"] == target_value) and diff --git a/src/scripts/linking/find-filter-target.lua b/src/scripts/linking/default/find-filter-target.lua similarity index 98% rename from src/scripts/linking/find-filter-target.lua rename to src/scripts/linking/default/find-filter-target.lua index b82879ca..719648c0 100644 --- a/src/scripts/linking/find-filter-target.lua +++ b/src/scripts/linking/default/find-filter-target.lua @@ -38,6 +38,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) diff --git a/src/scripts/linking/find-media-role-sink-target.lua b/src/scripts/linking/default/find-media-role-sink-target.lua similarity index 84% rename from src/scripts/linking/find-media-role-sink-target.lua rename to src/scripts/linking/default/find-media-role-sink-target.lua index d6ab4c38..2b22543b 100644 --- a/src/scripts/linking/find-media-role-sink-target.lua +++ b/src/scripts/linking/default/find-media-role-sink-target.lua @@ -19,6 +19,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) @@ -45,6 +46,7 @@ SimpleEventHook { type = "SiLinkable", Constraint { "media.class", "=", "Audio/Sink" }, Constraint { "node.link-group", "=", link_group }, + Constraint { "collection.name", "-" }, } if input_node == nil then @@ -62,20 +64,22 @@ SimpleEventHook { type = "SiLinkable", Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, Constraint { "node.name", "=", target_name }, + Constraint { "collection.name", "-" }, } if si_target == nil then - si_target = om:lookup { - type = "SiLinkable", - Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, - Constraint { "node.nick", "=", target_name }, - } + si_target = om:lookup { + type = "SiLinkable", + Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" }, + Constraint { "node.nick", "=", target_name }, + Constraint { "collection.name", "-" }, + } end if si_target then - log:info (si, + log:info (si, string.format ("... role based sink target picked: %s (%s)", tostring (si_target.properties ["node.name"]), tostring (si_target.properties ["node.id"]))) - event:set_data ("target", si_target) + event:set_data ("target", si_target) end end }:register () diff --git a/src/scripts/linking/find-media-role-target.lua b/src/scripts/linking/default/find-media-role-target.lua similarity index 95% rename from src/scripts/linking/find-media-role-target.lua rename to src/scripts/linking/default/find-media-role-target.lua index d654b0c2..4c0682a8 100644 --- a/src/scripts/linking/find-media-role-target.lua +++ b/src/scripts/linking/default/find-media-role-target.lua @@ -18,6 +18,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) @@ -41,6 +42,7 @@ SimpleEventHook { Constraint { "item.node.direction", "=", target_direction }, Constraint { "device.intended-roles", "+" }, Constraint { "media.type", "=", si_props["media.type"] }, + Constraint { "collection.name", "-" }, } do local roles_json = si_target.properties["device.intended-roles"] diff --git a/src/scripts/linking/find-user-target.lua.example b/src/scripts/linking/default/find-user-target.lua.example similarity index 95% rename from src/scripts/linking/find-user-target.lua.example rename to src/scripts/linking/default/find-user-target.lua.example index 723913c5..8f0836fd 100644 --- a/src/scripts/linking/find-user-target.lua.example +++ b/src/scripts/linking/default/find-user-target.lua.example @@ -15,6 +15,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) diff --git a/src/scripts/linking/get-filter-from-target.lua b/src/scripts/linking/default/get-filter-from-target.lua similarity index 98% rename from src/scripts/linking/get-filter-from-target.lua rename to src/scripts/linking/default/get-filter-from-target.lua index 8f52191c..f98951f4 100644 --- a/src/scripts/linking/get-filter-from-target.lua +++ b/src/scripts/linking/default/get-filter-from-target.lua @@ -22,6 +22,7 @@ SimpleEventHook { interests = { EventInterest { Constraint { "event.type", "=", "select-target" }, + Constraint { "collection.name", "-" }, }, }, execute = function (event) diff --git a/src/scripts/linking/rescan-media-role-links.lua b/src/scripts/linking/rescan-media-role-links.lua index f2747e73..14419c75 100644 --- a/src/scripts/linking/rescan-media-role-links.lua +++ b/src/scripts/linking/rescan-media-role-links.lua @@ -68,6 +68,7 @@ function getSuspendPlaybackFromMetadata (om) local metadata = om:lookup { type = "metadata", Constraint { "metadata.name", "=", "default" }, + Constraint { "wireplumber.collection", "-" }, } if metadata then local value = metadata:find(0, "suspend.playback") @@ -92,6 +93,7 @@ AsyncEventHook { Constraint { "event.type", "=", "metadata-changed" }, Constraint { "metadata.name", "=", "default" }, Constraint { "event.subject.key", "=", "suspend.playback" }, + Constraint { "wireplumber.collection", "-" }, } }, steps = { diff --git a/src/scripts/linking/rescan.lua b/src/scripts/linking/rescan.lua index 1cae5b86..aeb172dd 100644 --- a/src/scripts/linking/rescan.lua +++ b/src/scripts/linking/rescan.lua @@ -234,11 +234,13 @@ SimpleEventHook { Constraint { "metadata.name", "=", "default" }, Constraint { "event.subject.key", "c", "default.audio.source", "default.audio.sink", "default.video.source" }, + Constraint { "wireplumber.collection", "-" }, }, -- on any "filters" metadata changed EventInterest { Constraint { "event.type", "=", "metadata-changed" }, Constraint { "metadata.name", "=", "filters" }, + Constraint { "wireplumber.collection", "-" }, }, }, execute = function (event) @@ -312,6 +314,7 @@ function handleMoveSetting (enable) Constraint { "event.type", "=", "metadata-changed" }, Constraint { "metadata.name", "=", "default" }, Constraint { "event.subject.key", "c", "target.object", "target.node" }, + Constraint { "wireplumber.collection", "-" }, }, }, execute = function (event) diff --git a/src/scripts/node/create-item.lua b/src/scripts/node/create-item.lua index 259ebc83..f77e0dc2 100644 --- a/src/scripts/node/create-item.lua +++ b/src/scripts/node/create-item.lua @@ -17,6 +17,7 @@ function configProperties (node) local properties = node.properties local media_class = properties ["media.class"] or "" local factory_name = properties ["factory.name"] or "" + local collection = CollectionManager.get_global_collection (node) -- ensure a media.type is set if not properties ["media.type"] then @@ -44,6 +45,7 @@ function configProperties (node) (factory_name == "api.alsa.pcm.sink" or factory_name == "api.bluez5.a2dp.sink") and Settings.get_boolean ("node.features.audio.mono") properties ["node.id"] = node ["bound-id"] + properties ["collection.name"] = collection and collection:get_name () or nil -- set the default media.role, if configured -- avoid Settings.get_string(), as it will parse the default "null" value @@ -61,69 +63,76 @@ AsyncEventHook { name = "node/create-item", interests = { EventInterest { - Constraint { "event.type", "=", "node-added" }, + Constraint { "event.type", "c", "node-added", "node-collected", "node-dropped" }, Constraint { "media.class", "#", "Stream/*", type = "pw-global" }, }, EventInterest { - Constraint { "event.type", "=", "node-added" }, + Constraint { "event.type", "c", "node-added", "node-collected", "node-dropped" }, Constraint { "media.class", "#", "Video/*", type = "pw-global" }, }, EventInterest { - Constraint { "event.type", "=", "node-added" }, + Constraint { "event.type", "c", "node-added", "node-collected", "node-dropped" }, Constraint { "media.class", "#", "Audio/*", type = "pw-global" }, Constraint { "wireplumber.is-virtual", "-", type = "pw" }, }, }, steps = { start = { - next = "register", + next = "none", execute = function (event, transition) local node = event:get_subject () local id = node.id - local item - local item_type - local media_class = node.properties ['media.class'] + local node_name = node.properties ['node.name'] + local collection = CollectionManager.get_global_collection (node) + local collection_name = collection and collection:get_name () or nil + + -- Just return if the item already exists with the same collection name + if items [id] and + items [id]:get_property ("collection.name") == collection_name then + transition:advance () + return + end + + -- Destroy the old item if any + if items [id] then + log:info (items [id], "destroying item for node " .. tostring (id)) + items [id]:remove () + items [id] = nil + end + + -- get the item type + local item_type = nil if string.find (media_class, "Audio") then item_type = "si-audio-adapter" else item_type = "si-node" end - log:info (node, "creating item for node -> " .. item_type) - -- create item - item = SessionItem (item_type) + local item = SessionItem (item_type) items [id] = item + log:info (item, "created item for node " .. tostring (id) .. ": " .. node_name) -- configure item if not item:configure (configProperties (node)) then - transition:return_error ("failed to configure item for node " - .. tostring (id)) + transition:return_error ("failed to configure item for node " .. tostring (id)) return end -- activate item item:activate (Features.ALL, function (_, e) if e then - transition:return_error ("failed to activate item: " - .. tostring (e)); - else - transition:advance () + transition:return_error ("failed to activate item for node " .. tostring (id) .. + ": " .. tostring (e)); + return end - end) - end, - }, - register = { - next = "none", - execute = function (event, transition) - local node = event:get_subject () - local bound_id = node ["bound-id"] - local item = items [node.id] - log:info (item, "activated item for node " .. tostring (bound_id)) - item:register () - transition:advance () + -- register item + log:info (item, "activated item for node " .. tostring (id) .. ": " .. node_name) + item:register () + transition:advance () + end) end, }, }, diff --git a/src/tools/wpctl.c b/src/tools/wpctl.c index e059a532..15f149bd 100644 --- a/src/tools/wpctl.c +++ b/src/tools/wpctl.c @@ -89,6 +89,10 @@ static struct { gboolean reset; } settings; + struct { + const gchar *collection_name; + } collections; + struct { guint64 id; const char *level; @@ -1592,6 +1596,139 @@ out: g_main_loop_quit (self->loop); } +/* collections */ + +static gboolean +collections_parse_positional (gint argc, gchar ** argv, GError **error) +{ + cmdline.collections.collection_name = NULL; + + if (argc >= 3) + cmdline.collections.collection_name = argv[2]; + + return TRUE; +} + +static gboolean +collections_prepare (WpCtl * self, GError ** error) +{ + wp_object_manager_add_interest (self->om, WP_TYPE_GLOBAL_PROXY, NULL); + wp_object_manager_request_object_features (self->om, WP_TYPE_GLOBAL_PROXY, + WP_OBJECT_FEATURES_ALL); + return TRUE; +} + +static void +print_global (WpCollection *collection, WpGlobalProxy *global) +{ + g_autoptr (WpProperties) props = NULL; + const gchar *global_name = NULL; + gchar global_type = '-'; + guint32 bound_id; + + props = wp_global_proxy_get_global_properties (global); + bound_id = wp_proxy_get_bound_id (WP_PROXY (global)); + + if (WP_IS_NODE (global)) { + global_name = wp_properties_get (props, "node.name"); + global_type = 'n'; + } else if (WP_IS_PORT (global)) { + global_name = wp_properties_get (props, "port.name"); + global_type = 'p'; + } else if (WP_IS_DEVICE (global)) { + global_name = wp_properties_get (props, "device.name"); + global_type = 'd'; + } else if (WP_IS_CLIENT (global)) { + global_name = wp_properties_get (props, "client.name"); + global_type = 'c'; + } else if (WP_IS_METADATA (global)) { + global_name = wp_properties_get (props, "metadata.name"); + global_type = 'm'; + } else if (WP_IS_FACTORY (global)) { + global_name = wp_properties_get (props, "factory.name"); + global_type = 'f'; + } + + g_print (" [%c] %4u. %s\n", global_type, bound_id, + global_name ? global_name : "UNKNOWN"); +} + +static void +print_collection (WpCollectionManager *cm, WpCollection *collection) +{ + const gchar *collection_name = wp_collection_get_name (collection); + g_autoptr (WpMetadata) meta = NULL; + g_autoptr (WpIterator) iter = NULL; + g_auto (GValue) val = G_VALUE_INIT; + guint32 bound_id; + + meta = wp_collection_get_metadata (collection); + if (!meta) + return; + bound_id = wp_proxy_get_bound_id (WP_PROXY (meta)); + + g_print ("%4u. %s\n", + bound_id, + collection_name); + + iter = wp_collection_manager_new_global_iterator (cm, collection_name); + while (wp_iterator_next (iter, &val)) { + WpGlobalProxy *global = g_value_get_object (&val); + print_global (collection, global); + g_value_unset (&val); + } + + printf ("\n"); +} + +static void +print_collections (WpCollectionManager *cm) +{ + g_autoptr (WpIterator) it = NULL; + g_auto (GValue) item = G_VALUE_INIT; + + printf ("Collections:\n\n"); + + it = wp_collection_manager_new_collection_iterator (cm); + while (wp_iterator_next (it, &item)) { + WpCollection *collection = g_value_get_object (&item); + print_collection (cm, collection); + g_value_unset (&item); + } +} + +static void +collections_run (WpCtl * self) +{ + g_autoptr (WpCollectionManager) cm = NULL; + const gchar *collection_name = cmdline.collections.collection_name; + + cm = wp_collection_manager_find (self->core, NULL); + if (!cm) { + printf ("Could not find registered collection manager\n"); + goto error; + } + + /* Print all collections if name is not provided */ + if (collection_name) { + WpCollection *collection; + collection = wp_collection_manager_get_collection (cm, collection_name); + if (collection) + print_collection (cm, collection); + else + printf ("Collection '%s' does not exist\n", collection_name); + } else { + print_collections (cm); + } + + wp_core_sync (self->core, NULL, (GAsyncReadyCallback) async_quit, self); + return; + +error: + self->exit_code = 3; + g_main_loop_quit (self->loop); +} + /* set-log-level */ static gboolean @@ -1840,6 +1977,16 @@ static const struct subcommand { .prepare = settings_prepare, .run = settings_run, }, + { + .name = "collections", + .positional_args = "[COLLECTION]", + .summary = "Shows information about collections", + .description = NULL, + .entries = { { NULL } }, + .parse_positional = collections_parse_positional, + .prepare = collections_prepare, + .run = collections_run, + }, { .name = "set-log-level", .positional_args = "[ID] LEVEL", @@ -1867,6 +2014,22 @@ on_settings_activated (WpSettings *s, GAsyncResult *res, WpCtl *ctl) wp_core_register_object (ctl->core, g_object_ref (s)); } +static void +on_collection_manager_activated (WpCollectionManager *cm, GAsyncResult *res, + WpCtl *ctl) +{ + GError *error = NULL; + + if (!wp_object_activate_finish (WP_OBJECT (cm), res, &error)) { + fprintf (stderr, "%s\n", error->message); + ctl->exit_code = 1; + g_main_loop_quit (ctl->loop); + return; + } + + wp_core_register_object (ctl->core, g_object_ref (cm)); +} + static void on_plugin_loaded (WpCore * core, GAsyncResult * res, WpCtl *ctl) { @@ -1894,6 +2057,7 @@ main (gint argc, gchar **argv) g_autoptr (GError) error = NULL; g_autofree gchar *summary = NULL; g_autoptr (WpSettings) settings = NULL; + g_autoptr (WpCollectionManager) collection_manager = NULL; setlocale (LC_ALL, ""); setlocale (LC_NUMERIC, "C"); @@ -1976,6 +2140,14 @@ main (gint argc, gchar **argv) (GAsyncReadyCallback)on_settings_activated, &ctl); + /* load and register the collection manager */ + collection_manager = wp_collection_manager_new (ctl.core, NULL); + wp_object_activate (WP_OBJECT (collection_manager), + WP_OBJECT_FEATURES_ALL, + NULL, + (GAsyncReadyCallback)on_collection_manager_activated, + &ctl); + /* load required API modules */ ctl.pending_plugins++; wp_core_load_component (ctl.core, "libwireplumber-module-default-nodes-api", diff --git a/tests/script-tester.c b/tests/script-tester.c index e89e76b9..75b9d2d4 100644 --- a/tests/script-tester.c +++ b/tests/script-tester.c @@ -240,12 +240,12 @@ load_components (ScriptRunnerFixture *f, gconstpointer argv) load_component (f, "node/create-item.lua", "script/lua"); - load_component (f, "linking/find-best-target.lua", "script/lua"); - load_component (f, "linking/find-default-target.lua", "script/lua"); - load_component (f, "linking/find-defined-target.lua", "script/lua"); - load_component (f, "linking/find-filter-target.lua", "script/lua"); - load_component (f, "linking/find-media-role-target.lua", "script/lua"); - load_component (f, "linking/get-filter-from-target.lua", "script/lua"); + load_component (f, "linking/default/find-best-target.lua", "script/lua"); + load_component (f, "linking/default/find-default-target.lua", "script/lua"); + load_component (f, "linking/default/find-defined-target.lua", "script/lua"); + load_component (f, "linking/default/find-filter-target.lua", "script/lua"); + load_component (f, "linking/default/find-media-role-target.lua", "script/lua"); + load_component (f, "linking/default/get-filter-from-target.lua", "script/lua"); load_component (f, "linking/link-target.lua", "script/lua"); load_component (f, "linking/prepare-link.lua", "script/lua"); load_component (f, "linking/rescan.lua", "script/lua"); diff --git a/tests/wp/collection-manager.c b/tests/wp/collection-manager.c new file mode 100644 index 00000000..6752297a --- /dev/null +++ b/tests/wp/collection-manager.c @@ -0,0 +1,446 @@ +/* WirePlumber + * + * Copyright © 2025 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#include "../common/base-test-fixture.h" + +#define COLLECTION_A_NAME "sm-collection-a" +#define COLLECTION_B_NAME "sm-collection-b" +#define COLLECTION_C_NAME "sm-collection-c" +#define COLLECTION_MANAGER_METADATA_NAME "sm-collection-manager" + +typedef struct { + WpBaseTestFixture base; + WpImplMetadata *collection_a_metadata; + WpImplMetadata *collection_b_metadata; + WpImplMetadata *collection_manager_metadata; + WpCollectionManager *collection_manager; + WpCollection *collection_a; + WpCollection *collection_b; +} TestCollectionManagerFixture; + +static void +on_metadata_activated (WpMetadata * m, GAsyncResult * res, + gpointer d) +{ + TestCollectionManagerFixture *self = d; + g_assert_true (wp_object_activate_finish (WP_OBJECT (m), res, NULL)); + g_main_loop_quit (self->base.loop); +} + +static void +on_collection_manager_metadata_activated (WpMetadata * m, GAsyncResult * res, + gpointer d) +{ + TestCollectionManagerFixture *self = d; + g_assert_true (wp_object_activate_finish (WP_OBJECT (m), res, NULL)); + + /* Add collections A and B */ + wp_metadata_set (m, 0, COLLECTION_A_NAME, "Spa:String:JSON", "1"); + wp_metadata_set (m, 0, COLLECTION_B_NAME, "Spa:String:JSON", "1"); + + g_main_loop_quit (self->base.loop); +} + +static void +on_collection_manager_ready (WpCollectionManager *s, GAsyncResult *res, + gpointer d) +{ + TestCollectionManagerFixture *self = d; + + g_assert_true (wp_object_activate_finish (WP_OBJECT (s), res, NULL)); + + wp_core_register_object (self->base.core, g_object_ref (s)); + + g_main_loop_quit (self->base.loop); +} + +static void +collection_manager_setup (TestCollectionManagerFixture *self, gconstpointer d) +{ + wp_base_test_fixture_setup (&self->base, WP_BASE_TEST_FLAG_CLIENT_CORE); + + /* Create collection A metadata and activate it */ + self->collection_a_metadata = wp_impl_metadata_new_full ( + self->base.core, COLLECTION_A_NAME, + wp_properties_new ("wireplumber.collection", "true", NULL)); + wp_object_activate (WP_OBJECT (self->collection_a_metadata), + WP_OBJECT_FEATURES_ALL, NULL, + (GAsyncReadyCallback)on_metadata_activated, self); + g_main_loop_run (self->base.loop); + + /* Create collection B metadata and activate it */ + self->collection_b_metadata = wp_impl_metadata_new_full ( + self->base.core, COLLECTION_B_NAME, + wp_properties_new ("wireplumber.collection", "true", NULL)); + wp_object_activate (WP_OBJECT (self->collection_b_metadata), + WP_OBJECT_FEATURES_ALL, NULL, + (GAsyncReadyCallback)on_metadata_activated, self); + g_main_loop_run (self->base.loop); + + /* Create collection manager metadata and activate it */ + self->collection_manager_metadata = wp_impl_metadata_new_full ( + self->base.core, COLLECTION_MANAGER_METADATA_NAME, + wp_properties_new ("wireplumber.collection-manager", "true", NULL)); + wp_object_activate (WP_OBJECT (self->collection_manager_metadata), + WP_OBJECT_FEATURES_ALL, NULL, + (GAsyncReadyCallback)on_collection_manager_metadata_activated, self); + g_main_loop_run (self->base.loop); + + /* Create the collection manager and activate it */ + self->collection_manager = wp_collection_manager_new (self->base.core, + COLLECTION_MANAGER_METADATA_NAME); + wp_object_activate (WP_OBJECT (self->collection_manager), + WP_OBJECT_FEATURES_ALL, + NULL, + (GAsyncReadyCallback)on_collection_manager_ready, + self); + g_main_loop_run (self->base.loop); + + /* Get collections A and B */ + self->collection_a = wp_collection_manager_get_collection ( + self->collection_manager, COLLECTION_A_NAME); + g_assert_nonnull (self->collection_a); + self->collection_b = wp_collection_manager_get_collection ( + self->collection_manager, COLLECTION_B_NAME); + g_assert_nonnull (self->collection_b); +} + +static void +collection_manager_teardown (TestCollectionManagerFixture *self, + gconstpointer d) +{ + g_clear_object (&self->collection_b); + g_clear_object (&self->collection_a); + g_clear_object (&self->collection_manager); + g_clear_object (&self->collection_manager_metadata); + g_clear_object (&self->collection_b_metadata); + g_clear_object (&self->collection_a_metadata); + wp_base_test_fixture_teardown (&self->base); +} + +static void +on_collection_created (WpCollectionManager * cm, GAsyncResult * res, gpointer d) +{ + TestCollectionManagerFixture *self = d; + WpCollection *c = wp_collection_manager_create_collection_finish (cm, res, + NULL); + g_assert_nonnull (c); + g_assert_true (WP_IS_COLLECTION (c)); + g_main_loop_quit (self->base.loop); +} + +static void +test_collection_manager_collection (TestCollectionManagerFixture *self, + gconstpointer d) +{ + g_autoptr (WpImplMetadata) collection_c_metadata; + + /* Make sure the manager has 2 collections */ + g_assert_cmpuint (wp_collection_manager_get_collection_count ( + self->collection_manager), ==, 2); + + /* Make sure collections A and B were added */ + g_assert_true (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_A_NAME)); + g_assert_true (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_B_NAME)); + + /* Make sure collection C was not added */ + g_assert_false (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_C_NAME)); + + /* Create collection C metadata and activate it */ + collection_c_metadata = wp_impl_metadata_new_full ( + self->base.core, COLLECTION_C_NAME, + wp_properties_new ("wireplumber.collection", "true", NULL)); + wp_object_activate (WP_OBJECT (collection_c_metadata), + WP_OBJECT_FEATURES_ALL, NULL, + (GAsyncReadyCallback)on_metadata_activated, self); + g_main_loop_run (self->base.loop); + + /* Make sure collection C is still not added */ + g_assert_false (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_C_NAME)); + + /* Create collection C */ + wp_collection_manager_create_collection (self->collection_manager, + COLLECTION_C_NAME, NULL, (GAsyncReadyCallback)on_collection_created, self); + g_main_loop_run (self->base.loop); + + /* Make sure the manager has 3 collections */ + g_assert_cmpuint (wp_collection_manager_get_collection_count ( + self->collection_manager), ==, 3); + + /* Iterate collections */ + { + g_autoptr (WpIterator) iter = NULL; + g_auto (GValue) val = G_VALUE_INIT; + gboolean has_collection_a = FALSE; + gboolean has_collection_b = FALSE; + gboolean has_collection_c = FALSE; + guint count = 0; + + iter = wp_collection_manager_new_collection_iterator ( + self->collection_manager); + g_assert_nonnull (iter); + + while (wp_iterator_next (iter, &val)) { + WpCollection *c = g_value_get_object (&val); + g_assert_nonnull (c); + if (g_str_equal (wp_collection_get_name (c), COLLECTION_A_NAME)) + has_collection_a = TRUE; + if (g_str_equal (wp_collection_get_name (c), COLLECTION_B_NAME)) + has_collection_b = TRUE; + if (g_str_equal (wp_collection_get_name (c), COLLECTION_C_NAME)) + has_collection_c = TRUE; + count++; + g_value_unset (&val); + } + + g_assert_true (has_collection_a); + g_assert_true (has_collection_b); + g_assert_true (has_collection_c); + g_assert_cmpuint (count, ==, 3); + } + + /* Make sure collection C was added */ + g_assert_true (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_C_NAME)); + + /* Destroy collection C */ + g_assert_true (wp_collection_manager_destroy_collection ( + self->collection_manager, COLLECTION_C_NAME)); + + /* Make sure the manager has 2 collections */ + g_assert_cmpuint (wp_collection_manager_get_collection_count ( + self->collection_manager), ==, 2); + + /* Make sure collection C was removed */ + g_assert_false (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_C_NAME)); + + /* Remove collections A and B */ + g_assert_true (wp_collection_manager_destroy_collection ( + self->collection_manager, COLLECTION_A_NAME)); + g_assert_true (wp_collection_manager_destroy_collection ( + self->collection_manager, COLLECTION_B_NAME)); + + /* Make sure the manager does not have any collections */ + g_assert_cmpuint (wp_collection_manager_get_collection_count ( + self->collection_manager), ==, 0); + + /* Make sure collections A and B were removed */ + g_assert_false (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_A_NAME)); + g_assert_false (wp_collection_manager_has_collection (self->collection_manager, + COLLECTION_B_NAME)); +} + +static void +test_collection_manager_global (TestCollectionManagerFixture *self, + gconstpointer d) +{ + g_autoptr (WpGlobalProxy) global_a = NULL; + g_autoptr (WpGlobalProxy) global_b = NULL; + + /* Activate global A */ + global_a = WP_GLOBAL_PROXY (wp_impl_metadata_new_full (self->base.core, + "global-a", NULL)); + wp_object_activate (WP_OBJECT (global_a), WP_OBJECT_FEATURES_ALL, NULL, + (GAsyncReadyCallback)on_metadata_activated, self); + g_main_loop_run (self->base.loop); + + /* Activate global B */ + global_b = WP_GLOBAL_PROXY (wp_impl_metadata_new_full (self->base.core, + "global-a", NULL)); + wp_object_activate (WP_OBJECT (global_b), WP_OBJECT_FEATURES_ALL, NULL, + (GAsyncReadyCallback)on_metadata_activated, self); + g_main_loop_run (self->base.loop); + + /* Make sure collection A and B don't have any globals */ + g_assert_cmpuint (wp_collection_get_global_count (self->collection_a), ==, 0); + g_assert_cmpuint (wp_collection_get_global_count (self->collection_b), ==, 0); + + /* Make sure global A and B don't belong to any collection */ + g_assert_false (wp_collection_manager_is_global_collected ( + self->collection_manager, global_a, NULL)); + g_assert_false (wp_collection_manager_is_global_collected ( + self->collection_manager, global_b, NULL)); + + /* Collect global A into collection A and global B into collection B */ + g_assert_true (wp_collection_manager_collect_global (self->collection_manager, + global_a, COLLECTION_A_NAME)); + g_assert_true (wp_collection_manager_collect_global (self->collection_manager, + global_b, COLLECTION_B_NAME)); + + /* Make sure collection A and B have only one global */ + g_assert_cmpuint (wp_collection_get_global_count (self->collection_a), ==, 1); + g_assert_cmpuint (wp_collection_get_global_count (self->collection_b), ==, 1); + + /* Iterate collection A */ + { + g_autoptr (WpIterator) iter = NULL; + g_auto (GValue) val = G_VALUE_INIT; + guint count = 0; + + iter = wp_collection_manager_new_global_iterator (self->collection_manager, + COLLECTION_A_NAME); + g_assert_nonnull (iter); + + while (wp_iterator_next (iter, &val)) { + WpGlobalProxy *g = g_value_get_object (&val); + g_assert_nonnull (g); + g_assert_true (g == global_a); + count++; + g_value_unset (&val); + } + + g_assert_cmpuint (count, ==, 1); + } + + /* Iterate collection B */ + { + g_autoptr (WpIterator) iter = NULL; + g_auto (GValue) val = G_VALUE_INIT; + guint count = 0; + + iter = wp_collection_manager_new_global_iterator (self->collection_manager, + COLLECTION_B_NAME); + g_assert_nonnull (iter); + + while (wp_iterator_next (iter, &val)) { + WpGlobalProxy *g = g_value_get_object (&val); + g_assert_nonnull (g); + g_assert_true (g == global_b); + count++; + g_value_unset (&val); + } + + g_assert_cmpuint (count, ==, 1); + } + + /* Make sure global A and global B are in their respective collection */ + g_assert_true (wp_collection_manager_is_global_collected ( + self->collection_manager, global_a, COLLECTION_A_NAME)); + g_assert_true (wp_collection_manager_is_global_collected ( + self->collection_manager, global_b, COLLECTION_B_NAME)); + { + g_autoptr (WpCollection) ca = NULL; + g_autoptr (WpCollection) cb = NULL; + ca = wp_collection_manager_get_global_collection (self->collection_manager, + global_a); + g_assert_true (ca == self->collection_a); + cb = wp_collection_manager_get_global_collection (self->collection_manager, + global_b); + g_assert_true (cb == self->collection_b); + } + + g_assert_true (WP_IS_COLLECTION (self->collection_a)); + + /* Make sure we cannot add global A into collection B and vice versa */ + g_assert_false (wp_collection_manager_collect_global ( + self->collection_manager, global_a, COLLECTION_B_NAME)); + g_assert_false (wp_collection_manager_collect_global ( + self->collection_manager, global_b, COLLECTION_A_NAME)); + + /* Drop global A from collection A */ + g_assert_true (wp_collection_manager_drop_global (self->collection_manager, + global_a)); + + /* Make sure collection A does not have any globals */ + g_assert_cmpuint (wp_collection_get_global_count (self->collection_a), ==, 0); + + /* Make sure global A does not belong to any collection */ + g_assert_false (wp_collection_manager_is_global_collected ( + self->collection_manager, global_a, NULL)); + + /* Make sure collection A was automatically removed */ + g_assert_false (wp_collection_manager_has_collection ( + self->collection_manager, COLLECTION_A_NAME)); + + /* Collect global A into collection B */ + g_assert_true (wp_collection_manager_collect_global (self->collection_manager, + global_a, COLLECTION_B_NAME)); + + /* Make sure global A is in collection B */ + g_assert_true (wp_collection_manager_is_global_collected ( + self->collection_manager, global_a, COLLECTION_B_NAME)); + + /* Make sure collection B has 2 globals */ + g_assert_cmpuint (wp_collection_get_global_count (self->collection_b), ==, 2); + + /* Iterate collection B */ + { + g_autoptr (WpIterator) iter = NULL; + g_auto (GValue) val = G_VALUE_INIT; + gboolean has_global_a = FALSE; + gboolean has_global_b = FALSE; + guint count = 0; + + iter = wp_collection_manager_new_global_iterator (self->collection_manager, + COLLECTION_B_NAME); + g_assert_nonnull (iter); + + while (wp_iterator_next (iter, &val)) { + WpGlobalProxy *g = g_value_get_object (&val); + g_assert_nonnull (g); + if (g == global_a) + has_global_a = TRUE; + if (g == global_b) + has_global_b = TRUE; + count++; + g_value_unset (&val); + } + + g_assert_true (has_global_a); + g_assert_true (has_global_b); + g_assert_cmpuint (count, ==, 2); + } + + /* Drop global B from collection B */ + g_assert_true (wp_collection_manager_drop_global (self->collection_manager, + global_b)); + + /* Make sure collection B has only one global */ + g_assert_cmpuint (wp_collection_get_global_count (self->collection_b), ==, 1); + + /* Make sure global B does not belong to any collection */ + g_assert_false (wp_collection_manager_is_global_collected ( + self->collection_manager, global_b, NULL)); + + /* Drop global A from collection B */ + g_assert_true (wp_collection_manager_drop_global (self->collection_manager, + global_a)); + + /* Make sure collection B does not have any globals */ + g_assert_cmpuint (wp_collection_get_global_count (self->collection_b), ==, 0); + + /* Make sure global A does not belong to any collection */ + g_assert_false (wp_collection_manager_is_global_collected ( + self->collection_manager, global_a, NULL)); + + /* Make sure collection B was automatically removed */ + g_assert_false (wp_collection_manager_has_collection ( + self->collection_manager, COLLECTION_B_NAME)); +} + +int +main (int argc, char *argv[]) +{ + g_test_init (&argc, &argv, NULL); + wp_init (WP_INIT_ALL); + + g_test_add ("/wp/collection-manager/collection", + TestCollectionManagerFixture, NULL, collection_manager_setup, + test_collection_manager_collection, collection_manager_teardown); + g_test_add ("/wp/collection-manager/global", + TestCollectionManagerFixture, NULL, collection_manager_setup, + test_collection_manager_global, collection_manager_teardown); + + return g_test_run (); +} diff --git a/tests/wp/meson.build b/tests/wp/meson.build index cc0b9ac8..686d871c 100644 --- a/tests/wp/meson.build +++ b/tests/wp/meson.build @@ -3,6 +3,13 @@ common_env = common_test_env common_env.set('G_TEST_SRCDIR', meson.current_source_dir()) common_env.set('G_TEST_BUILDDIR', meson.current_build_dir()) +test( + 'test-collection-manager', + executable('test-collection-manager', 'collection-manager.c', + dependencies: common_deps), + env: common_env, +) + test( 'test-component-loader', executable('test-component-loader', 'component-loader.c',