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