using System.Net; using System.Net.Sockets; using NAudio.CoreAudioApi; using RemSound.Core; using RemSound.Sender; namespace RemSound.App; /// /// The headless engine behind the RemSound Windows service: it loads the designated send-only profile /// and streams it to the profile's peers, with no window, tray, hotkeys or screen reader. It YIELDS to /// the interactive app — while a normal RemSound is open it suspends (stops capturing, drops the send), /// resuming when the app closes or crashes (see ). /// /// Send-only and WASAPI-only by design: ASIO can't run in a service, and receive is impossible on /// Windows 11 with no user logged in, so neither is attempted. v1 sends directly to the profile's /// configured peer addresses (LAN / port-forwarded / reachable hosts); NAT hole-punching and relay /// discovery are the interactive app's job, not the service's. /// /// Structured so the mechanism is unit-testable without a real service: , /// and are driven directly by the self-tests, and /// wires them to the interactive-presence token. /// public sealed class ServiceSendHost : IDisposable { private readonly Func loadProfile; private readonly Action? log; private readonly AudioSender sender = new(); private readonly object gate = new(); private bool running; // the engine is actively sending private bool disposed; /// Supplies the current service profile (re-read on each resume so edits /// are picked up). Returns null if none is configured. /// Optional diagnostic sink. public ServiceSendHost(Func loadProfile, Action? log = null) { this.loadProfile = loadProfile; this.log = log; } /// Convenience factory for the real service: loads the profile named by /// from the given profiles folder each time it's asked. public static ServiceSendHost FromConfig(Action? log = null) => new(() => { var cfg = AppConfig.Load(); if (string.IsNullOrWhiteSpace(cfg.ServiceProfileName) || string.IsNullOrWhiteSpace(cfg.ProfilesDirectory)) return null; try { return new ProfileStore(cfg.ProfilesDirectory).Load(cfg.ServiceProfileName!); } catch { return null; } }, log); public bool IsSending { get { lock (gate) return running; } } /// Builds the send sources, peer endpoints and encryption key from a profile and starts the /// sender. Idempotent-ish: call before re-applying a different profile. Returns /// false (and stays stopped) if the profile has nothing to send or no reachable peers. public bool ApplyProfile(Profile profile) { lock (gate) { if (disposed) return false; var specs = BuildSendSpecs(profile); var endpoints = BuildEndpoints(profile); if (specs.Count == 0) { log?.Invoke("service: profile has no WASAPI send sources — nothing to stream"); return false; } if (endpoints.Count == 0) { log?.Invoke("service: profile has no reachable peers — nothing to stream to"); return false; } sender.AudioKey = string.IsNullOrEmpty(profile.Password) ? null : RemSoundCrypto.DeriveKey(RemSoundCrypto.Deobfuscate(profile.Password)); sender.ConfigureCodec(profile.Codec, profile.OpusFrameSamplesPerChannel); sender.SetSendRate(profile.SendRate); sender.SetTightLatency(profile.TightLatencyMode); sender.SetReceivers(endpoints); sender.Configure(specs); sender.Start(); running = true; log?.Invoke($"service: streaming \"{profile.Title}\" — {specs.Count} source(s) to {endpoints.Count} peer(s)"); return true; } } /// Stops sending and releases capture. Safe to call when already stopped. public void Suspend() { lock (gate) { if (!running) return; 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)"); } } /// Re-reads the current service profile and starts sending. Used when the interactive app /// goes away, so any edits it made are picked up. public bool Resume() { var profile = loadProfile(); if (profile is null) { log?.Invoke("service: no service profile configured — staying idle"); return false; } return ApplyProfile(profile); } /// The service's main loop: watch the interactive-presence token and hand the send back and /// forth. Starts sending immediately if no app is present. A short settle delay before resuming /// stops rapid app open/close from thrashing the engine. Returns when is /// cancelled (service stop). public void RunLoop(CancellationToken ct, int pollMs = 1000, int resumeSettleMs = 2000) => RunLoopCore(ct, InteractivePresence.IsInteractiveAppRunning, pollMs, resumeSettleMs); /// Test seam: run the loop against a caller-supplied presence-token name so a test can't /// collide with a real running app on the production token. internal void RunLoopWithToken(CancellationToken ct, string tokenName, int pollMs, int resumeSettleMs) => RunLoopCore(ct, () => InteractivePresence.IsInteractiveAppRunning(tokenName), pollMs, resumeSettleMs); private void RunLoopCore(CancellationToken ct, Func isAppPresent, int pollMs, int resumeSettleMs) { var appWasPresent = true; // force an initial evaluation var absentSince = Environment.TickCount64; while (!ct.IsCancellationRequested) { var appPresent = isAppPresent(); if (appPresent) { if (IsSending) Suspend(); absentSince = long.MaxValue; } else { if (appWasPresent) absentSince = Environment.TickCount64; // app just left — start the settle timer if (!IsSending && Environment.TickCount64 - absentSince >= resumeSettleMs) Resume(); } appWasPresent = appPresent; ct.WaitHandle.WaitOne(pollMs); } Suspend(); } // WASAPI-only send specs from a profile. Mirrors the app's applications-vs-devices logic but never // touches ASIO (the service can't). Applications mode needs Windows 10 19041+. internal static List BuildSendSpecs(Profile p) { var specs = new List(); var appsMode = ProcessLoopbackCapture.IsSupported && string.Equals(p.WasapiSendMode, "applications", StringComparison.OrdinalIgnoreCase); if (appsMode) { if (p.SendAllApplications) { var def = ResolveDefaultRenderId(); if (def is not null) specs.Add(new CaptureSourceSpec(def, CaptureKind.Loopback, "All applications (system audio)")); } else { foreach (var name in p.SelectedSendApplications.Distinct(StringComparer.OrdinalIgnoreCase)) foreach (var pid in AudioAppEnumerator.PidsForProcessName(name)) specs.Add(new CaptureSourceSpec(ProcessLoopbackId.Format(pid), CaptureKind.ProcessLoopback, name)); } } else { foreach (var id in p.SelectedWasapiSendOutputs.Distinct()) specs.Add(new CaptureSourceSpec(id, CaptureKind.Loopback, id)); } foreach (var id in p.SelectedWasapiSendInputs.Distinct()) specs.Add(new CaptureSourceSpec(id, CaptureKind.Input, id)); return specs; } // Resolve the profile's configured peers to audio endpoints. v1: direct addresses only. internal static List BuildEndpoints(Profile p) { var entries = p.SelectedConnectedPeers.Count > 0 ? p.SelectedConnectedPeers : p.RememberedPeers; var result = new List(); var seen = new HashSet(); foreach (var entry in entries.Where(e => !string.IsNullOrWhiteSpace(e)).Distinct()) { var (host, port) = SplitHostPort(entry); IPAddress? addr; if (!IPAddress.TryParse(host, out addr)) { try { var found = Dns.GetHostAddresses(host); addr = found.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) ?? found.FirstOrDefault(); } catch { addr = null; } } if (addr is null) continue; var ep = new IPEndPoint(addr, port ?? p.AudioPort); if (seen.Add($"{ep.Address}:{ep.Port}")) result.Add(ep); } return result; } // Minimal "host[:port]" parser (self-contained so the host doesn't depend on the WinForms UI). internal static (string host, int? port) SplitHostPort(string text) { text = text.Trim(); var colon = text.LastIndexOf(':'); if (colon <= 0 || colon == text.Length - 1) return (text, null); var host = text[..colon]; if (host.Contains(':')) return (text, null); // looks like an IPv6 literal — treat whole as host return int.TryParse(text[(colon + 1)..], out var port) && port is >= 1 and <= 65535 ? (host, port) : (text, null); } private static string? ResolveDefaultRenderId() { try { using var en = new MMDeviceEnumerator(); if (!en.HasDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia)) return null; using var d = en.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); return d.ID; } catch { return null; } } public void Dispose() { lock (gate) { if (disposed) return; disposed = true; } try { sender.Stop(); } catch { } try { sender.Dispose(); } catch { } } }