feat(privacy): report camera, microphone and system audio use
parent
67e734a87e
commit
893d2ff3c0
@ -0,0 +1,42 @@
|
||||
find_package(Qt6 REQUIRED COMPONENTS Core Qml)
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(PIPEWIRE REQUIRED IMPORTED_TARGET libpipewire-0.3>=0.3.65)
|
||||
pkg_check_modules(SPA REQUIRED IMPORTED_TARGET libspa-0.2)
|
||||
|
||||
set(CUTEFISH_PRIVACY_SOURCES
|
||||
deviceuse.h
|
||||
deviceusage.cpp
|
||||
deviceusage.h
|
||||
pipewirecapture.cpp
|
||||
pipewirecapture.h
|
||||
videodevicewatch.cpp
|
||||
videodevicewatch.h
|
||||
qml/plugin.cpp
|
||||
)
|
||||
|
||||
add_library(cutefish-framework-privacy SHARED ${CUTEFISH_PRIVACY_SOURCES})
|
||||
add_library(Cutefish::Privacy ALIAS cutefish-framework-privacy)
|
||||
|
||||
qt_add_qml_module(cutefish-framework-privacy
|
||||
URI Cutefish.Privacy
|
||||
VERSION 1.0
|
||||
PLUGIN_TARGET cutefish-framework-privacy
|
||||
CLASS_NAME Plugin
|
||||
NO_GENERATE_PLUGIN_SOURCE
|
||||
NO_PLUGIN_OPTIONAL
|
||||
OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/Cutefish/Privacy"
|
||||
)
|
||||
|
||||
target_include_directories(cutefish-framework-privacy PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_link_libraries(cutefish-framework-privacy PUBLIC
|
||||
Qt6::Core
|
||||
Qt6::Qml
|
||||
PkgConfig::PIPEWIRE
|
||||
PkgConfig::SPA
|
||||
)
|
||||
|
||||
cutefish_install_qml_module(cutefish-framework-privacy)
|
||||
|
||||
if(BUILD_TESTING)
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
@ -0,0 +1,75 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "deviceusage.h"
|
||||
#include "pipewirecapture.h"
|
||||
#include "videodevicewatch.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace CutefishPrivacy {
|
||||
|
||||
DeviceUsage::DeviceUsage(QObject *parent)
|
||||
: DeviceUsage(new PipeWireCapture, new VideoDeviceWatch, parent)
|
||||
{
|
||||
}
|
||||
|
||||
DeviceUsage::DeviceUsage(PipeWireCapture *capture, VideoDeviceWatch *videoDevices, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_capture(capture)
|
||||
, m_videoDevices(videoDevices)
|
||||
{
|
||||
m_capture->setParent(this);
|
||||
m_videoDevices->setParent(this);
|
||||
connect(m_capture, &PipeWireCapture::usesChanged, this, &DeviceUsage::update);
|
||||
connect(m_videoDevices, &VideoDeviceWatch::usesChanged, this, &DeviceUsage::update);
|
||||
update();
|
||||
}
|
||||
|
||||
DeviceUsage *DeviceUsage::instance()
|
||||
{
|
||||
static DeviceUsage *usage = new DeviceUsage(QCoreApplication::instance());
|
||||
return usage;
|
||||
}
|
||||
|
||||
QVariantList DeviceUsage::usesList() const
|
||||
{
|
||||
QVariantList list;
|
||||
for (const DeviceUse &use : m_uses) {
|
||||
list.append(QVariantMap {
|
||||
{QStringLiteral("kind"), int(use.kind)},
|
||||
{QStringLiteral("pid"), use.pid},
|
||||
{QStringLiteral("appId"), use.appId},
|
||||
{QStringLiteral("name"), use.name},
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
bool DeviceUsage::isUsed(DeviceUse::Kind kind) const
|
||||
{
|
||||
return std::any_of(m_uses.cbegin(), m_uses.cend(), [kind](const DeviceUse &use) {
|
||||
return use.kind == kind;
|
||||
});
|
||||
}
|
||||
|
||||
void DeviceUsage::update()
|
||||
{
|
||||
DeviceUses uses = m_capture->uses();
|
||||
for (const DeviceUse &use : m_videoDevices->uses()) {
|
||||
if (!uses.contains(use))
|
||||
uses.append(use);
|
||||
}
|
||||
std::stable_sort(uses.begin(), uses.end(), [](const DeviceUse &a, const DeviceUse &b) {
|
||||
return a.kind < b.kind;
|
||||
});
|
||||
if (uses != m_uses) {
|
||||
m_uses = uses;
|
||||
Q_EMIT usesChanged();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "deviceuse.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QVariantList>
|
||||
|
||||
namespace CutefishPrivacy {
|
||||
|
||||
class PipeWireCapture;
|
||||
class VideoDeviceWatch;
|
||||
|
||||
/**
|
||||
* Which processes use a camera, a microphone, or record the system's audio.
|
||||
* Each entry of uses is a map of kind, pid, appId and name, cameras first.
|
||||
*/
|
||||
class DeviceUsage : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(bool camera READ camera NOTIFY usesChanged)
|
||||
Q_PROPERTY(bool microphone READ microphone NOTIFY usesChanged)
|
||||
Q_PROPERTY(bool systemAudio READ systemAudio NOTIFY usesChanged)
|
||||
Q_PROPERTY(QVariantList uses READ usesList NOTIFY usesChanged)
|
||||
|
||||
public:
|
||||
enum Kind { Camera = DeviceUse::Camera, Microphone = DeviceUse::Microphone,
|
||||
SystemAudio = DeviceUse::SystemAudio };
|
||||
Q_ENUM(Kind)
|
||||
|
||||
explicit DeviceUsage(QObject *parent = nullptr);
|
||||
DeviceUsage(PipeWireCapture *capture, VideoDeviceWatch *videoDevices, QObject *parent = nullptr);
|
||||
static DeviceUsage *instance();
|
||||
|
||||
DeviceUses uses() const { return m_uses; }
|
||||
bool camera() const { return isUsed(DeviceUse::Camera); }
|
||||
bool microphone() const { return isUsed(DeviceUse::Microphone); }
|
||||
bool systemAudio() const { return isUsed(DeviceUse::SystemAudio); }
|
||||
QVariantList usesList() const;
|
||||
|
||||
Q_SIGNALS:
|
||||
void usesChanged();
|
||||
|
||||
private:
|
||||
bool isUsed(DeviceUse::Kind kind) const;
|
||||
void update();
|
||||
|
||||
PipeWireCapture *m_capture;
|
||||
VideoDeviceWatch *m_videoDevices;
|
||||
DeviceUses m_uses;
|
||||
};
|
||||
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
namespace CutefishPrivacy {
|
||||
|
||||
// One process using one capture device.
|
||||
struct DeviceUse {
|
||||
enum Kind { Camera, Microphone, SystemAudio };
|
||||
|
||||
Kind kind = Camera;
|
||||
quint32 pid = 0;
|
||||
// The sandboxed application's id, as the portal granted it access.
|
||||
QString appId;
|
||||
// What the process calls itself; a fallback for one no application claims.
|
||||
QString name;
|
||||
|
||||
bool operator==(const DeviceUse &other) const = default;
|
||||
};
|
||||
|
||||
using DeviceUses = QList<DeviceUse>;
|
||||
|
||||
}
|
||||
@ -0,0 +1,327 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "pipewirecapture.h"
|
||||
|
||||
#include <QSocketNotifier>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <optional>
|
||||
#include <tuple>
|
||||
|
||||
namespace CutefishPrivacy {
|
||||
|
||||
static QVariantMap toMap(const spa_dict *dict)
|
||||
{
|
||||
QVariantMap map;
|
||||
if (!dict)
|
||||
return map;
|
||||
const spa_dict_item *item;
|
||||
spa_dict_for_each(item, dict) {
|
||||
if (item->key && item->value)
|
||||
map.insert(QString::fromUtf8(item->key), QString::fromUtf8(item->value));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
static bool isWatchedClass(const char *mediaClass)
|
||||
{
|
||||
static const char *const classes[] = {"Audio/Source", "Audio/Source/Virtual", "Audio/Sink",
|
||||
"Video/Source", "Stream/Input/Audio", "Stream/Input/Video"};
|
||||
for (const char *watched : classes) {
|
||||
if (std::strcmp(mediaClass, watched) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool isCamera(const QVariantMap &properties)
|
||||
{
|
||||
const QString api = properties.value(PW_KEY_DEVICE_API).toString();
|
||||
return properties.value(PW_KEY_MEDIA_ROLE).toString() == QLatin1String("Camera")
|
||||
|| api == QLatin1String("v4l2") || api == QLatin1String("libcamera");
|
||||
}
|
||||
|
||||
const pw_core_events PipeWireCapture::coreEvents = [] {
|
||||
pw_core_events events {};
|
||||
events.version = PW_VERSION_CORE_EVENTS;
|
||||
events.info = [](void *data, const pw_core_info *) {
|
||||
auto *self = static_cast<PipeWireCapture *>(data);
|
||||
if (!self->m_connected) {
|
||||
self->m_connected = true;
|
||||
Q_EMIT self->connectedChanged();
|
||||
}
|
||||
};
|
||||
events.error = [](void *data, quint32 id, int, int result, const char *) {
|
||||
auto *self = static_cast<PipeWireCapture *>(data);
|
||||
if (id == PW_ID_CORE && (result == -EPIPE || result == -ECONNRESET) && !self->m_failurePending) {
|
||||
self->m_failurePending = true;
|
||||
QMetaObject::invokeMethod(self, &PipeWireCapture::fail, Qt::QueuedConnection);
|
||||
}
|
||||
};
|
||||
return events;
|
||||
}();
|
||||
|
||||
const pw_registry_events PipeWireCapture::registryEvents = [] {
|
||||
pw_registry_events events {};
|
||||
events.version = PW_VERSION_REGISTRY_EVENTS;
|
||||
events.global = [](void *data, quint32 id, quint32, const char *type, quint32 version,
|
||||
const spa_dict *properties) {
|
||||
static_cast<PipeWireCapture *>(data)->addGlobal(id, type, version, properties);
|
||||
};
|
||||
events.global_remove = [](void *data, quint32 id) {
|
||||
static_cast<PipeWireCapture *>(data)->removeGlobal(id);
|
||||
};
|
||||
return events;
|
||||
}();
|
||||
|
||||
const pw_node_events PipeWireCapture::nodeEvents = [] {
|
||||
pw_node_events events {};
|
||||
events.version = PW_VERSION_NODE_EVENTS;
|
||||
events.info = [](void *data, const pw_node_info *info) {
|
||||
auto *proxy = static_cast<Proxy *>(data);
|
||||
if (info->change_mask & PW_NODE_CHANGE_MASK_PROPS)
|
||||
proxy->properties = toMap(info->props);
|
||||
if (info->change_mask & PW_NODE_CHANGE_MASK_STATE)
|
||||
proxy->running = info->state == PW_NODE_STATE_RUNNING;
|
||||
proxy->owner->scheduleUpdate();
|
||||
};
|
||||
return events;
|
||||
}();
|
||||
|
||||
const pw_client_events PipeWireCapture::clientEvents = [] {
|
||||
pw_client_events events {};
|
||||
events.version = PW_VERSION_CLIENT_EVENTS;
|
||||
events.info = [](void *data, const pw_client_info *info) {
|
||||
auto *proxy = static_cast<Proxy *>(data);
|
||||
if (info->change_mask & PW_CLIENT_CHANGE_MASK_PROPS) {
|
||||
proxy->properties = toMap(info->props);
|
||||
proxy->owner->scheduleUpdate();
|
||||
}
|
||||
};
|
||||
return events;
|
||||
}();
|
||||
|
||||
PipeWireCapture::PipeWireCapture(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
pw_init(nullptr, nullptr);
|
||||
m_retry.setSingleShot(true);
|
||||
m_retry.setInterval(1000);
|
||||
connect(&m_retry, &QTimer::timeout, this, &PipeWireCapture::connectToDaemon);
|
||||
connectToDaemon();
|
||||
}
|
||||
|
||||
PipeWireCapture::~PipeWireCapture()
|
||||
{
|
||||
m_retry.stop();
|
||||
disconnectFromDaemon();
|
||||
if (m_context)
|
||||
pw_context_destroy(m_context);
|
||||
if (m_loop) {
|
||||
pw_loop_leave(m_loop);
|
||||
pw_loop_destroy(m_loop);
|
||||
}
|
||||
pw_deinit();
|
||||
}
|
||||
|
||||
void PipeWireCapture::connectToDaemon()
|
||||
{
|
||||
if (m_core)
|
||||
return;
|
||||
m_failurePending = false;
|
||||
if (!m_loop) {
|
||||
m_loop = pw_loop_new(nullptr);
|
||||
if (m_loop)
|
||||
pw_loop_enter(m_loop);
|
||||
}
|
||||
if (m_loop && !m_context)
|
||||
m_context = pw_context_new(m_loop, nullptr, 0);
|
||||
if (m_context) {
|
||||
m_core = pw_context_connect(m_context,
|
||||
pw_properties_new(PW_KEY_APP_NAME, "Cutefish Privacy",
|
||||
PW_KEY_APP_ID, "org.cutefish.Privacy", nullptr), 0);
|
||||
}
|
||||
if (!m_core) {
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
pw_core_add_listener(m_core, &m_coreListener, &coreEvents, this);
|
||||
m_registry = pw_core_get_registry(m_core, PW_VERSION_REGISTRY, 0);
|
||||
if (!m_registry) {
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
pw_registry_add_listener(m_registry, &m_registryListener, ®istryEvents, this);
|
||||
m_notifier = new QSocketNotifier(pw_loop_get_fd(m_loop), QSocketNotifier::Read, this);
|
||||
connect(m_notifier, &QSocketNotifier::activated, this, &PipeWireCapture::iterate);
|
||||
}
|
||||
|
||||
void PipeWireCapture::disconnectFromDaemon()
|
||||
{
|
||||
delete m_notifier;
|
||||
m_notifier = nullptr;
|
||||
const auto ids = m_proxies.keys();
|
||||
for (quint32 id : ids)
|
||||
removeGlobal(id);
|
||||
m_links.clear();
|
||||
if (m_registry) {
|
||||
spa_hook_remove(&m_registryListener);
|
||||
pw_proxy_destroy(reinterpret_cast<pw_proxy *>(m_registry));
|
||||
m_registry = nullptr;
|
||||
}
|
||||
if (m_core) {
|
||||
spa_hook_remove(&m_coreListener);
|
||||
pw_core_disconnect(m_core);
|
||||
m_core = nullptr;
|
||||
}
|
||||
if (m_connected) {
|
||||
m_connected = false;
|
||||
Q_EMIT connectedChanged();
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
void PipeWireCapture::iterate()
|
||||
{
|
||||
if (!m_loop || m_iterating)
|
||||
return;
|
||||
m_iterating = true;
|
||||
const int result = pw_loop_iterate(m_loop, 0);
|
||||
m_iterating = false;
|
||||
if (result < 0 && result != -EINTR)
|
||||
fail();
|
||||
}
|
||||
|
||||
void PipeWireCapture::fail()
|
||||
{
|
||||
disconnectFromDaemon();
|
||||
m_retry.start();
|
||||
}
|
||||
|
||||
void PipeWireCapture::addGlobal(quint32 id, const char *type, quint32 version, const spa_dict *properties)
|
||||
{
|
||||
if (!properties)
|
||||
return;
|
||||
if (std::strcmp(type, PW_TYPE_INTERFACE_Link) == 0) {
|
||||
bool outputOk = false;
|
||||
bool inputOk = false;
|
||||
const quint32 output = QByteArray(spa_dict_lookup(properties, PW_KEY_LINK_OUTPUT_NODE)).toUInt(&outputOk);
|
||||
const quint32 input = QByteArray(spa_dict_lookup(properties, PW_KEY_LINK_INPUT_NODE)).toUInt(&inputOk);
|
||||
if (outputOk && inputOk) {
|
||||
m_links.insert(id, {output, input});
|
||||
scheduleUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const bool client = std::strcmp(type, PW_TYPE_INTERFACE_Client) == 0;
|
||||
if (!client) {
|
||||
const char *mediaClass = spa_dict_lookup(properties, PW_KEY_MEDIA_CLASS);
|
||||
if (std::strcmp(type, PW_TYPE_INTERFACE_Node) != 0 || !mediaClass || !isWatchedClass(mediaClass))
|
||||
return;
|
||||
}
|
||||
if (m_proxies.contains(id))
|
||||
return;
|
||||
|
||||
auto *bound = static_cast<pw_proxy *>(pw_registry_bind(m_registry, id, type,
|
||||
std::min(version, quint32(client ? PW_VERSION_CLIENT : PW_VERSION_NODE)), 0));
|
||||
if (!bound)
|
||||
return;
|
||||
auto *proxy = new Proxy {this, id, client, bound, {}, toMap(properties), false};
|
||||
m_proxies.insert(id, proxy);
|
||||
if (client)
|
||||
pw_client_add_listener(reinterpret_cast<pw_client *>(bound), &proxy->listener, &clientEvents, proxy);
|
||||
else
|
||||
pw_node_add_listener(reinterpret_cast<pw_node *>(bound), &proxy->listener, &nodeEvents, proxy);
|
||||
}
|
||||
|
||||
void PipeWireCapture::removeGlobal(quint32 id)
|
||||
{
|
||||
if (Proxy *proxy = m_proxies.take(id)) {
|
||||
spa_hook_remove(&proxy->listener);
|
||||
pw_proxy_destroy(proxy->proxy);
|
||||
delete proxy;
|
||||
scheduleUpdate();
|
||||
}
|
||||
if (m_links.remove(id))
|
||||
scheduleUpdate();
|
||||
}
|
||||
|
||||
void PipeWireCapture::scheduleUpdate()
|
||||
{
|
||||
// A stream starting brings its node, links and state in one burst.
|
||||
if (m_updatePending)
|
||||
return;
|
||||
m_updatePending = true;
|
||||
QMetaObject::invokeMethod(this, &PipeWireCapture::update, Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
void PipeWireCapture::update()
|
||||
{
|
||||
m_updatePending = false;
|
||||
DeviceUses uses;
|
||||
for (const Proxy *stream : std::as_const(m_proxies)) {
|
||||
if (stream->client || !stream->running)
|
||||
continue;
|
||||
const QVariantMap &properties = stream->properties;
|
||||
if (!properties.value(PW_KEY_MEDIA_CLASS).toString().startsWith(QLatin1String("Stream/Input/")))
|
||||
continue;
|
||||
// Our own level meters, peak meters, and the loopback and filter
|
||||
// plumbing that a real capture stream already stands for.
|
||||
if (properties.value(PW_KEY_NODE_NAME).toString().startsWith(QLatin1String("cutefish.internal."))
|
||||
|| properties.value(PW_KEY_STREAM_MONITOR).toString() == QLatin1String("true")
|
||||
|| properties.contains(PW_KEY_NODE_LINK_GROUP))
|
||||
continue;
|
||||
for (const auto &link : std::as_const(m_links)) {
|
||||
if (link.second != stream->id)
|
||||
continue;
|
||||
const Proxy *source = m_proxies.value(link.first);
|
||||
if (!source || source->client)
|
||||
continue;
|
||||
const QString mediaClass = source->properties.value(PW_KEY_MEDIA_CLASS).toString();
|
||||
std::optional<DeviceUse::Kind> kind;
|
||||
if (mediaClass == QLatin1String("Video/Source") && isCamera(source->properties))
|
||||
kind = DeviceUse::Camera;
|
||||
else if (mediaClass.startsWith(QLatin1String("Audio/Source")))
|
||||
kind = DeviceUse::Microphone;
|
||||
else if (mediaClass == QLatin1String("Audio/Sink"))
|
||||
kind = DeviceUse::SystemAudio;
|
||||
if (!kind)
|
||||
continue;
|
||||
const DeviceUse use = useOf(stream, *kind);
|
||||
if (!uses.contains(use))
|
||||
uses.append(use);
|
||||
}
|
||||
}
|
||||
// Hash order would otherwise report a reordering as a change.
|
||||
std::sort(uses.begin(), uses.end(), [](const DeviceUse &a, const DeviceUse &b) {
|
||||
return std::tie(a.kind, a.pid, a.appId, a.name) < std::tie(b.kind, b.pid, b.appId, b.name);
|
||||
});
|
||||
if (uses != m_uses) {
|
||||
m_uses = uses;
|
||||
Q_EMIT usesChanged();
|
||||
}
|
||||
}
|
||||
|
||||
DeviceUse PipeWireCapture::useOf(const Proxy *stream, DeviceUse::Kind kind) const
|
||||
{
|
||||
DeviceUse use;
|
||||
use.kind = kind;
|
||||
const Proxy *client = m_proxies.value(stream->properties.value(PW_KEY_CLIENT_ID).toUInt());
|
||||
const QVariantMap clientProperties = client && client->client ? client->properties : QVariantMap();
|
||||
// A PulseAudio application's connection is pipewire-pulse's, so its own
|
||||
// process id is only the one it reports. Any other reported id may be a
|
||||
// sandbox's own, so the socket's peer is trusted instead.
|
||||
const bool pulse = clientProperties.value(PW_KEY_CLIENT_API).toString() == QLatin1String("pipewire-pulse");
|
||||
use.pid = clientProperties.value(pulse ? PW_KEY_APP_PROCESS_ID : PW_KEY_SEC_PID).toUInt();
|
||||
use.appId = clientProperties.value(QStringLiteral("pipewire.access.portal.app_id")).toString();
|
||||
use.name = stream->properties.value(PW_KEY_APP_NAME).toString();
|
||||
if (use.name.isEmpty())
|
||||
use.name = clientProperties.value(PW_KEY_APP_NAME).toString();
|
||||
return use;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "deviceuse.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QTimer>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include <pipewire/pipewire.h>
|
||||
|
||||
class QSocketNotifier;
|
||||
|
||||
namespace CutefishPrivacy {
|
||||
|
||||
/**
|
||||
* The streams that capture from a camera, a microphone or a sink's monitor,
|
||||
* read from the PipeWire graph. A device PipeWire opens is held by the daemon,
|
||||
* so only the graph tells which client the frames go to.
|
||||
*/
|
||||
class PipeWireCapture : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit PipeWireCapture(QObject *parent = nullptr);
|
||||
~PipeWireCapture() override;
|
||||
|
||||
bool isConnected() const { return m_connected; }
|
||||
DeviceUses uses() const { return m_uses; }
|
||||
|
||||
Q_SIGNALS:
|
||||
void usesChanged();
|
||||
void connectedChanged();
|
||||
|
||||
private:
|
||||
struct Proxy {
|
||||
PipeWireCapture *owner = nullptr;
|
||||
quint32 id = 0;
|
||||
bool client = false;
|
||||
pw_proxy *proxy = nullptr;
|
||||
spa_hook listener {};
|
||||
QVariantMap properties;
|
||||
bool running = false;
|
||||
};
|
||||
|
||||
void connectToDaemon();
|
||||
void disconnectFromDaemon();
|
||||
void iterate();
|
||||
void fail();
|
||||
void addGlobal(quint32 id, const char *type, quint32 version, const spa_dict *properties);
|
||||
void removeGlobal(quint32 id);
|
||||
void scheduleUpdate();
|
||||
void update();
|
||||
DeviceUse useOf(const Proxy *stream, DeviceUse::Kind kind) const;
|
||||
|
||||
static const pw_core_events coreEvents;
|
||||
static const pw_registry_events registryEvents;
|
||||
static const pw_node_events nodeEvents;
|
||||
static const pw_client_events clientEvents;
|
||||
|
||||
pw_loop *m_loop = nullptr;
|
||||
pw_context *m_context = nullptr;
|
||||
pw_core *m_core = nullptr;
|
||||
pw_registry *m_registry = nullptr;
|
||||
spa_hook m_coreListener {};
|
||||
spa_hook m_registryListener {};
|
||||
QSocketNotifier *m_notifier = nullptr;
|
||||
QTimer m_retry;
|
||||
QHash<quint32, Proxy *> m_proxies;
|
||||
// Link id to its output and input node.
|
||||
QHash<quint32, QPair<quint32, quint32>> m_links;
|
||||
DeviceUses m_uses;
|
||||
bool m_connected = false;
|
||||
bool m_iterating = false;
|
||||
bool m_updatePending = false;
|
||||
bool m_failurePending = false;
|
||||
};
|
||||
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "plugin.h"
|
||||
|
||||
#include "deviceusage.h"
|
||||
|
||||
#include <QQmlEngine>
|
||||
|
||||
void Plugin::registerTypes(const char *uri)
|
||||
{
|
||||
using namespace CutefishPrivacy;
|
||||
|
||||
qmlRegisterSingletonType<DeviceUsage>(uri, 1, 0, "DeviceUsage", [](QQmlEngine *, QJSEngine *) {
|
||||
DeviceUsage *usage = DeviceUsage::instance();
|
||||
QQmlEngine::setObjectOwnership(usage, QQmlEngine::CppOwnership);
|
||||
return usage;
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QQmlExtensionPlugin>
|
||||
|
||||
class Plugin : public QQmlExtensionPlugin
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PLUGIN_METADATA(IID "org.qt-project.Qt.QQmlExtensionInterface")
|
||||
|
||||
public:
|
||||
void registerTypes(const char *uri) override;
|
||||
};
|
||||
@ -0,0 +1,17 @@
|
||||
find_package(Qt6 REQUIRED COMPONENTS Test)
|
||||
find_program(PIPEWIRE_EXECUTABLE pipewire REQUIRED)
|
||||
find_program(WIREPLUMBER_EXECUTABLE wireplumber REQUIRED)
|
||||
find_program(DBUS_RUN_SESSION dbus-run-session REQUIRED)
|
||||
|
||||
add_executable(privacy-test privacy_test.cpp)
|
||||
target_link_libraries(privacy-test PRIVATE cutefish-framework-privacy Qt6::Test)
|
||||
target_compile_definitions(privacy-test PRIVATE
|
||||
PRIVACY_TEST_CONFIG="${CMAKE_CURRENT_SOURCE_DIR}/pipewire.conf"
|
||||
PIPEWIRE_EXECUTABLE="${PIPEWIRE_EXECUTABLE}"
|
||||
WIREPLUMBER_EXECUTABLE="${WIREPLUMBER_EXECUTABLE}"
|
||||
)
|
||||
add_test(NAME privacy COMMAND ${DBUS_RUN_SESSION} -- $<TARGET_FILE:privacy-test>)
|
||||
set_tests_properties(privacy PROPERTIES
|
||||
TIMEOUT 120
|
||||
ENVIRONMENT "QT_QPA_PLATFORM=offscreen"
|
||||
)
|
||||
@ -0,0 +1,82 @@
|
||||
context.properties = {
|
||||
core.daemon = true
|
||||
core.name = cutefish-privacy-test
|
||||
default.clock.rate = 48000
|
||||
default.clock.quantum = 256
|
||||
}
|
||||
|
||||
context.spa-libs = {
|
||||
audio.convert.* = audioconvert/libspa-audioconvert
|
||||
video.convert.* = videoconvert/libspa-videoconvert
|
||||
support.* = support/libspa-support
|
||||
audiotestsrc = audiotestsrc/libspa-audiotestsrc
|
||||
videotestsrc = videotestsrc/libspa-videotestsrc
|
||||
}
|
||||
|
||||
context.modules = [
|
||||
{ name = libpipewire-module-protocol-native }
|
||||
{ name = libpipewire-module-metadata }
|
||||
{ name = libpipewire-module-spa-node-factory }
|
||||
{ name = libpipewire-module-client-node }
|
||||
{ name = libpipewire-module-client-device }
|
||||
{ name = libpipewire-module-adapter }
|
||||
{ name = libpipewire-module-link-factory }
|
||||
{ name = libpipewire-module-access }
|
||||
]
|
||||
|
||||
context.objects = [
|
||||
{ factory = spa-node-factory
|
||||
args = {
|
||||
factory.name = support.node.driver
|
||||
node.name = Test-Driver
|
||||
node.group = pipewire.dummy
|
||||
priority.driver = 20000
|
||||
}
|
||||
}
|
||||
{ factory = adapter
|
||||
args = {
|
||||
factory.name = support.null-audio-sink
|
||||
node.name = test-speaker
|
||||
node.virtual = true
|
||||
audio.rate = 48000
|
||||
audio.channels = 2
|
||||
node.description = "Test Speaker"
|
||||
media.class = Audio/Sink
|
||||
audio.position = [ FL FR ]
|
||||
object.linger = true
|
||||
}
|
||||
}
|
||||
{ factory = adapter
|
||||
args = {
|
||||
factory.name = audiotestsrc
|
||||
node.name = test-microphone
|
||||
node.virtual = true
|
||||
audio.rate = 48000
|
||||
audio.channels = 2
|
||||
node.description = "Test Microphone"
|
||||
media.class = Audio/Source
|
||||
audio.position = [ FL FR ]
|
||||
object.linger = true
|
||||
}
|
||||
}
|
||||
{ factory = spa-node-factory
|
||||
args = {
|
||||
factory.name = videotestsrc
|
||||
node.name = test-camera
|
||||
node.description = "Test Camera"
|
||||
media.class = Video/Source
|
||||
media.role = Camera
|
||||
object.linger = true
|
||||
}
|
||||
}
|
||||
{ factory = spa-node-factory
|
||||
args = {
|
||||
factory.name = videotestsrc
|
||||
node.name = test-screen
|
||||
node.description = "Test Screen"
|
||||
media.class = Video/Source
|
||||
media.role = Screen
|
||||
object.linger = true
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -0,0 +1,334 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "deviceusage.h"
|
||||
#include "pipewirecapture.h"
|
||||
#include "videodevicewatch.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QProcess>
|
||||
#include <QSignalSpy>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTest>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include <pipewire/pipewire.h>
|
||||
#include <spa/param/audio/format-utils.h>
|
||||
#include <spa/param/video/format-utils.h>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
using namespace CutefishPrivacy;
|
||||
|
||||
// A capture stream of this process, on a connection of its own.
|
||||
class TestStream
|
||||
{
|
||||
public:
|
||||
TestStream(pw_thread_loop *loop, pw_core *core, const char *name, bool video, const char *target,
|
||||
std::initializer_list<std::pair<const char *, const char *>> extra = {})
|
||||
: m_loop(loop)
|
||||
{
|
||||
pw_thread_loop_lock(m_loop);
|
||||
auto *properties = pw_properties_new(PW_KEY_NODE_NAME, name, PW_KEY_APP_NAME, name,
|
||||
PW_KEY_MEDIA_TYPE, video ? "Video" : "Audio", PW_KEY_MEDIA_CATEGORY, "Capture",
|
||||
PW_KEY_TARGET_OBJECT, target, nullptr);
|
||||
for (const auto &[key, value] : extra)
|
||||
pw_properties_set(properties, key, value);
|
||||
m_stream = pw_stream_new(core, name, properties);
|
||||
pw_stream_add_listener(m_stream, &m_listener, &events, this);
|
||||
uint8_t buffer[512];
|
||||
spa_pod_builder builder = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
|
||||
const spa_pod *params[1];
|
||||
if (video) {
|
||||
params[0] = static_cast<const spa_pod *>(spa_pod_builder_add_object(&builder,
|
||||
SPA_TYPE_OBJECT_Format, SPA_PARAM_EnumFormat,
|
||||
SPA_FORMAT_mediaType, SPA_POD_Id(SPA_MEDIA_TYPE_video),
|
||||
SPA_FORMAT_mediaSubtype, SPA_POD_Id(SPA_MEDIA_SUBTYPE_raw)));
|
||||
} else {
|
||||
spa_audio_info_raw format {};
|
||||
format.format = SPA_AUDIO_FORMAT_F32;
|
||||
format.rate = 48000;
|
||||
format.channels = 2;
|
||||
format.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||
format.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||
params[0] = spa_format_audio_raw_build(&builder, SPA_PARAM_EnumFormat, &format);
|
||||
}
|
||||
pw_stream_connect(m_stream, PW_DIRECTION_INPUT, PW_ID_ANY,
|
||||
static_cast<pw_stream_flags>(PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS), params, 1);
|
||||
pw_thread_loop_unlock(m_loop);
|
||||
}
|
||||
|
||||
~TestStream()
|
||||
{
|
||||
pw_thread_loop_lock(m_loop);
|
||||
spa_hook_remove(&m_listener);
|
||||
pw_stream_destroy(m_stream);
|
||||
pw_thread_loop_unlock(m_loop);
|
||||
}
|
||||
|
||||
void setActive(bool active)
|
||||
{
|
||||
pw_thread_loop_lock(m_loop);
|
||||
pw_stream_set_active(m_stream, active);
|
||||
pw_thread_loop_unlock(m_loop);
|
||||
}
|
||||
|
||||
private:
|
||||
pw_thread_loop *m_loop;
|
||||
pw_stream *m_stream = nullptr;
|
||||
spa_hook m_listener {};
|
||||
|
||||
inline static const pw_stream_events events = [] {
|
||||
pw_stream_events events {};
|
||||
events.version = PW_VERSION_STREAM_EVENTS;
|
||||
events.process = [](void *data) {
|
||||
auto *self = static_cast<TestStream *>(data);
|
||||
if (pw_buffer *buffer = pw_stream_dequeue_buffer(self->m_stream))
|
||||
pw_stream_queue_buffer(self->m_stream, buffer);
|
||||
};
|
||||
return events;
|
||||
}();
|
||||
};
|
||||
|
||||
class PrivacyTest : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
private Q_SLOTS:
|
||||
void initTestCase();
|
||||
void directOpen();
|
||||
void briefOpenIsIgnored();
|
||||
void deviceHotplug();
|
||||
void microphone();
|
||||
void systemAudio();
|
||||
void camera();
|
||||
void ignoredStreams();
|
||||
void pausedStream();
|
||||
void merged();
|
||||
void cleanupTestCase();
|
||||
|
||||
private:
|
||||
bool hasUse(const DeviceUses &uses, DeviceUse::Kind kind, const QString &name) const;
|
||||
|
||||
QTemporaryDir m_runtime;
|
||||
QTemporaryDir m_devices;
|
||||
QProcess m_server;
|
||||
QProcess m_policy;
|
||||
pw_thread_loop *m_loop = nullptr;
|
||||
pw_context *m_context = nullptr;
|
||||
pw_core *m_core = nullptr;
|
||||
std::unique_ptr<PipeWireCapture> m_capture;
|
||||
};
|
||||
|
||||
void PrivacyTest::initTestCase()
|
||||
{
|
||||
QVERIFY(m_runtime.isValid());
|
||||
QVERIFY(m_devices.isValid());
|
||||
const QByteArray path = m_runtime.path().toUtf8();
|
||||
qputenv("XDG_RUNTIME_DIR", path);
|
||||
qputenv("PIPEWIRE_RUNTIME_DIR", path);
|
||||
qputenv("PIPEWIRE_REMOTE", "cutefish-privacy-test");
|
||||
qputenv("XDG_CONFIG_HOME", path + "/config");
|
||||
qputenv("XDG_STATE_HOME", path + "/state");
|
||||
qputenv("XDG_CACHE_HOME", path + "/cache");
|
||||
qunsetenv("PIPEWIRE_CONFIG_NAME");
|
||||
qunsetenv("PIPEWIRE_CONFIG_DIR");
|
||||
qunsetenv("WIREPLUMBER_CONFIG_DIR");
|
||||
|
||||
m_server.setProcessChannelMode(QProcess::ForwardedChannels);
|
||||
m_server.start(PIPEWIRE_EXECUTABLE, {"-c", PRIVACY_TEST_CONFIG});
|
||||
QVERIFY2(m_server.waitForStarted(), qPrintable(m_server.errorString()));
|
||||
QTRY_VERIFY_WITH_TIMEOUT(QFile::exists(m_runtime.path() + "/cutefish-privacy-test"), 5000);
|
||||
m_policy.setProcessChannelMode(QProcess::ForwardedChannels);
|
||||
m_policy.start(WIREPLUMBER_EXECUTABLE, {"--profile=policy"});
|
||||
QVERIFY2(m_policy.waitForStarted(), qPrintable(m_policy.errorString()));
|
||||
|
||||
pw_init(nullptr, nullptr);
|
||||
m_loop = pw_thread_loop_new("privacy-test", nullptr);
|
||||
QVERIFY(m_loop);
|
||||
m_context = pw_context_new(pw_thread_loop_get_loop(m_loop), nullptr, 0);
|
||||
QVERIFY(m_context);
|
||||
QVERIFY(pw_thread_loop_start(m_loop) == 0);
|
||||
pw_thread_loop_lock(m_loop);
|
||||
m_core = pw_context_connect(m_context, nullptr, 0);
|
||||
pw_thread_loop_unlock(m_loop);
|
||||
QVERIFY(m_core);
|
||||
|
||||
m_capture = std::make_unique<PipeWireCapture>();
|
||||
QTRY_VERIFY_WITH_TIMEOUT(m_capture->isConnected(), 10000);
|
||||
}
|
||||
|
||||
void PrivacyTest::cleanupTestCase()
|
||||
{
|
||||
m_capture.reset();
|
||||
if (m_loop) {
|
||||
pw_thread_loop_lock(m_loop);
|
||||
if (m_core)
|
||||
pw_core_disconnect(m_core);
|
||||
pw_thread_loop_unlock(m_loop);
|
||||
pw_thread_loop_stop(m_loop);
|
||||
if (m_context)
|
||||
pw_context_destroy(m_context);
|
||||
pw_thread_loop_destroy(m_loop);
|
||||
}
|
||||
for (QProcess *process : {&m_policy, &m_server}) {
|
||||
process->terminate();
|
||||
if (!process->waitForFinished(3000))
|
||||
process->kill();
|
||||
}
|
||||
}
|
||||
|
||||
bool PrivacyTest::hasUse(const DeviceUses &uses, DeviceUse::Kind kind, const QString &name) const
|
||||
{
|
||||
for (const DeviceUse &use : uses) {
|
||||
if (use.kind == kind && use.name == name && use.pid == quint32(getpid()))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static QString ownName()
|
||||
{
|
||||
QFile comm(QStringLiteral("/proc/self/comm"));
|
||||
comm.open(QIODevice::ReadOnly);
|
||||
return QString::fromUtf8(comm.readAll()).trimmed();
|
||||
}
|
||||
|
||||
void PrivacyTest::directOpen()
|
||||
{
|
||||
QFile device(m_devices.filePath("video0"));
|
||||
QVERIFY(device.open(QIODevice::WriteOnly));
|
||||
device.close();
|
||||
QFile other(m_devices.filePath("videoX"));
|
||||
QVERIFY(other.open(QIODevice::WriteOnly));
|
||||
|
||||
VideoDeviceWatch watch(m_devices.path());
|
||||
QVERIFY(watch.uses().isEmpty());
|
||||
|
||||
// Held open before the watch began.
|
||||
QVERIFY(device.open(QIODevice::ReadOnly));
|
||||
VideoDeviceWatch late(m_devices.path());
|
||||
QVERIFY(hasUse(late.uses(), DeviceUse::Camera, ownName()));
|
||||
|
||||
QTRY_VERIFY_WITH_TIMEOUT(hasUse(watch.uses(), DeviceUse::Camera, ownName()), 5000);
|
||||
QCOMPARE(watch.uses().size(), 1);
|
||||
device.close();
|
||||
QTRY_VERIFY_WITH_TIMEOUT(watch.uses().isEmpty(), 5000);
|
||||
}
|
||||
|
||||
void PrivacyTest::briefOpenIsIgnored()
|
||||
{
|
||||
QFile device(m_devices.filePath("video0"));
|
||||
VideoDeviceWatch watch(m_devices.path());
|
||||
QSignalSpy spy(&watch, &VideoDeviceWatch::usesChanged);
|
||||
QVERIFY(device.open(QIODevice::ReadOnly));
|
||||
QTest::qWait(VideoDeviceWatch::SettleDelay / 4);
|
||||
device.close();
|
||||
QTest::qWait(VideoDeviceWatch::SettleDelay * 2);
|
||||
QCOMPARE(spy.count(), 0);
|
||||
}
|
||||
|
||||
void PrivacyTest::deviceHotplug()
|
||||
{
|
||||
VideoDeviceWatch watch(m_devices.path());
|
||||
QFile device(m_devices.filePath("video7"));
|
||||
QVERIFY(device.open(QIODevice::WriteOnly));
|
||||
device.close();
|
||||
QTest::qWait(VideoDeviceWatch::SettleDelay * 2);
|
||||
QVERIFY(device.open(QIODevice::ReadOnly));
|
||||
QTRY_VERIFY_WITH_TIMEOUT(hasUse(watch.uses(), DeviceUse::Camera, ownName()), 5000);
|
||||
device.close();
|
||||
QVERIFY(device.remove());
|
||||
QTRY_VERIFY_WITH_TIMEOUT(watch.uses().isEmpty(), 5000);
|
||||
}
|
||||
|
||||
void PrivacyTest::microphone()
|
||||
{
|
||||
{
|
||||
TestStream stream(m_loop, m_core, "test-recorder", false, "test-microphone");
|
||||
QTRY_VERIFY_WITH_TIMEOUT(hasUse(m_capture->uses(), DeviceUse::Microphone, "test-recorder"), 10000);
|
||||
QCOMPARE(m_capture->uses().size(), 1);
|
||||
}
|
||||
QTRY_VERIFY_WITH_TIMEOUT(m_capture->uses().isEmpty(), 10000);
|
||||
}
|
||||
|
||||
void PrivacyTest::systemAudio()
|
||||
{
|
||||
{
|
||||
TestStream stream(m_loop, m_core, "test-loopback-recorder", false, "test-speaker",
|
||||
{{PW_KEY_STREAM_CAPTURE_SINK, "true"}});
|
||||
QTRY_VERIFY_WITH_TIMEOUT(hasUse(m_capture->uses(), DeviceUse::SystemAudio, "test-loopback-recorder"), 10000);
|
||||
QCOMPARE(m_capture->uses().size(), 1);
|
||||
}
|
||||
QTRY_VERIFY_WITH_TIMEOUT(m_capture->uses().isEmpty(), 10000);
|
||||
}
|
||||
|
||||
void PrivacyTest::camera()
|
||||
{
|
||||
{
|
||||
TestStream stream(m_loop, m_core, "test-video-call", true, "test-camera");
|
||||
QTRY_VERIFY_WITH_TIMEOUT(hasUse(m_capture->uses(), DeviceUse::Camera, "test-video-call"), 10000);
|
||||
QCOMPARE(m_capture->uses().size(), 1);
|
||||
}
|
||||
QTRY_VERIFY_WITH_TIMEOUT(m_capture->uses().isEmpty(), 10000);
|
||||
|
||||
// A screen cast is no camera.
|
||||
TestStream screen(m_loop, m_core, "test-screen-viewer", true, "test-screen");
|
||||
QTest::qWait(2000);
|
||||
QVERIFY(m_capture->uses().isEmpty());
|
||||
}
|
||||
|
||||
void PrivacyTest::ignoredStreams()
|
||||
{
|
||||
TestStream meter(m_loop, m_core, "cutefish.internal.meter", false, "test-microphone");
|
||||
TestStream peak(m_loop, m_core, "test-peak", false, "test-microphone", {{PW_KEY_STREAM_MONITOR, "true"}});
|
||||
TestStream plumbing(m_loop, m_core, "test-loopback", false, "test-microphone",
|
||||
{{PW_KEY_NODE_LINK_GROUP, "test-loopback-group"}});
|
||||
TestStream recorder(m_loop, m_core, "test-recorder", false, "test-microphone");
|
||||
QTRY_VERIFY_WITH_TIMEOUT(hasUse(m_capture->uses(), DeviceUse::Microphone, "test-recorder"), 10000);
|
||||
QTest::qWait(500);
|
||||
QCOMPARE(m_capture->uses().size(), 1);
|
||||
}
|
||||
|
||||
void PrivacyTest::pausedStream()
|
||||
{
|
||||
QTRY_VERIFY_WITH_TIMEOUT(m_capture->uses().isEmpty(), 10000);
|
||||
TestStream stream(m_loop, m_core, "test-paused", false, "test-microphone");
|
||||
QTRY_VERIFY_WITH_TIMEOUT(hasUse(m_capture->uses(), DeviceUse::Microphone, "test-paused"), 10000);
|
||||
stream.setActive(false);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(m_capture->uses().isEmpty(), 10000);
|
||||
stream.setActive(true);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(hasUse(m_capture->uses(), DeviceUse::Microphone, "test-paused"), 10000);
|
||||
}
|
||||
|
||||
void PrivacyTest::merged()
|
||||
{
|
||||
auto *capture = new PipeWireCapture;
|
||||
auto *watch = new VideoDeviceWatch(m_devices.path());
|
||||
DeviceUsage usage(capture, watch);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(capture->isConnected(), 10000);
|
||||
QTRY_VERIFY_WITH_TIMEOUT(usage.uses().isEmpty(), 10000);
|
||||
QVERIFY(!usage.camera() && !usage.microphone() && !usage.systemAudio());
|
||||
|
||||
TestStream stream(m_loop, m_core, "test-recorder", false, "test-microphone");
|
||||
QFile device(m_devices.filePath("video0"));
|
||||
QVERIFY(device.open(QIODevice::ReadOnly));
|
||||
QTRY_VERIFY_WITH_TIMEOUT(usage.camera() && usage.microphone(), 10000);
|
||||
QVERIFY(!usage.systemAudio());
|
||||
QCOMPARE(usage.uses().size(), 2);
|
||||
const QVariantList list = usage.usesList();
|
||||
QCOMPARE(list.size(), 2);
|
||||
QCOMPARE(list.first().toMap().value("kind").toInt(), int(DeviceUsage::Camera));
|
||||
for (const QVariant &entry : list) {
|
||||
const QVariantMap map = entry.toMap();
|
||||
QCOMPARE(map.value("pid").toUInt(), quint32(getpid()));
|
||||
QVERIFY(map.value("kind").toInt() == DeviceUsage::Camera || map.value("kind").toInt() == DeviceUsage::Microphone);
|
||||
}
|
||||
device.close();
|
||||
QTRY_VERIFY_WITH_TIMEOUT(!usage.camera() && usage.microphone(), 10000);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(PrivacyTest)
|
||||
|
||||
#include "privacy_test.moc"
|
||||
@ -0,0 +1,153 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "videodevicewatch.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
#include <QSocketNotifier>
|
||||
|
||||
#include <algorithm>
|
||||
#include <tuple>
|
||||
|
||||
#include <dirent.h>
|
||||
#include <limits.h>
|
||||
#include <sys/inotify.h>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace CutefishPrivacy {
|
||||
|
||||
static bool isVideoDevice(const QString &name)
|
||||
{
|
||||
static const QRegularExpression pattern(QStringLiteral("^video[0-9]+$"));
|
||||
return pattern.match(name).hasMatch();
|
||||
}
|
||||
|
||||
VideoDeviceWatch::VideoDeviceWatch(const QString &deviceDirectory, const QString &processDirectory,
|
||||
QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_deviceDirectory(QDir(deviceDirectory).canonicalPath())
|
||||
, m_processDirectory(processDirectory)
|
||||
{
|
||||
m_settle.setSingleShot(true);
|
||||
m_settle.setInterval(SettleDelay);
|
||||
connect(&m_settle, &QTimer::timeout, this, &VideoDeviceWatch::scan);
|
||||
|
||||
m_fd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
|
||||
if (m_fd < 0)
|
||||
return;
|
||||
m_directoryWatch = inotify_add_watch(m_fd, QFile::encodeName(m_deviceDirectory).constData(),
|
||||
IN_CREATE | IN_DELETE | IN_MOVED_TO | IN_MOVED_FROM);
|
||||
m_notifier = new QSocketNotifier(m_fd, QSocketNotifier::Read, this);
|
||||
connect(m_notifier, &QSocketNotifier::activated, this, &VideoDeviceWatch::readEvents);
|
||||
watchDevices();
|
||||
scan();
|
||||
}
|
||||
|
||||
VideoDeviceWatch::~VideoDeviceWatch()
|
||||
{
|
||||
if (m_fd >= 0)
|
||||
close(m_fd);
|
||||
}
|
||||
|
||||
void VideoDeviceWatch::readEvents()
|
||||
{
|
||||
alignas(inotify_event) char buffer[4096];
|
||||
bool devicesChanged = false;
|
||||
bool accessed = false;
|
||||
for (;;) {
|
||||
const ssize_t length = read(m_fd, buffer, sizeof(buffer));
|
||||
if (length <= 0)
|
||||
break;
|
||||
for (ssize_t offset = 0; offset < length;) {
|
||||
const auto *event = reinterpret_cast<const inotify_event *>(buffer + offset);
|
||||
offset += sizeof(inotify_event) + event->len;
|
||||
if (event->wd == m_directoryWatch) {
|
||||
if (event->len > 0 && isVideoDevice(QFile::decodeName(event->name)))
|
||||
devicesChanged = true;
|
||||
} else if (event->mask & IN_IGNORED) {
|
||||
m_devices.remove(event->wd);
|
||||
devicesChanged = true;
|
||||
} else {
|
||||
accessed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (devicesChanged)
|
||||
watchDevices();
|
||||
if ((devicesChanged || accessed) && !m_settle.isActive())
|
||||
m_settle.start();
|
||||
}
|
||||
|
||||
void VideoDeviceWatch::watchDevices()
|
||||
{
|
||||
const QStringList names = QDir(m_deviceDirectory).entryList(QDir::System | QDir::Files);
|
||||
for (const QString &name : names) {
|
||||
if (!isVideoDevice(name))
|
||||
continue;
|
||||
const QString path = m_deviceDirectory + QLatin1Char('/') + name;
|
||||
// A device already watched keeps its descriptor: inotify returns it again.
|
||||
const int watch = inotify_add_watch(m_fd, QFile::encodeName(path).constData(),
|
||||
IN_OPEN | IN_CLOSE_WRITE | IN_CLOSE_NOWRITE);
|
||||
if (watch >= 0)
|
||||
m_devices.insert(watch, path);
|
||||
}
|
||||
}
|
||||
|
||||
void VideoDeviceWatch::scan()
|
||||
{
|
||||
const QSet<QString> devices(m_devices.cbegin(), m_devices.cend());
|
||||
DeviceUses uses;
|
||||
if (!devices.isEmpty()) {
|
||||
const QStringList processes = QDir(m_processDirectory).entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||||
for (const QString &process : processes) {
|
||||
bool ok = false;
|
||||
const quint32 pid = process.toUInt(&ok);
|
||||
if (!ok)
|
||||
continue;
|
||||
// Read raw: QDir would stat every descriptor's target.
|
||||
const QByteArray descriptors = QFile::encodeName(m_processDirectory + QLatin1Char('/') + process)
|
||||
+ "/fd";
|
||||
DIR *directory = opendir(descriptors.constData());
|
||||
if (!directory)
|
||||
continue;
|
||||
bool holds = false;
|
||||
while (const dirent *entry = readdir(directory)) {
|
||||
if (entry->d_name[0] == '.')
|
||||
continue;
|
||||
char target[PATH_MAX];
|
||||
const QByteArray path = descriptors + '/' + entry->d_name;
|
||||
const ssize_t length = readlink(path.constData(), target, sizeof(target));
|
||||
if (length > 0 && devices.contains(QFile::decodeName(QByteArray(target, length)))) {
|
||||
holds = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
closedir(directory);
|
||||
if (!holds)
|
||||
continue;
|
||||
QFile comm(m_processDirectory + QLatin1Char('/') + process + QStringLiteral("/comm"));
|
||||
const QString name = comm.open(QIODevice::ReadOnly) ? QString::fromUtf8(comm.readAll()).trimmed()
|
||||
: QString();
|
||||
// PipeWire holds the devices it serves; its graph names the clients.
|
||||
if (name == QLatin1String("pipewire") || name == QLatin1String("wireplumber"))
|
||||
continue;
|
||||
DeviceUse use;
|
||||
use.kind = DeviceUse::Camera;
|
||||
use.pid = pid;
|
||||
use.name = name;
|
||||
uses.append(use);
|
||||
}
|
||||
}
|
||||
std::sort(uses.begin(), uses.end(), [](const DeviceUse &a, const DeviceUse &b) {
|
||||
return std::tie(a.pid, a.name) < std::tie(b.pid, b.name);
|
||||
});
|
||||
if (uses != m_uses) {
|
||||
m_uses = uses;
|
||||
Q_EMIT usesChanged();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "deviceuse.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QTimer>
|
||||
|
||||
class QSocketNotifier;
|
||||
|
||||
namespace CutefishPrivacy {
|
||||
|
||||
/**
|
||||
* The processes that hold a video device open themselves, outside PipeWire.
|
||||
* Opens and closes of the device nodes arrive through inotify; only then are
|
||||
* the processes' descriptors read, and an open must outlast a settle delay to
|
||||
* count, so the brief opens that probe a device never show.
|
||||
*/
|
||||
class VideoDeviceWatch : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit VideoDeviceWatch(const QString &deviceDirectory = QStringLiteral("/dev"),
|
||||
const QString &processDirectory = QStringLiteral("/proc"),
|
||||
QObject *parent = nullptr);
|
||||
~VideoDeviceWatch() override;
|
||||
|
||||
DeviceUses uses() const { return m_uses; }
|
||||
|
||||
static constexpr int SettleDelay = 1000;
|
||||
|
||||
Q_SIGNALS:
|
||||
void usesChanged();
|
||||
|
||||
private:
|
||||
void readEvents();
|
||||
void watchDevices();
|
||||
void scan();
|
||||
|
||||
QString m_deviceDirectory;
|
||||
QString m_processDirectory;
|
||||
int m_fd = -1;
|
||||
int m_directoryWatch = -1;
|
||||
QSocketNotifier *m_notifier = nullptr;
|
||||
// Watch descriptor to device path.
|
||||
QHash<int, QString> m_devices;
|
||||
QTimer m_settle;
|
||||
DeviceUses m_uses;
|
||||
};
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue