diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 6deb721..145f581 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -66,6 +66,7 @@ internal static class SelfTest RunStep(results, "Service sender parity (crypto + Opus frame)", ServiceSenderParity); RunStep(results, "Service profile isolation (location + hidden from pickers)", ServiceProfileIsolation); RunStep(results, "Service send host (headless stream + yield)", ServiceSendHostStream); + RunStep(results, "Service network presence (reachable + shell teardown)", ServiceNetworkPresenceReachable); 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); @@ -857,12 +858,14 @@ internal static class SelfTest Check(host.ApplyProfile(profile), "ApplyProfile should start streaming"); Check(host.IsSending, "host should report sending after ApplyProfile"); + Check(host.IsNetworkPresenceUpForTest, "the network presence must come up with streaming (discoverable + reachable)"); Thread.Sleep(500); var afterStart = receiver.PacketsReceived; Check(afterStart > 0, $"packets must flow from the service host (got {afterStart})"); host.Suspend(); Check(!host.IsSending, "host should report not sending after Suspend"); + Check(!host.IsNetworkPresenceUpForTest, "the network presence must drop to a shell on Suspend (nothing left on the network for the app to fight)"); Thread.Sleep(200); var atSuspend = receiver.PacketsReceived; Thread.Sleep(400); @@ -1402,6 +1405,45 @@ internal static class SelfTest } } + /// The service's network presence (what makes it discoverable + connectable, not just a blind + /// push): Start binds the well-known-port listener and comes up; Stop tears it ALL the way down to a + /// shell (listener unbound) so the interactive app can own the network; and it's re-startable (the + /// service resuming after the app closes). Uses a free port so it never fights a real RemSound. + private static string? ServiceNetworkPresenceReachable() + { + var sender = new RemSound.Sender.AudioSender(); + var presence = new ServiceNetworkPresence(sender, null); + try + { + var peers = new List { new(IPAddress.Loopback, RemPacket.DefaultPeerDialPort) }; + + presence.Start(FreeUdpPort(), peers); + Check(presence.IsUp, "presence must report up after Start"); + Check(presence.ListenerBound, "the well-known-port listener must be bound so a peer can reach the service"); + + presence.Stop(); + Check(!presence.IsUp, "presence must report down after Stop"); + Check(!presence.ListenerBound, "Stop must unbind the listener — no footprint left for the interactive app to fight over"); + + presence.Start(FreeUdpPort(), peers); + Check(presence.IsUp && presence.ListenerBound, "presence must come back up after a stop/start cycle (resume after the app closes)"); + return "presence binds the listener on Start, tears fully down (shell) on Stop, and is re-startable"; + } + finally + { + try { presence.Dispose(); } catch { /* ignore */ } + try { sender.Dispose(); } catch { /* ignore */ } + } + } + + private static int FreeUdpPort() + { + using var s = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.InterNetwork, + System.Net.Sockets.SocketType.Dgram, System.Net.Sockets.ProtocolType.Udp); + s.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + return ((IPEndPoint)s.LocalEndPoint!).Port; + } + private static int CountControls(Control root, Func predicate) { var n = 0; diff --git a/src/RemSound.App/ServiceNetworkPresence.cs b/src/RemSound.App/ServiceNetworkPresence.cs new file mode 100644 index 0000000..62475a8 --- /dev/null +++ b/src/RemSound.App/ServiceNetworkPresence.cs @@ -0,0 +1,127 @@ +using System.Net; +using RemSound.Core; +using RemSound.Receiver; +using RemSound.Sender; + +namespace RemSound.App; + +/// +/// Gives the send-only Windows service a REAL presence on the network, so a peer (e.g. a phone) can +/// discover it and connect to it — the thing the bare sender could never do. It reuses the exact same +/// components the interactive app uses, wired the same way, so its discovery, heartbeat, NAT-pinhole and +/// relay behaviour are identical to the app's (that's what makes it "one identity": both announce under +/// the machine name and pair through the relay the same way — see ). +/// +/// +/// Discoverable broadcasts (and unicasts to the profile's +/// peers, for across-the-internet) an announcement under the machine name, advertising send-only. +/// Reachable's listener is bound to the well-known audio port so +/// peers can reach it; PLAYBACK stays OFF (send-only never plays received audio — the listener only carries +/// heartbeat/pairing). +/// Pairable pings the peers, which proves reachability, opens +/// the NAT pinhole and drives relay pairing; replies return either on the listener (LAN) or the sender's +/// socket (relay), both routed into the heartbeat service. +/// +/// +/// Crucially it can be fully torn down () to a SHELL: when the interactive app is +/// present the service must vacate the network entirely — stop announcing, unbind the port, stop the +/// heartbeat — so the two never both broadcast or fight over the port. A brief dropout on that handover is +/// acceptable (Ed's call): only one of the two holds the network at a time. +/// +internal sealed class ServiceNetworkPresence : IDisposable +{ + private readonly AudioSender sender; + private readonly Action? log; + private readonly PeerDiscoveryService discovery = new(); + private readonly AudioReceiver receiver = new(); + private HeartbeatService? heartbeat; + private bool running; + private bool disposed; + + public ServiceNetworkPresence(AudioSender sender, Action? log = null) + { + this.sender = sender; + this.log = log; + } + + public bool IsUp => running; + + /// Test seam: is the well-known-port listener actually bound? + internal bool ListenerBound => receiver.IsListenerRunning; + + /// Bring the service up as a discoverable, reachable, send-only peer on , + /// tracking (the profile's peers) for heartbeat/pairing and unicast + /// announcements. Idempotent: re-applies cleanly if already up. Never throws. + public void Start(int port, IReadOnlyList endpoints) + { + if (disposed) return; + if (running) Stop(); + + // Heartbeat pings go out through the sender's socket (sharing the audio NAT pinhole); replies come + // back on the listener (LAN) or the sender's socket (relay). Send-only: we route ONLY heartbeat + // packets — audio/format returns are ignored because we never play received audio. + heartbeat = new HeartbeatService(m => log?.Invoke($"heartbeat: {m}")); + heartbeat.SendTransport = sender.SendVia; + + receiver.OnHeartbeatReceived = (buf, len, remote) => heartbeat?.HandleInjectedPacket(buf, len, remote); + sender.OnInboundPacket = (buf, len, remote) => + { + if (len < RemPacket.HeaderSize) return; + if (RemPacket.TryReadHeader(buf.AsSpan(0, len), out var type, out _, out _) && type == RemPacketType.Heartbeat) + heartbeat?.HandleInjectedPacket(buf, len, remote); + // Send-only: Format / Audio / KeepAlive returns are deliberately dropped — the service never + // plays received audio, so there is no receive pipeline to feed. + }; + + // Make the sender's socket always-receiving so relay replies have somewhere to land. + try { sender.StartReceiving(); } catch (Exception ex) { log?.Invoke($"service: sender receive-side failed {ex.GetType().Name}: {ex.Message}"); } + + // Bind the listener (heartbeat/pairing only — playback stays OFF for send-only). + try + { + receiver.Start(port); + receiver.SetPlaybackEnabled(false); + } + catch (Exception ex) { log?.Invoke($"service: listener bind failed {ex.GetType().Name}: {ex.Message}"); } + + heartbeat.SetTrackedPeers(endpoints); + heartbeat.Start(); + + // Announce ourselves: LAN broadcast plus a unicast to each configured peer, so a peer across the + // internet (reached via the relay/Tailscale/port-forward) also learns we're here. Send-only. + try + { + discovery.SetUnicastPeerAddresses(endpoints.Select(e => e.Address)); + discovery.Start(port, sendEnabled: true, receiveEnabled: false); + } + catch (Exception ex) { log?.Invoke($"service: discovery failed {ex.GetType().Name}: {ex.Message}"); } + + running = true; + log?.Invoke($"service: network presence up on :{port} — discoverable + reachable to {endpoints.Count} peer(s), send-only"); + } + + /// Tear the presence all the way down to a SHELL: stop announcing, stop the heartbeat, unbind + /// the listener, unwire the sender's receive-side. Leaves NO network footprint on the well-known port — + /// this is what lets the interactive app take the network over cleanly. Never throws. + public void Stop() + { + if (!running) return; + running = false; + try { discovery.Stop(); } catch { } + try { heartbeat?.Stop(); heartbeat?.Dispose(); } catch { } + heartbeat = null; + try { receiver.OnHeartbeatReceived = null; } catch { } + try { receiver.Stop(); } catch { } + try { sender.OnInboundPacket = null; } catch { } + log?.Invoke("service: network presence down (shell — the network is free for the interactive app)"); + } + + public void Dispose() + { + if (disposed) return; + disposed = true; + Stop(); + try { discovery.Dispose(); } catch { } + try { receiver.Dispose(); } catch { } + } +} diff --git a/src/RemSound.App/ServiceSendHost.cs b/src/RemSound.App/ServiceSendHost.cs index 9acb9d0..9875ae7 100644 --- a/src/RemSound.App/ServiceSendHost.cs +++ b/src/RemSound.App/ServiceSendHost.cs @@ -26,6 +26,10 @@ public sealed class ServiceSendHost : IDisposable private readonly Func loadProfile; private readonly Action? log; private readonly AudioSender sender = new(); + // Network reachability: discovery + listener + heartbeat, so a peer can actually FIND and CONNECT to + // the service (the bare sender only ever pushed blindly to fixed addresses). Brought up alongside the + // sender while we're streaming, and fully torn down to a shell whenever we yield to the interactive app. + private readonly ServiceNetworkPresence presence; private readonly object gate = new(); private bool running; // the engine is actively sending private bool disposed; @@ -46,6 +50,7 @@ public sealed class ServiceSendHost : IDisposable { this.loadProfile = loadProfile; this.log = log; + presence = new ServiceNetworkPresence(sender, log); } /// Convenience factory for the real service: loads the profile from the machine-wide @@ -55,6 +60,10 @@ public sealed class ServiceSendHost : IDisposable public bool IsSending { get { lock (gate) return running; } } + /// Test seam: is the network presence (discovery + listener + heartbeat) currently up? Tracks + /// sending — up while streaming, torn down to a shell while yielded to the interactive app. + internal bool IsNetworkPresenceUpForTest => presence.IsUp; + /// Test seam: the crypto material the host pushed to the sender + the codec/frame it set, so /// a self-test can prove the service configures the sender exactly like the main app. internal (byte[]? Key, byte[]? Fingerprint, AudioTransportCodec Codec, int Frame) SenderConfigForTest => @@ -88,6 +97,9 @@ public sealed class ServiceSendHost : IDisposable sender.SetReceivers(endpoints); sender.Configure(specs); sender.Start(); + // Come up on the network too, so the peers can discover and connect to us — not just receive a + // blind push. Same well-known audio port and the same components the interactive app uses. + presence.Start(RemPacket.DefaultPort, endpoints); running = true; log?.Invoke($"service: streaming \"{profile.Title}\" — {specs.Count} source(s) to {endpoints.Count} peer(s)"); return true; @@ -100,6 +112,9 @@ public sealed class ServiceSendHost : IDisposable lock (gate) { if (!running) return; + // Vacate the network FIRST (stop announcing, unbind the port, stop the heartbeat) so the + // interactive app can take it over cleanly, then stop the audio send. + try { presence.Stop(); } catch (Exception ex) { log?.Invoke($"service: presence stop error {ex.GetType().Name}: {ex.Message}"); } try { sender.Stop(); } catch (Exception ex) { log?.Invoke($"service: stop error {ex.GetType().Name}: {ex.Message}"); } running = false; log?.Invoke("service: suspended (interactive app present)"); @@ -289,6 +304,7 @@ public sealed class ServiceSendHost : IDisposable } wantSending = false; try { deviceNotifier?.Dispose(); } catch { } deviceNotifier = null; + try { presence.Dispose(); } catch { } try { sender.Stop(); } catch { } try { sender.Dispose(); } catch { } }