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