Per-app send fixes + apps-mode UI rework (no send-all) + truly-global remembered lists

Per-app capture (the "foobar alone = no sound" report):
- ProcessLoopbackCapture: take ActivateAudioInterfaceAsync's out operation as a raw
  IntPtr (released after completion) instead of a typed interface. The eager RCW cast
  of the not-yet-realised operation object threw InvalidCastException / E_NOINTERFACE
  and killed EVERY specific-app capture at start.
- CompositeCaptureBackend: single IsPushEligible authority shared by Start,
  UpdateSources and the coalesced rebuild. The update paths were missing the
  ProcessLoopback exclusion, so switching whole-device -> one app kept the push
  backend and fed it "proc:<pid>" (GetDevice ArgumentException).
- PushModeWasapiBackend: loud backstop rejecting process-loopback specs.
- Self-test: lifecycle test now FAILS on an activation error (it previously passed
  green with the feature completely dead) + pure routing-rule checks.

Apps-mode UI (Ed 2026-07-16): the "Send all applications" checkbox is GONE from the
main window - applications mode always means picking specific apps; whole-system
audio is devices mode's job. Active list = running apps + any ticked app that is not
running ("(not running)" so it can be unticked); Remembered list = global address
book minus whatever is ticked. In-place list reconcile (no Clear+rebuild) kills the
NVDA double-read of the toggled row. Profile.SendAllApplications stays for the
SERVICE (deliberate divergence - a headless lock-screen sender wants system audio).

Remembered lists now genuinely machine-wide (AppConfig-backed): the settings store is
an intra-process cache, so remembered applications were forgotten on every exit and
remembered peers were per-profile in practice. Both books moved to AppConfig; legacy
per-profile peers are unioned in on profile load; profile save snapshots the global
book back for old-build compat. Cross-instance persistence pinned by self-test.

Service (issue #23): 15s capture pulse (callbacks/bytes/pre-encode peak/frames sent)
while sending - distinguishes "endpoint mix is genuinely silent at the lock screen"
from a pipeline fault, which callbacks alone cannot.

Gate: 38/38.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-16 22:50:38 +01:00
co-authored by Claude Opus 4.8
parent 92ad77ff52
commit 1003bbfdf2
10 changed files with 337 additions and 150 deletions
+80 -4
View File
@@ -70,6 +70,7 @@ internal static class SelfTest
RunStep(results, "Service reachability-gated sending (drop dead peers, re-arm recovered)", ServiceReachabilityGating);
RunStep(results, "Send-app capture change-detection (catch an app the instant it opens)", SendAppCaptureChangeDetection);
RunStep(results, "Remembered applications list is global + clearable", RememberedApplicationsGlobal);
RunStep(results, "Send-app lists semantics (ticked → Active, out of Remembered)", SendAppListSemantics);
RunStep(results, "Service registration args", ServiceRegistrationArgs);
RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine);
RunStep(results, "Recording split tracks (per-peer + own)", RecordingSplitTracks);
@@ -303,6 +304,21 @@ internal static class SelfTest
var supported = RemSound.Sender.ProcessLoopbackCapture.IsSupported;
Check(supported == OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041),
"support gate disagrees with the OS build check");
// Push-mode routing rule: a single whole-device loopback source IS push-eligible under tight
// latency, but a single per-app process-loopback source must NEVER be — the push backend opens an
// MMDevice by id and a synthetic "proc:<pid>" id makes GetDevice throw ArgumentException. This is
// the regression guard for "I only heard foobar with 'all applications' ticked": switching from the
// whole-device spec to a per-app spec used to keep the push backend and feed it the proc id.
var oneLoopback = new[] { new CaptureSourceSpec("dev-x", CaptureKind.Loopback, "device") };
var oneProc = new[] { new CaptureSourceSpec(ProcessLoopbackId.Format(1234), CaptureKind.ProcessLoopback, "app") };
Check(RemSound.Sender.CompositeCaptureBackend.IsPushEligibleFor(oneLoopback, tightLatency: true),
"a single whole-device loopback source should be push-eligible under tight latency");
Check(!RemSound.Sender.CompositeCaptureBackend.IsPushEligibleFor(oneProc, tightLatency: true),
"a per-app process-loopback source must never be routed to the push backend");
Check(!RemSound.Sender.CompositeCaptureBackend.IsPushEligibleFor(oneLoopback, tightLatency: false),
"nothing is push-eligible when tight latency is off");
return $"enumerated {apps.Count} app(s); process-loopback supported={supported}";
}
@@ -322,13 +338,24 @@ internal static class SelfTest
{
var capture = new RemSound.Sender.ProcessLoopbackCapture(pid);
var frames = 0L;
Exception? stopError = null;
capture.DataAvailable += (_, e) => Interlocked.Add(ref frames, e.BytesRecorded);
capture.RecordingStopped += (_, e) => stopError = e.Exception;
capture.StartRecording();
Thread.Sleep(150); // let activation + the capture loop run and then be torn down
capture.Dispose(); // teardown while the capture thread is live — the crash scenario
// Activation MUST have succeeded. This is the regression guard for the E_NOINTERFACE cast on
// IActivateAudioInterfaceAsyncOperation that silently killed every per-app capture: it was
// caught and reported via RecordingStopped, so "no crash" alone passed green while the feature
// was completely dead. A clean process-loopback teardown carries no exception (a silent process
// still activates fine — it just yields silence). Anything surfaced here is a real activation
// failure, so fail the gate on it.
if (stopError is not null)
return $"process-loopback activation failed: {stopError.GetType().Name}: {stopError.Message}";
cycles++;
}
return $"ran {cycles} start/stop/dispose cycles on pid {pid} with no crash";
return $"ran {cycles} start/stop/dispose cycles on pid {pid}; activation clean, no crash";
}
/// <summary>Soak test for runtime lifecycle transitions — the class of bug that hard-crashed when Ed
@@ -1196,7 +1223,6 @@ internal static class SelfTest
SendAudioOn = true,
EnableAllPeerShaping = true,
WasapiSendMode = "applications",
SendAllApplications = false,
};
input.SelectedSendApplications.Add("vlc");
input.SelectedSendApplications.Add("firefox");
@@ -1212,7 +1238,8 @@ internal static class SelfTest
if (RemSound.Sender.ProcessLoopbackCapture.IsSupported)
{
Check(back.WasapiSendMode == "applications", $"send mode must round-trip (got {back.WasapiSendMode})");
Check(!back.SendAllApplications, "the send-all-applications toggle must round-trip");
// No send-all check: the main window has no "send all applications" concept any more
// (removed 2026-07-16) — Profile.SendAllApplications is service-only and untouched here.
Check(back.SelectedSendApplications.Contains("vlc") && back.SelectedSendApplications.Contains("firefox"),
"the selected applications must round-trip through the app list");
covered += ", send-mode, apps";
@@ -1566,9 +1593,58 @@ internal static class SelfTest
Check(loaded.Count == 2, $"remembered apps must dedupe case-insensitively (got {loaded.Count})");
Check(loaded.All(a => a == a.ToLowerInvariant()), "remembered app names must be stored lower-case");
// Cross-instance read: the list must be MACHINE-WIDE (AppConfig-backed). The old backing was
// the per-instance in-memory settings cache, so a second instance — or the next launch of the
// app — saw an empty list and every remembered application was silently forgotten on exit.
var second = new RemSoundSettingsStore("RemSound");
Check(second.LoadRememberedApplications().Count == 2,
"a separate store instance must see the same remembered applications (machine-wide persistence)");
store.SaveRememberedApplications(Array.Empty<string>());
Check(store.LoadRememberedApplications().Count == 0, "clearing must empty the remembered applications list");
return "global remembered applications: round-trip, case-insensitive dedupe, and clear";
return "global remembered applications: round-trip, case-insensitive dedupe, cross-instance, and clear";
}
finally { store.SaveRememberedApplications(original); }
}
/// <summary>Pins the two-list semantics Ed specified 2026-07-16 (no send-all option): a TICKED app
/// must live in the Active list — even when it isn't running, marked "(not running)", so it can
/// always be found and unticked — and must NOT appear in Remembered; an UNTICKED remembered app
/// stays in Remembered. Runs the REAL reconcile against a headless main window, seeding the global
/// remembered store with fake names (restored afterwards) so nothing on the machine can interfere.</summary>
private static string? SendAppListSemantics()
{
if (!RemSound.Sender.ProcessLoopbackCapture.IsSupported)
return Skip("process loopback needs Windows 10 build 19041+");
const string ticked = "zzremsound_selftest_ticked"; // never a real process name
const string unticked = "zzremsound_selftest_unticked";
var store = new RemSoundSettingsStore("RemSound");
var original = store.LoadRememberedApplications().ToList();
try
{
store.SaveRememberedApplications(original.Concat(new[] { ticked, unticked }).ToList());
MainForm mf;
try { mf = new MainForm(null, RemSound.Core.Profile.NewBlank(), null, null, headless: true); }
catch (Exception ex) { return Skip($"headless MainForm could not be constructed: {ex.GetType().Name}: {ex.Message}"); }
using (mf)
{
var p = new Profile { Title = "app list semantics", WasapiSendMode = "applications" };
p.SelectedSendApplications.Add(ticked);
mf.ApplyThenCaptureForTest(p);
var (activeRows, activeChecked, rememberedRows) = mf.SnapshotAppListsForTest();
Check(activeRows.Contains(ticked, StringComparer.OrdinalIgnoreCase),
"a ticked app that isn't running must still appear in the Active list (so it can be unticked)");
Check(activeChecked.Contains(ticked, StringComparer.OrdinalIgnoreCase),
"the ticked app must actually be ticked in the Active list");
Check(!rememberedRows.Contains(ticked, StringComparer.OrdinalIgnoreCase),
"a ticked app must NOT appear in the Remembered list");
Check(rememberedRows.Contains(unticked, StringComparer.OrdinalIgnoreCase),
"an unticked remembered app must stay in the Remembered list");
}
return "ticked app: in Active (not running) + out of Remembered; unticked app: stays in Remembered";
}
finally { store.SaveRememberedApplications(original); }
}