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);
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<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();
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<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(
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<warppipe::RuleDirection>(
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();
}

View file

@ -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);

View file

@ -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 {

View file

@ -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<uint32_t> 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<uint32_t> 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<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;
for (const auto& proxy : auto_link_proxies) {
if (proxy && proxy->output_port == out_port &&
@ -1305,13 +1350,26 @@ void Client::Impl::ProcessPendingAutoLinks() {
std::vector<PortEntry> src_ports;
std::vector<PortEntry> 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<RuleId> 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<RuleId> 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<RuleId> 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<std::mutex> 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<std::pair<uint32_t, uint32_t>> pairs_to_remove;
{
std::lock_guard<std::mutex> 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 (...) {

View file

@ -317,10 +317,10 @@ bool acceptRuleDialog(const QString &appName, const QString &targetNodeName) {
}
auto combos = dialog->findChildren<QComboBox *>();
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);
}
}

View file

@ -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);
}