Add capture routing rules (source → app) to complement playback rules

This commit is contained in:
Joey Yakimowich-Payne 2026-02-12 13:08:50 -07:00
commit 242d0ec09f
6 changed files with 418 additions and 78 deletions

View file

@ -1864,9 +1864,17 @@ void GraphEditorWidget::rebuildRulesList() {
matchLabel->setStyleSheet(valueStyle); matchLabel->setStyleSheet(valueStyle);
infoLayout->addWidget(matchLabel); infoLayout->addWidget(matchLabel);
auto *targetLabel = new QLabel( QString dirPrefix;
QString(QChar(0x2192)) + QStringLiteral(" ") + QString nodeTarget;
QString::fromStdString(rule.target_node)); if (rule.direction == warppipe::RuleDirection::kCapture) {
dirPrefix = QString(QChar(0x2190)) + QStringLiteral(" ");
nodeTarget = QString::fromStdString(rule.source_node);
} else {
dirPrefix = QString(QChar(0x2192)) + QStringLiteral(" ");
nodeTarget = QString::fromStdString(rule.target_node);
}
auto *targetLabel = new QLabel(dirPrefix + nodeTarget);
targetLabel->setStyleSheet(labelStyle); targetLabel->setStyleSheet(labelStyle);
infoLayout->addWidget(targetLabel); infoLayout->addWidget(targetLabel);
@ -1880,9 +1888,11 @@ void GraphEditorWidget::rebuildRulesList() {
std::string ruleBin = rule.match.process_binary; std::string ruleBin = rule.match.process_binary;
std::string ruleRole = rule.match.media_role; std::string ruleRole = rule.match.media_role;
std::string ruleTarget = rule.target_node; std::string ruleTarget = rule.target_node;
warppipe::RuleDirection ruleDir = rule.direction;
std::string ruleSource = rule.source_node;
connect(editBtn, &QPushButton::clicked, this, connect(editBtn, &QPushButton::clicked, this,
[this, ruleApp, ruleBin, ruleRole, ruleTarget, ruleId]() { [this, ruleApp, ruleBin, ruleRole, ruleTarget, ruleId, ruleDir, ruleSource]() {
showAddRuleDialog(ruleApp, ruleBin, ruleRole, ruleTarget, ruleId); showAddRuleDialog(ruleApp, ruleBin, ruleRole, ruleTarget, ruleId, ruleDir, ruleSource);
}); });
cardLayout->addWidget(editBtn); cardLayout->addWidget(editBtn);
@ -1931,7 +1941,9 @@ void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp,
const std::string &prefillBin, const std::string &prefillBin,
const std::string &prefillRole, const std::string &prefillRole,
const std::string &prefillTarget, const std::string &prefillTarget,
warppipe::RuleId editRuleId) { warppipe::RuleId editRuleId,
warppipe::RuleDirection prefillDirection,
const std::string &prefillSource) {
if (!m_client) if (!m_client)
return; return;
@ -1955,6 +1967,15 @@ void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp,
form->setContentsMargins(16, 16, 16, 16); form->setContentsMargins(16, 16, 16, 16);
form->setSpacing(8); form->setSpacing(8);
auto *directionCombo = new QComboBox();
directionCombo->addItem(QStringLiteral("Playback (App ") + QString(QChar(0x2192)) + QStringLiteral(" Sink)"),
static_cast<int>(warppipe::RuleDirection::kPlayback));
directionCombo->addItem(QStringLiteral("Capture (Source ") + QString(QChar(0x2192)) + QStringLiteral(" App)"),
static_cast<int>(warppipe::RuleDirection::kCapture));
if (prefillDirection == warppipe::RuleDirection::kCapture)
directionCombo->setCurrentIndex(1);
form->addRow(QStringLiteral("Direction:"), directionCombo);
auto *appNameEdit = new QLineEdit(); auto *appNameEdit = new QLineEdit();
appNameEdit->setPlaceholderText(QStringLiteral("e.g. Firefox")); appNameEdit->setPlaceholderText(QStringLiteral("e.g. Firefox"));
if (!prefillApp.empty()) if (!prefillApp.empty())
@ -1974,22 +1995,47 @@ void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp,
form->addRow(QStringLiteral("Media Role:"), mediaRoleEdit); form->addRow(QStringLiteral("Media Role:"), mediaRoleEdit);
auto *targetCombo = new QComboBox(); auto *targetCombo = new QComboBox();
auto *sourceCombo = new QComboBox();
auto *targetLabel = new QLabel(QStringLiteral("Target Sink:"));
auto *sourceLabel = new QLabel(QStringLiteral("Source Node:"));
auto nodesResult = m_client->ListNodes(); auto nodesResult = m_client->ListNodes();
if (nodesResult.ok()) { if (nodesResult.ok()) {
for (const auto &node : nodesResult.value) { for (const auto &node : nodesResult.value) {
if (node.media_class.find("Sink") != std::string::npos) {
QString label = QString::fromStdString( QString label = QString::fromStdString(
node.description.empty() ? node.name : node.description); node.description.empty() ? node.name : node.description);
targetCombo->addItem(label, QString::fromStdString(node.name)); QString data = QString::fromStdString(node.name);
if (node.media_class.find("Sink") != std::string::npos) {
targetCombo->addItem(label, data);
}
if (node.media_class.find("Source") != std::string::npos) {
sourceCombo->addItem(label, data);
} }
} }
} }
if (!prefillTarget.empty()) { if (!prefillTarget.empty()) {
int idx = targetCombo->findData(QString::fromStdString(prefillTarget)); int idx = targetCombo->findData(QString::fromStdString(prefillTarget));
if (idx >= 0) if (idx >= 0) targetCombo->setCurrentIndex(idx);
targetCombo->setCurrentIndex(idx);
} }
form->addRow(QStringLiteral("Target Node:"), targetCombo); if (!prefillSource.empty()) {
int idx = sourceCombo->findData(QString::fromStdString(prefillSource));
if (idx >= 0) sourceCombo->setCurrentIndex(idx);
}
form->addRow(targetLabel, targetCombo);
form->addRow(sourceLabel, sourceCombo);
auto updateVisibility = [=](int index) {
bool isCapture = directionCombo->itemData(index).toInt() ==
static_cast<int>(warppipe::RuleDirection::kCapture);
targetLabel->setVisible(!isCapture);
targetCombo->setVisible(!isCapture);
sourceLabel->setVisible(isCapture);
sourceCombo->setVisible(isCapture);
};
connect(directionCombo, &QComboBox::currentIndexChanged, &dlg, updateVisibility);
updateVisibility(directionCombo->currentIndex());
auto *buttons = new QDialogButtonBox( auto *buttons = new QDialogButtonBox(
QDialogButtonBox::Ok | QDialogButtonBox::Cancel); QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
@ -2007,16 +2053,26 @@ void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp,
std::string appName = appNameEdit->text().trimmed().toStdString(); std::string appName = appNameEdit->text().trimmed().toStdString();
std::string procBin = processBinEdit->text().trimmed().toStdString(); std::string procBin = processBinEdit->text().trimmed().toStdString();
std::string role = mediaRoleEdit->text().trimmed().toStdString(); std::string role = mediaRoleEdit->text().trimmed().toStdString();
std::string target = targetCombo->currentData().toString().toStdString(); auto dir = static_cast<warppipe::RuleDirection>(
directionCombo->currentData().toInt());
if (appName.empty() && procBin.empty() && role.empty()) { if (appName.empty() && procBin.empty() && role.empty()) {
QMessageBox::warning(this, QStringLiteral("Invalid Rule"), QMessageBox::warning(this, QStringLiteral("Invalid Rule"),
QStringLiteral("At least one match field must be filled.")); QStringLiteral("At least one match field must be filled."));
return; return;
} }
if (target.empty()) {
std::string target = targetCombo->currentData().toString().toStdString();
std::string source = sourceCombo->currentData().toString().toStdString();
if (dir == warppipe::RuleDirection::kPlayback && target.empty()) {
QMessageBox::warning(this, QStringLiteral("Invalid Rule"), QMessageBox::warning(this, QStringLiteral("Invalid Rule"),
QStringLiteral("A target node must be selected.")); QStringLiteral("A target sink must be selected."));
return;
}
if (dir == warppipe::RuleDirection::kCapture && source.empty()) {
QMessageBox::warning(this, QStringLiteral("Invalid Rule"),
QStringLiteral("A source node must be selected."));
return; return;
} }
@ -2028,7 +2084,12 @@ void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp,
rule.match.application_name = appName; rule.match.application_name = appName;
rule.match.process_binary = procBin; rule.match.process_binary = procBin;
rule.match.media_role = role; rule.match.media_role = role;
rule.direction = dir;
if (dir == warppipe::RuleDirection::kPlayback) {
rule.target_node = target; rule.target_node = target;
} else {
rule.source_node = source;
}
m_client->AddRouteRule(rule); m_client->AddRouteRule(rule);
rebuildRulesList(); rebuildRulesList();
} }

View file

@ -101,7 +101,9 @@ private:
const std::string &prefillBin = {}, const std::string &prefillBin = {},
const std::string &prefillRole = {}, const std::string &prefillRole = {},
const std::string &prefillTarget = {}, const std::string &prefillTarget = {},
warppipe::RuleId editRuleId = {}); warppipe::RuleId editRuleId = {},
warppipe::RuleDirection prefillDirection = warppipe::RuleDirection::kPlayback,
const std::string &prefillSource = {});
void setConnectionStyle(ConnectionStyleType style); void setConnectionStyle(ConnectionStyleType style);
void onSelectionChanged(); void onSelectionChanged();
void updateNodeDetailsPanel(QtNodes::NodeId nodeId); void updateNodeDetailsPanel(QtNodes::NodeId nodeId);

View file

@ -132,10 +132,17 @@ struct RuleMatch {
std::string media_role; std::string media_role;
}; };
enum class RuleDirection : uint8_t {
kPlayback = 0,
kCapture,
};
struct RouteRule { struct RouteRule {
RuleId id; RuleId id;
RuleMatch match; RuleMatch match;
std::string target_node; std::string target_node;
RuleDirection direction = RuleDirection::kPlayback;
std::string source_node;
}; };
struct VirtualNodeInfo { struct VirtualNodeInfo {

View file

@ -78,6 +78,7 @@ struct PendingAutoLink {
uint32_t source_node_id = 0; uint32_t source_node_id = 0;
std::string target_node_name; std::string target_node_name;
uint32_t rule_id = 0; uint32_t rule_id = 0;
RuleDirection direction = RuleDirection::kPlayback;
}; };
bool MatchesRule(const NodeInfo& node, const RuleMatch& match) { bool MatchesRule(const NodeInfo& node, const RuleMatch& match) {
@ -1200,28 +1201,48 @@ void Client::Impl::DisconnectLocked() {
void Client::Impl::CheckRulesForNode(const NodeInfo& node) { void Client::Impl::CheckRulesForNode(const NodeInfo& node) {
for (const auto& entry : route_rules) { for (const auto& entry : route_rules) {
if (MatchesRule(node, entry.second.match)) { if (!MatchesRule(node, entry.second.match)) continue;
const RouteRule& rule = entry.second;
PendingAutoLink pending; PendingAutoLink pending;
pending.source_node_id = node.id.value;
pending.target_node_name = entry.second.target_node;
pending.rule_id = entry.first; pending.rule_id = entry.first;
pending.direction = rule.direction;
if (rule.direction == RuleDirection::kPlayback) {
pending.source_node_id = node.id.value;
pending.target_node_name = rule.target_node;
} else if (!rule.source_node.empty()) {
// source_node_id = app (input side); target_node_name = hw source (output side)
pending.source_node_id = node.id.value;
pending.target_node_name = rule.source_node;
} else {
continue;
}
pending_auto_links.push_back(std::move(pending)); pending_auto_links.push_back(std::move(pending));
SchedulePolicySync(); SchedulePolicySync();
} }
}
} }
void Client::Impl::EnforceRulesForLink(uint32_t link_id, uint32_t out_port, void Client::Impl::EnforceRulesForLink(uint32_t link_id, uint32_t out_port,
uint32_t in_port) { uint32_t in_port) {
auto port_it = ports.find(out_port); auto out_port_it = ports.find(out_port);
if (port_it == ports.end()) return; auto in_port_it = ports.find(in_port);
uint32_t src_node_id = port_it->second.node.value; if (out_port_it == ports.end() || in_port_it == ports.end()) return;
auto node_it = nodes.find(src_node_id); uint32_t src_node_id = out_port_it->second.node.value;
if (node_it == nodes.end()) return; uint32_t dest_node_id = in_port_it->second.node.value;
auto src_node_it = nodes.find(src_node_id);
auto dest_node_it = nodes.find(dest_node_id);
bool should_destroy = false;
// Playback enforcement: if source app matches a playback rule, link must go to rule target
if (src_node_it != nodes.end()) {
std::vector<uint32_t> rule_target_ids; std::vector<uint32_t> rule_target_ids;
for (const auto& rule_entry : route_rules) { for (const auto& rule_entry : route_rules) {
if (!MatchesRule(node_it->second, rule_entry.second.match)) continue; if (rule_entry.second.direction != RuleDirection::kPlayback) continue;
if (!MatchesRule(src_node_it->second, rule_entry.second.match)) continue;
for (const auto& n : nodes) { for (const auto& n : nodes) {
if (n.second.name == rule_entry.second.target_node) { if (n.second.name == rule_entry.second.target_node) {
rule_target_ids.push_back(n.first); rule_target_ids.push_back(n.first);
@ -1229,14 +1250,38 @@ void Client::Impl::EnforceRulesForLink(uint32_t link_id, uint32_t out_port,
} }
} }
} }
if (rule_target_ids.empty()) return; if (!rule_target_ids.empty()) {
bool links_to_target = false;
auto in_port_it = ports.find(in_port);
if (in_port_it == ports.end()) return;
uint32_t dest_node_id = in_port_it->second.node.value;
for (uint32_t tid : rule_target_ids) { for (uint32_t tid : rule_target_ids) {
if (dest_node_id == tid) return; if (dest_node_id == tid) { links_to_target = true; break; }
} }
if (!links_to_target) should_destroy = true;
}
}
// Capture enforcement: if dest app matches a capture rule, link must come from rule source
if (!should_destroy && dest_node_it != nodes.end()) {
std::vector<uint32_t> rule_source_ids;
for (const auto& rule_entry : route_rules) {
if (rule_entry.second.direction != RuleDirection::kCapture) continue;
if (!MatchesRule(dest_node_it->second, rule_entry.second.match)) continue;
for (const auto& n : nodes) {
if (n.second.name == rule_entry.second.source_node) {
rule_source_ids.push_back(n.first);
break;
}
}
}
if (!rule_source_ids.empty()) {
bool links_from_source = false;
for (uint32_t sid : rule_source_ids) {
if (src_node_id == sid) { links_from_source = true; break; }
}
if (!links_from_source) should_destroy = true;
}
}
if (!should_destroy) return;
if (link_proxies.count(link_id)) return; if (link_proxies.count(link_id)) return;
for (const auto& proxy : auto_link_proxies) { for (const auto& proxy : auto_link_proxies) {
@ -1305,6 +1350,18 @@ void Client::Impl::ProcessPendingAutoLinks() {
std::vector<PortEntry> src_ports; std::vector<PortEntry> src_ports;
std::vector<PortEntry> tgt_ports; std::vector<PortEntry> tgt_ports;
if (it->direction == RuleDirection::kCapture) {
// Capture: target_node_name is the hw source (outputs), source_node_id is the app (inputs)
for (const auto& port_entry : ports) {
const PortInfo& port = port_entry.second;
if (port.node.value == target_node_id && !port.is_input) {
src_ports.push_back({port_entry.first, port.name});
}
if (port.node.value == it->source_node_id && port.is_input) {
tgt_ports.push_back({port_entry.first, port.name});
}
}
} else {
for (const auto& port_entry : ports) { for (const auto& port_entry : ports) {
const PortInfo& port = port_entry.second; const PortInfo& port = port_entry.second;
if (port.node.value == it->source_node_id && !port.is_input) { if (port.node.value == it->source_node_id && !port.is_input) {
@ -1314,6 +1371,7 @@ void Client::Impl::ProcessPendingAutoLinks() {
tgt_ports.push_back({port_entry.first, port.name}); tgt_ports.push_back({port_entry.first, port.name});
} }
} }
}
if (src_ports.empty() || tgt_ports.empty()) { if (src_ports.empty() || tgt_ports.empty()) {
++it; ++it;
@ -1643,6 +1701,8 @@ void Client::Impl::AutoSave() {
rule_obj["match"]["process_binary"] = entry.second.match.process_binary; rule_obj["match"]["process_binary"] = entry.second.match.process_binary;
rule_obj["match"]["media_role"] = entry.second.match.media_role; rule_obj["match"]["media_role"] = entry.second.match.media_role;
rule_obj["target_node"] = entry.second.target_node; rule_obj["target_node"] = entry.second.target_node;
rule_obj["direction"] = entry.second.direction == RuleDirection::kCapture ? "capture" : "playback";
rule_obj["source_node"] = entry.second.source_node;
rules_array.push_back(std::move(rule_obj)); rules_array.push_back(std::move(rule_obj));
} }
} }
@ -2595,9 +2655,12 @@ Result<RuleId> Client::AddRouteRule(const RouteRule& rule) {
rule.match.media_role.empty()) { rule.match.media_role.empty()) {
return {Status::Error(StatusCode::kInvalidArgument, "rule match has no criteria"), {}}; return {Status::Error(StatusCode::kInvalidArgument, "rule match has no criteria"), {}};
} }
if (rule.target_node.empty()) { if (rule.direction == RuleDirection::kPlayback && rule.target_node.empty()) {
return {Status::Error(StatusCode::kInvalidArgument, "rule target node is empty"), {}}; return {Status::Error(StatusCode::kInvalidArgument, "rule target node is empty"), {}};
} }
if (rule.direction == RuleDirection::kCapture && rule.source_node.empty()) {
return {Status::Error(StatusCode::kInvalidArgument, "rule source node is empty"), {}};
}
uint32_t id = 0; uint32_t id = 0;
{ {
@ -2610,9 +2673,16 @@ Result<RuleId> Client::AddRouteRule(const RouteRule& rule) {
for (const auto& node_entry : impl_->nodes) { for (const auto& node_entry : impl_->nodes) {
if (MatchesRule(node_entry.second, rule.match)) { if (MatchesRule(node_entry.second, rule.match)) {
PendingAutoLink pending; PendingAutoLink pending;
pending.rule_id = id;
pending.direction = rule.direction;
if (rule.direction == RuleDirection::kPlayback) {
pending.source_node_id = node_entry.first; pending.source_node_id = node_entry.first;
pending.target_node_name = rule.target_node; pending.target_node_name = rule.target_node;
pending.rule_id = id; } else {
pending.source_node_id = node_entry.first;
pending.target_node_name = rule.source_node;
}
impl_->pending_auto_links.push_back(std::move(pending)); impl_->pending_auto_links.push_back(std::move(pending));
} }
} }
@ -2631,6 +2701,8 @@ Result<RuleId> Client::AddRouteRule(const RouteRule& rule) {
Status Client::RemoveRouteRule(RuleId id) { Status Client::RemoveRouteRule(RuleId id) {
RuleMatch match; RuleMatch match;
std::string target_node; std::string target_node;
std::string source_node;
RuleDirection direction = RuleDirection::kPlayback;
{ {
std::lock_guard<std::mutex> lock(impl_->cache_mutex); std::lock_guard<std::mutex> lock(impl_->cache_mutex);
@ -2640,6 +2712,8 @@ Status Client::RemoveRouteRule(RuleId id) {
} }
match = it->second.match; match = it->second.match;
target_node = it->second.target_node; target_node = it->second.target_node;
source_node = it->second.source_node;
direction = it->second.direction;
impl_->route_rules.erase(it); impl_->route_rules.erase(it);
auto pending_it = impl_->pending_auto_links.begin(); auto pending_it = impl_->pending_auto_links.begin();
@ -2657,6 +2731,8 @@ Status Client::RemoveRouteRule(RuleId id) {
std::vector<std::pair<uint32_t, uint32_t>> pairs_to_remove; std::vector<std::pair<uint32_t, uint32_t>> pairs_to_remove;
{ {
std::lock_guard<std::mutex> lock(impl_->cache_mutex); std::lock_guard<std::mutex> lock(impl_->cache_mutex);
if (direction == RuleDirection::kPlayback) {
uint32_t target_id = 0; uint32_t target_id = 0;
for (const auto& n : impl_->nodes) { for (const auto& n : impl_->nodes) {
if (n.second.name == target_node) { if (n.second.name == target_node) {
@ -2681,6 +2757,32 @@ Status Client::RemoveRouteRule(RuleId id) {
} }
} }
} }
} else {
uint32_t source_id = 0;
for (const auto& n : impl_->nodes) {
if (n.second.name == source_node) {
source_id = n.first;
break;
}
}
if (source_id) {
for (const auto& link_entry : impl_->links) {
uint32_t out_port = link_entry.second.output_port.value;
uint32_t in_port = link_entry.second.input_port.value;
auto out_port_it = impl_->ports.find(out_port);
if (out_port_it == impl_->ports.end() ||
out_port_it->second.node.value != source_id) continue;
auto in_port_it = impl_->ports.find(in_port);
if (in_port_it == impl_->ports.end()) continue;
auto dest_node_it = impl_->nodes.find(in_port_it->second.node.value);
if (dest_node_it == impl_->nodes.end()) continue;
if (MatchesRule(dest_node_it->second, match)) {
links_to_destroy.push_back(link_entry.first);
pairs_to_remove.emplace_back(out_port, in_port);
}
}
}
}
} }
if (!links_to_destroy.empty() && impl_->thread_loop) { if (!links_to_destroy.empty() && impl_->thread_loop) {
@ -2802,6 +2904,8 @@ Status Client::SaveConfig(std::string_view path) {
rule_obj["match"]["process_binary"] = entry.second.match.process_binary; rule_obj["match"]["process_binary"] = entry.second.match.process_binary;
rule_obj["match"]["media_role"] = entry.second.match.media_role; rule_obj["match"]["media_role"] = entry.second.match.media_role;
rule_obj["target_node"] = entry.second.target_node; rule_obj["target_node"] = entry.second.target_node;
rule_obj["direction"] = entry.second.direction == RuleDirection::kCapture ? "capture" : "playback";
rule_obj["source_node"] = entry.second.source_node;
rules_array.push_back(std::move(rule_obj)); rules_array.push_back(std::move(rule_obj));
} }
} }
@ -2943,10 +3047,16 @@ Status Client::LoadConfig(std::string_view path) {
rule.match.media_role = m.value("media_role", ""); rule.match.media_role = m.value("media_role", "");
} }
rule.target_node = rule_obj.value("target_node", ""); rule.target_node = rule_obj.value("target_node", "");
if (!rule.target_node.empty() && rule.source_node = rule_obj.value("source_node", "");
(!rule.match.application_name.empty() || std::string dir_str = rule_obj.value("direction", "playback");
rule.direction = (dir_str == "capture") ? RuleDirection::kCapture
: RuleDirection::kPlayback;
bool has_match = !rule.match.application_name.empty() ||
!rule.match.process_binary.empty() || !rule.match.process_binary.empty() ||
!rule.match.media_role.empty())) { !rule.match.media_role.empty();
bool has_target = (rule.direction == RuleDirection::kPlayback && !rule.target_node.empty()) ||
(rule.direction == RuleDirection::kCapture && !rule.source_node.empty());
if (has_match && has_target) {
AddRouteRule(rule); AddRouteRule(rule);
} }
} catch (...) { } catch (...) {

View file

@ -317,10 +317,10 @@ bool acceptRuleDialog(const QString &appName, const QString &targetNodeName) {
} }
auto combos = dialog->findChildren<QComboBox *>(); auto combos = dialog->findChildren<QComboBox *>();
if (!combos.isEmpty()) { if (combos.size() >= 2) {
int idx = combos[0]->findData(targetNodeName); int idx = combos[1]->findData(targetNodeName);
if (idx >= 0) { if (idx >= 0) {
combos[0]->setCurrentIndex(idx); combos[1]->setCurrentIndex(idx);
} }
} }

View file

@ -1013,3 +1013,163 @@ TEST_CASE("EnsureNodeMeter fails for nonexistent node") {
REQUIRE_FALSE(status.ok()); REQUIRE_FALSE(status.ok());
REQUIRE(status.code == warppipe::StatusCode::kNotFound); REQUIRE(status.code == warppipe::StatusCode::kNotFound);
} }
TEST_CASE("capture rule validation rejects empty source_node") {
auto result = warppipe::Client::Create(DefaultOptions());
if (!result.ok()) {
SUCCEED("PipeWire unavailable");
return;
}
warppipe::RouteRule rule;
rule.match.application_name = "discord";
rule.direction = warppipe::RuleDirection::kCapture;
auto r = result.value->AddRouteRule(rule);
REQUIRE_FALSE(r.ok());
REQUIRE(r.status.code == warppipe::StatusCode::kInvalidArgument);
}
TEST_CASE("capture rule validation accepts valid source_node") {
auto result = warppipe::Client::Create(DefaultOptions());
if (!result.ok()) {
SUCCEED("PipeWire unavailable");
return;
}
warppipe::RouteRule rule;
rule.match.application_name = "discord";
rule.direction = warppipe::RuleDirection::kCapture;
rule.source_node = "alsa_input.usb-mic";
auto r = result.value->AddRouteRule(rule);
REQUIRE(r.ok());
REQUIRE(r.value.value != 0);
auto list = result.value->ListRouteRules();
REQUIRE(list.ok());
REQUIRE(list.value.size() == 1);
REQUIRE(list.value[0].direction == warppipe::RuleDirection::kCapture);
REQUIRE(list.value[0].source_node == "alsa_input.usb-mic");
}
TEST_CASE("capture rule creates pending auto-link for matching app") {
auto result = warppipe::Client::Create(DefaultOptions());
if (!result.ok()) {
SUCCEED("PipeWire unavailable");
return;
}
warppipe::RouteRule rule;
rule.match.application_name = "teams";
rule.direction = warppipe::RuleDirection::kCapture;
rule.source_node = "hw-mic";
REQUIRE(result.value->AddRouteRule(rule).ok());
REQUIRE(result.value->Test_GetPendingAutoLinkCount() == 0);
warppipe::NodeInfo app_node;
app_node.id = warppipe::NodeId{800001};
app_node.name = "teams-capture";
app_node.media_class = "Stream/Input/Audio";
app_node.application_name = "teams";
REQUIRE(result.value->Test_InsertNode(app_node).ok());
REQUIRE(result.value->Test_GetPendingAutoLinkCount() == 1);
}
TEST_CASE("capture rule ignores non-matching app") {
auto result = warppipe::Client::Create(DefaultOptions());
if (!result.ok()) {
SUCCEED("PipeWire unavailable");
return;
}
warppipe::RouteRule rule;
rule.match.application_name = "discord";
rule.direction = warppipe::RuleDirection::kCapture;
rule.source_node = "hw-mic";
REQUIRE(result.value->AddRouteRule(rule).ok());
warppipe::NodeInfo node;
node.id = warppipe::NodeId{800002};
node.name = "zoom-capture";
node.media_class = "Stream/Input/Audio";
node.application_name = "zoom";
REQUIRE(result.value->Test_InsertNode(node).ok());
REQUIRE(result.value->Test_GetPendingAutoLinkCount() == 0);
}
TEST_CASE("capture rule added after app creates pending link") {
auto result = warppipe::Client::Create(DefaultOptions());
if (!result.ok()) {
SUCCEED("PipeWire unavailable");
return;
}
warppipe::NodeInfo node;
node.id = warppipe::NodeId{800003};
node.name = "discord-capture";
node.media_class = "Stream/Input/Audio";
node.application_name = "discord";
REQUIRE(result.value->Test_InsertNode(node).ok());
warppipe::RouteRule rule;
rule.match.application_name = "discord";
rule.direction = warppipe::RuleDirection::kCapture;
rule.source_node = "hw-mic";
REQUIRE(result.value->AddRouteRule(rule).ok());
REQUIRE(result.value->Test_GetPendingAutoLinkCount() == 1);
}
TEST_CASE("playback rule still rejects empty target_node") {
auto result = warppipe::Client::Create(DefaultOptions());
if (!result.ok()) {
SUCCEED("PipeWire unavailable");
return;
}
warppipe::RouteRule rule;
rule.match.application_name = "firefox";
rule.direction = warppipe::RuleDirection::kPlayback;
auto r = result.value->AddRouteRule(rule);
REQUIRE_FALSE(r.ok());
REQUIRE(r.status.code == warppipe::StatusCode::kInvalidArgument);
}
TEST_CASE("save and load capture rule round trip") {
auto result = warppipe::Client::Create(DefaultOptions());
if (!result.ok()) {
SUCCEED("PipeWire unavailable");
return;
}
warppipe::RouteRule rule;
rule.match.application_name = "discord";
rule.match.process_binary = "Discord";
rule.direction = warppipe::RuleDirection::kCapture;
rule.source_node = "alsa_input.usb-mic";
REQUIRE(result.value->AddRouteRule(rule).ok());
const char* path = "/tmp/warppipe_test_capture_config.json";
REQUIRE(result.value->SaveConfig(path).ok());
auto result2 = warppipe::Client::Create(DefaultOptions());
if (!result2.ok()) {
SUCCEED("PipeWire unavailable");
return;
}
REQUIRE(result2.value->LoadConfig(path).ok());
auto rules = result2.value->ListRouteRules();
REQUIRE(rules.ok());
REQUIRE(rules.value.size() == 1);
REQUIRE(rules.value[0].match.application_name == "discord");
REQUIRE(rules.value[0].match.process_binary == "Discord");
REQUIRE(rules.value[0].direction == warppipe::RuleDirection::kCapture);
REQUIRE(rules.value[0].source_node == "alsa_input.usb-mic");
REQUIRE(rules.value[0].target_node.empty());
std::remove(path);
}