From 242d0ec09f0fce2e39247a76dde8490e41318e54 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 12 Feb 2026 13:08:50 -0700 Subject: [PATCH] =?UTF-8?q?Add=20capture=20routing=20rules=20(source=20?= =?UTF-8?q?=E2=86=92=20app)=20to=20complement=20playback=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gui/GraphEditorWidget.cpp | 93 ++++++++++--- gui/GraphEditorWidget.h | 4 +- include/warppipe/warppipe.hpp | 7 + src/warppipe.cpp | 226 +++++++++++++++++++++++-------- tests/gui/warppipe_gui_tests.cpp | 6 +- tests/warppipe_tests.cpp | 160 ++++++++++++++++++++++ 6 files changed, 418 insertions(+), 78 deletions(-) diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 763ad14..7c26fa9 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -1864,9 +1864,17 @@ void GraphEditorWidget::rebuildRulesList() { matchLabel->setStyleSheet(valueStyle); infoLayout->addWidget(matchLabel); - auto *targetLabel = new QLabel( - QString(QChar(0x2192)) + QStringLiteral(" ") + - QString::fromStdString(rule.target_node)); + QString dirPrefix; + QString nodeTarget; + 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); infoLayout->addWidget(targetLabel); @@ -1880,9 +1888,11 @@ void GraphEditorWidget::rebuildRulesList() { std::string ruleBin = rule.match.process_binary; std::string ruleRole = rule.match.media_role; std::string ruleTarget = rule.target_node; + warppipe::RuleDirection ruleDir = rule.direction; + std::string ruleSource = rule.source_node; connect(editBtn, &QPushButton::clicked, this, - [this, ruleApp, ruleBin, ruleRole, ruleTarget, ruleId]() { - showAddRuleDialog(ruleApp, ruleBin, ruleRole, ruleTarget, ruleId); + [this, ruleApp, ruleBin, ruleRole, ruleTarget, ruleId, ruleDir, ruleSource]() { + showAddRuleDialog(ruleApp, ruleBin, ruleRole, ruleTarget, ruleId, ruleDir, ruleSource); }); cardLayout->addWidget(editBtn); @@ -1931,7 +1941,9 @@ void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp, const std::string &prefillBin, const std::string &prefillRole, const std::string &prefillTarget, - warppipe::RuleId editRuleId) { + warppipe::RuleId editRuleId, + warppipe::RuleDirection prefillDirection, + const std::string &prefillSource) { if (!m_client) return; @@ -1955,6 +1967,15 @@ void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp, form->setContentsMargins(16, 16, 16, 16); form->setSpacing(8); + auto *directionCombo = new QComboBox(); + directionCombo->addItem(QStringLiteral("Playback (App ") + QString(QChar(0x2192)) + QStringLiteral(" Sink)"), + static_cast(warppipe::RuleDirection::kPlayback)); + directionCombo->addItem(QStringLiteral("Capture (Source ") + QString(QChar(0x2192)) + QStringLiteral(" App)"), + static_cast(warppipe::RuleDirection::kCapture)); + if (prefillDirection == warppipe::RuleDirection::kCapture) + directionCombo->setCurrentIndex(1); + form->addRow(QStringLiteral("Direction:"), directionCombo); + auto *appNameEdit = new QLineEdit(); appNameEdit->setPlaceholderText(QStringLiteral("e.g. Firefox")); if (!prefillApp.empty()) @@ -1974,22 +1995,47 @@ void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp, form->addRow(QStringLiteral("Media Role:"), mediaRoleEdit); 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(); if (nodesResult.ok()) { for (const auto &node : nodesResult.value) { + QString label = QString::fromStdString( + node.description.empty() ? node.name : node.description); + QString data = QString::fromStdString(node.name); if (node.media_class.find("Sink") != std::string::npos) { - QString label = QString::fromStdString( - node.description.empty() ? node.name : node.description); - targetCombo->addItem(label, QString::fromStdString(node.name)); + targetCombo->addItem(label, data); + } + if (node.media_class.find("Source") != std::string::npos) { + sourceCombo->addItem(label, data); } } } + if (!prefillTarget.empty()) { int idx = targetCombo->findData(QString::fromStdString(prefillTarget)); - if (idx >= 0) - targetCombo->setCurrentIndex(idx); + if (idx >= 0) 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(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( QDialogButtonBox::Ok | QDialogButtonBox::Cancel); @@ -2007,16 +2053,26 @@ void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp, std::string appName = appNameEdit->text().trimmed().toStdString(); std::string procBin = processBinEdit->text().trimmed().toStdString(); std::string role = mediaRoleEdit->text().trimmed().toStdString(); - std::string target = targetCombo->currentData().toString().toStdString(); + auto dir = static_cast( + directionCombo->currentData().toInt()); if (appName.empty() && procBin.empty() && role.empty()) { QMessageBox::warning(this, QStringLiteral("Invalid Rule"), QStringLiteral("At least one match field must be filled.")); 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"), - 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; } @@ -2028,7 +2084,12 @@ void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp, rule.match.application_name = appName; rule.match.process_binary = procBin; rule.match.media_role = role; - rule.target_node = target; + rule.direction = dir; + if (dir == warppipe::RuleDirection::kPlayback) { + rule.target_node = target; + } else { + rule.source_node = source; + } m_client->AddRouteRule(rule); rebuildRulesList(); } diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index 73247a4..b7e0dbe 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -101,7 +101,9 @@ private: const std::string &prefillBin = {}, const std::string &prefillRole = {}, 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 onSelectionChanged(); void updateNodeDetailsPanel(QtNodes::NodeId nodeId); diff --git a/include/warppipe/warppipe.hpp b/include/warppipe/warppipe.hpp index a01a0e6..55cfa94 100644 --- a/include/warppipe/warppipe.hpp +++ b/include/warppipe/warppipe.hpp @@ -132,10 +132,17 @@ struct RuleMatch { std::string media_role; }; +enum class RuleDirection : uint8_t { + kPlayback = 0, + kCapture, +}; + struct RouteRule { RuleId id; RuleMatch match; std::string target_node; + RuleDirection direction = RuleDirection::kPlayback; + std::string source_node; }; struct VirtualNodeInfo { diff --git a/src/warppipe.cpp b/src/warppipe.cpp index ba06c64..7213d75 100644 --- a/src/warppipe.cpp +++ b/src/warppipe.cpp @@ -78,6 +78,7 @@ struct PendingAutoLink { uint32_t source_node_id = 0; std::string target_node_name; uint32_t rule_id = 0; + RuleDirection direction = RuleDirection::kPlayback; }; bool MatchesRule(const NodeInfo& node, const RuleMatch& match) { @@ -1200,44 +1201,88 @@ void Client::Impl::DisconnectLocked() { void Client::Impl::CheckRulesForNode(const NodeInfo& node) { for (const auto& entry : route_rules) { - if (MatchesRule(node, entry.second.match)) { - PendingAutoLink pending; + if (!MatchesRule(node, entry.second.match)) continue; + const RouteRule& rule = entry.second; + + PendingAutoLink pending; + 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 = entry.second.target_node; - pending.rule_id = entry.first; - pending_auto_links.push_back(std::move(pending)); - SchedulePolicySync(); + 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)); + SchedulePolicySync(); } } void Client::Impl::EnforceRulesForLink(uint32_t link_id, uint32_t out_port, - uint32_t in_port) { - auto port_it = ports.find(out_port); - if (port_it == ports.end()) return; - uint32_t src_node_id = port_it->second.node.value; - auto node_it = nodes.find(src_node_id); - if (node_it == nodes.end()) return; + uint32_t in_port) { + auto out_port_it = ports.find(out_port); + auto in_port_it = ports.find(in_port); + if (out_port_it == ports.end() || in_port_it == ports.end()) return; + uint32_t src_node_id = out_port_it->second.node.value; + uint32_t dest_node_id = in_port_it->second.node.value; - std::vector rule_target_ids; - for (const auto& rule_entry : route_rules) { - if (!MatchesRule(node_it->second, rule_entry.second.match)) continue; - for (const auto& n : nodes) { - if (n.second.name == rule_entry.second.target_node) { - rule_target_ids.push_back(n.first); - break; + 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 rule_target_ids; + for (const auto& rule_entry : route_rules) { + if (rule_entry.second.direction != RuleDirection::kPlayback) continue; + if (!MatchesRule(src_node_it->second, rule_entry.second.match)) continue; + for (const auto& n : nodes) { + if (n.second.name == rule_entry.second.target_node) { + rule_target_ids.push_back(n.first); + break; + } } } + if (!rule_target_ids.empty()) { + bool links_to_target = false; + for (uint32_t tid : rule_target_ids) { + if (dest_node_id == tid) { links_to_target = true; break; } + } + if (!links_to_target) should_destroy = true; + } } - if (rule_target_ids.empty()) return; - 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) { - if (dest_node_id == tid) return; + // 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 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; for (const auto& proxy : auto_link_proxies) { if (proxy && proxy->output_port == out_port && @@ -1305,13 +1350,26 @@ void Client::Impl::ProcessPendingAutoLinks() { std::vector src_ports; std::vector tgt_ports; - for (const auto& port_entry : ports) { - const PortInfo& port = port_entry.second; - if (port.node.value == it->source_node_id && !port.is_input) { - src_ports.push_back({port_entry.first, port.name}); + 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}); + } } - if (port.node.value == target_node_id && port.is_input) { - tgt_ports.push_back({port_entry.first, port.name}); + } else { + for (const auto& port_entry : ports) { + const PortInfo& port = port_entry.second; + if (port.node.value == it->source_node_id && !port.is_input) { + src_ports.push_back({port_entry.first, port.name}); + } + if (port.node.value == target_node_id && port.is_input) { + tgt_ports.push_back({port_entry.first, port.name}); + } } } @@ -1643,6 +1701,8 @@ void Client::Impl::AutoSave() { rule_obj["match"]["process_binary"] = entry.second.match.process_binary; rule_obj["match"]["media_role"] = entry.second.match.media_role; 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)); } } @@ -2595,9 +2655,12 @@ Result Client::AddRouteRule(const RouteRule& rule) { rule.match.media_role.empty()) { 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"), {}}; } + if (rule.direction == RuleDirection::kCapture && rule.source_node.empty()) { + return {Status::Error(StatusCode::kInvalidArgument, "rule source node is empty"), {}}; + } uint32_t id = 0; { @@ -2610,9 +2673,16 @@ Result Client::AddRouteRule(const RouteRule& rule) { for (const auto& node_entry : impl_->nodes) { if (MatchesRule(node_entry.second, rule.match)) { PendingAutoLink pending; - pending.source_node_id = node_entry.first; - pending.target_node_name = rule.target_node; pending.rule_id = id; + pending.direction = rule.direction; + + if (rule.direction == RuleDirection::kPlayback) { + pending.source_node_id = node_entry.first; + pending.target_node_name = rule.target_node; + } else { + pending.source_node_id = node_entry.first; + pending.target_node_name = rule.source_node; + } impl_->pending_auto_links.push_back(std::move(pending)); } } @@ -2631,6 +2701,8 @@ Result Client::AddRouteRule(const RouteRule& rule) { Status Client::RemoveRouteRule(RuleId id) { RuleMatch match; std::string target_node; + std::string source_node; + RuleDirection direction = RuleDirection::kPlayback; { std::lock_guard lock(impl_->cache_mutex); @@ -2640,6 +2712,8 @@ Status Client::RemoveRouteRule(RuleId id) { } match = it->second.match; target_node = it->second.target_node; + source_node = it->second.source_node; + direction = it->second.direction; impl_->route_rules.erase(it); auto pending_it = impl_->pending_auto_links.begin(); @@ -2657,27 +2731,55 @@ Status Client::RemoveRouteRule(RuleId id) { std::vector> pairs_to_remove; { std::lock_guard lock(impl_->cache_mutex); - uint32_t target_id = 0; - for (const auto& n : impl_->nodes) { - if (n.second.name == target_node) { - target_id = n.first; - break; + + if (direction == RuleDirection::kPlayback) { + uint32_t target_id = 0; + for (const auto& n : impl_->nodes) { + if (n.second.name == target_node) { + target_id = n.first; + break; + } } - } - if (target_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 in_port_it = impl_->ports.find(in_port); - if (in_port_it == impl_->ports.end() || - in_port_it->second.node.value != target_id) continue; - auto out_port_it = impl_->ports.find(out_port); - if (out_port_it == impl_->ports.end()) continue; - auto src_node_it = impl_->nodes.find(out_port_it->second.node.value); - if (src_node_it == impl_->nodes.end()) continue; - if (MatchesRule(src_node_it->second, match)) { - links_to_destroy.push_back(link_entry.first); - pairs_to_remove.emplace_back(out_port, in_port); + if (target_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 in_port_it = impl_->ports.find(in_port); + if (in_port_it == impl_->ports.end() || + in_port_it->second.node.value != target_id) continue; + auto out_port_it = impl_->ports.find(out_port); + if (out_port_it == impl_->ports.end()) continue; + auto src_node_it = impl_->nodes.find(out_port_it->second.node.value); + if (src_node_it == impl_->nodes.end()) continue; + if (MatchesRule(src_node_it->second, match)) { + links_to_destroy.push_back(link_entry.first); + pairs_to_remove.emplace_back(out_port, in_port); + } + } + } + } 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); + } } } } @@ -2802,6 +2904,8 @@ Status Client::SaveConfig(std::string_view path) { rule_obj["match"]["process_binary"] = entry.second.match.process_binary; rule_obj["match"]["media_role"] = entry.second.match.media_role; 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)); } } @@ -2943,10 +3047,16 @@ Status Client::LoadConfig(std::string_view path) { rule.match.media_role = m.value("media_role", ""); } rule.target_node = rule_obj.value("target_node", ""); - if (!rule.target_node.empty() && - (!rule.match.application_name.empty() || - !rule.match.process_binary.empty() || - !rule.match.media_role.empty())) { + rule.source_node = rule_obj.value("source_node", ""); + 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.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); } } catch (...) { diff --git a/tests/gui/warppipe_gui_tests.cpp b/tests/gui/warppipe_gui_tests.cpp index 93949c8..fd1d227 100644 --- a/tests/gui/warppipe_gui_tests.cpp +++ b/tests/gui/warppipe_gui_tests.cpp @@ -317,10 +317,10 @@ bool acceptRuleDialog(const QString &appName, const QString &targetNodeName) { } auto combos = dialog->findChildren(); - if (!combos.isEmpty()) { - int idx = combos[0]->findData(targetNodeName); + if (combos.size() >= 2) { + int idx = combos[1]->findData(targetNodeName); if (idx >= 0) { - combos[0]->setCurrentIndex(idx); + combos[1]->setCurrentIndex(idx); } } diff --git a/tests/warppipe_tests.cpp b/tests/warppipe_tests.cpp index f03c743..4ec573a 100644 --- a/tests/warppipe_tests.cpp +++ b/tests/warppipe_tests.cpp @@ -1013,3 +1013,163 @@ TEST_CASE("EnsureNodeMeter fails for nonexistent node") { REQUIRE_FALSE(status.ok()); 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); +}