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

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