Catch a per-app send from its very start (instant capture-on-open)

Fixes the bug where a ticked app that launched AFTER the profile loaded was
never captured: the 3s reconcile only redrew the list and only ran while you
were looking at it, so it never restarted the audio capture.

Now:
- AudioSessionStartWatcher (RemSound.Sender): hooks the Windows audio-session-
  created notification on the default render device, firing the instant an app
  opens an audio session (i.e. about to make its first sound). Re-points on a
  default-device change. Best-effort; falls back to the poll.
- RefreshSendAppCapture: re-applies the send sources whenever the ticked apps'
  running process ids change, driven by both the session watcher (instant) and
  the reconcile poll (backstop) — and the poll now runs regardless of which tab
  is showing, so a remembered app is caught even when you're not on the list.
- Profile.RememberedSendApplications added (persisted; the two-list UI that
  uses it lands next).
- Self-test 'Send-app capture change-detection': the pure signature is stable
  while nothing changes and flips when a ticked app opens/closes. Gate 36/36.

Live behaviour (real apps + the session notification) needs on-machine testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-15 19:54:27 +01:00
co-authored by Claude Opus 4.8
parent 5129aa2a39
commit 1e5030b08f
4 changed files with 197 additions and 3 deletions
+33
View File
@@ -68,6 +68,7 @@ internal static class SelfTest
RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream);
RunStep(results, "Service network presence (reachable + shell teardown)", ServiceNetworkPresenceReachable);
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, "Service registration args", ServiceRegistrationArgs);
RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine);
RunStep(results, "Recording split tracks (per-peer + own)", RecordingSplitTracks);
@@ -1518,6 +1519,38 @@ internal static class SelfTest
return "reachable armed; long-unreachable dropped; grace-window kept; recovery re-arms (issues #8/#15)";
}
/// <summary>The fix for "a saved app that launches later never gets captured": the send engine
/// re-applies capture whenever the ticked apps' running process ids change. This tests the pure
/// change-detector that drives it — the signature is stable while nothing changes (so we don't churn),
/// and changes the moment a ticked app opens or closes a process.</summary>
private static string? SendAppCaptureChangeDetection()
{
var pids = new Dictionary<string, IReadOnlyList<int>>(StringComparer.OrdinalIgnoreCase)
{
["vlc"] = new[] { 100 },
["firefox"] = Array.Empty<int>(), // remembered but not running yet
};
IReadOnlyList<int> Lookup(string n) => pids.TryGetValue(n, out var v) ? v : Array.Empty<int>();
var names = new[] { "vlc", "firefox" };
var s1 = MainForm.ComputeSendAppPidSignature(names, Lookup);
Check(MainForm.ComputeSendAppPidSignature(names, Lookup) == s1, "the signature must be stable while nothing changes (no needless re-apply)");
// firefox launches → a new process id appears → the signature must change (triggers capture).
pids["firefox"] = new[] { 200 };
var s2 = MainForm.ComputeSendAppPidSignature(names, Lookup);
Check(s2 != s1, "a ticked app opening must change the signature (so capture starts for it)");
// firefox closes again → back to the original signature.
pids["firefox"] = Array.Empty<int>();
Check(MainForm.ComputeSendAppPidSignature(names, Lookup) == s1, "the app closing must return the signature (so its capture is dropped)");
// A second instance of a ticked app (another PID) also changes it.
pids["vlc"] = new[] { 100, 101 };
Check(MainForm.ComputeSendAppPidSignature(names, Lookup) != s1, "a second process of a ticked app must change the signature too");
return "signature stable when unchanged; changes when a ticked app opens/closes (drives instant capture)";
}
private static int FreeUdpPort()
{
using var s = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.InterNetwork,