Make the service a reachable network peer (discoverable + connectable)

The send-only service could never be found or connected to — it only pushed
audio blindly to fixed peer addresses, with no beacon and nothing listening.
So a phone could neither discover it nor dial it. This gives the service a real
network presence built from the SAME components the interactive app uses, wired
the same way, so its discovery / heartbeat / NAT-pinhole / relay behaviour is
identical to the app's — which is what makes it "one identity" (both announce
under the machine name and pair through the relay the same way). Works LAN and,
inheriting the app's relay path, across the internet.

New ServiceNetworkPresence (reuses PeerDiscoveryService + AudioReceiver listener
+ HeartbeatService, wired to the host's AudioSender):
- Discoverable: announces send-only under the machine name (LAN broadcast +
  unicast to the configured peers for across-the-internet).
- Reachable: binds the well-known audio-port listener; PLAYBACK stays OFF
  (send-only never plays received audio — the listener only carries
  heartbeat/pairing).
- Pairable: heartbeat pings the peers (opens the NAT pinhole, drives relay
  pairing); replies route back on the listener (LAN) or the sender socket
  (relay).

Integrated into ServiceSendHost: comes up alongside the sender while streaming,
and — critically — tears ALL the way down to a shell on Suspend (stop
announcing, unbind the port, stop the heartbeat) so the service and the
interactive app never both hold the network. A brief dropout on that handover
is accepted (Ed's call); only one owns the network at a time.

Tests: "Service network presence" (Start binds the listener + comes up; Stop
unbinds to a shell; re-startable). "Service send host" now also asserts the
presence comes up with streaming and drops to a shell on Suspend. Gate 33/33.

NOTE: the live discover/connect/relay path can only be proven by the tester's
phone — the headless tests prove the lifecycle and teardown, not the internet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-14 19:52:38 +01:00
co-authored by Claude Opus 4.8
parent 91f5fc74c0
commit 541f66d379
3 changed files with 185 additions and 0 deletions
+42
View File
@@ -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
}
}
/// <summary>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.</summary>
private static string? ServiceNetworkPresenceReachable()
{
var sender = new RemSound.Sender.AudioSender();
var presence = new ServiceNetworkPresence(sender, null);
try
{
var peers = new List<IPEndPoint> { 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<Control, bool> predicate)
{
var n = 0;
+127
View File
@@ -0,0 +1,127 @@
using System.Net;
using RemSound.Core;
using RemSound.Receiver;
using RemSound.Sender;
namespace RemSound.App;
/// <summary>
/// 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 <see cref="PeerDiscoveryService"/>).
///
/// <list type="bullet">
/// <item><b>Discoverable</b> — <see cref="PeerDiscoveryService"/> broadcasts (and unicasts to the profile's
/// peers, for across-the-internet) an announcement under the machine name, advertising send-only.</item>
/// <item><b>Reachable</b> — <see cref="AudioReceiver"/>'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).</item>
/// <item><b>Pairable</b> — <see cref="HeartbeatService"/> 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.</item>
/// </list>
///
/// <para>Crucially it can be fully torn down (<see cref="Stop"/>) 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.</para>
/// </summary>
internal sealed class ServiceNetworkPresence : IDisposable
{
private readonly AudioSender sender;
private readonly Action<string>? 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<string>? log = null)
{
this.sender = sender;
this.log = log;
}
public bool IsUp => running;
/// <summary>Test seam: is the well-known-port listener actually bound?</summary>
internal bool ListenerBound => receiver.IsListenerRunning;
/// <summary>Bring the service up as a discoverable, reachable, send-only peer on <paramref name="port"/>,
/// tracking <paramref name="endpoints"/> (the profile's peers) for heartbeat/pairing and unicast
/// announcements. Idempotent: re-applies cleanly if already up. Never throws.</summary>
public void Start(int port, IReadOnlyList<IPEndPoint> 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");
}
/// <summary>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.</summary>
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 { }
}
}
+16
View File
@@ -26,6 +26,10 @@ public sealed class ServiceSendHost : IDisposable
private readonly Func<Profile?> loadProfile;
private readonly Action<string>? 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);
}
/// <summary>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; } }
/// <summary>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.</summary>
internal bool IsNetworkPresenceUpForTest => presence.IsUp;
/// <summary>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.</summary>
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 { }
}