using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Net; using System.Net.WebSockets; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Windows.Threading; using Microsoft.Extensions.Logging; using TeamsISO.App.ViewModels; using TeamsISO.Engine.Controller; using TeamsISO.Engine.Domain; namespace TeamsISO.App.Services; /// /// Localhost-only HTTP control surface. Lets external controllers (Bitfocus /// Companion, Stream Deck plugins, Bome MIDI Translator, custom node-RED flows, /// etc.) drive TeamsISO without needing to embed a UI binding. /// /// Bound to 127.0.0.1 by default — exposing this to LAN would require auth, and /// the typical operator workflow is "Stream Deck on the same machine as TeamsISO". /// If a future user needs LAN access, add a token check + bind to a configurable /// address; both are deliberately punted for v1. /// /// Endpoints (all return application/json): /// /// GET / — server info + endpoint list /// GET /participants — list of {id, displayName, isOnline, isEnabled} /// POST /participants/{id}/iso — body {"enabled":bool,"customName":string?} /// POST /participants/iso — body {"displayName":string,"enabled":bool} (look up by name) /// POST /presets/{name}/apply — apply a saved preset /// POST /presets/refresh-discovery — rebuild NDI finder /// POST /presets/stop-all — disable every running ISO /// POST /teams/mute — toggle mute via UIA /// POST /teams/camera — toggle camera via UIA /// POST /teams/leave — leave the call via UIA /// POST /teams/share — open share tray via UIA /// POST /teams/raise-hand — toggle raise hand via UIA /// POST /recording — body {"enabled":bool,"directory":string?} /// /// All POST bodies are optional — endpoints that take parameters accept them /// either via JSON body or via query string (?enabled=true&customName=Host). /// This is friendly to Companion's "URL with query string" mode. /// // Endpoint handlers live in partial files under Services/ControlSurface/Endpoints/. // This file holds the host: listener lifecycle, accept loop, dispatch table, // response helpers, and the WebSocket push loop. public sealed partial class ControlSurfaceServer : IAsyncDisposable { public const int DefaultPort = 9755; private readonly IIsoController _controller; private readonly Func _viewModel; private readonly ILogger? _logger; private HttpListener? _listener; private CancellationTokenSource? _cts; private Task? _acceptTask; private DispatcherTimer? _pushTimer; private readonly ConcurrentDictionary _clients = new(); private string _lastPushedSnapshot = string.Empty; public bool IsRunning { get; private set; } public int Port { get; private set; } = DefaultPort; /// True when the listener is bound to all interfaces (LAN-reachable) rather than just 127.0.0.1. public bool BoundToLan { get; private set; } /// /// JSON serializer options shared across all responses. Camel-case property /// naming matches Companion's request shape and what most JS clients expect. /// private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; public ControlSurfaceServer( IIsoController controller, Func viewModel, ILogger? logger = null) { _controller = controller; _viewModel = viewModel; _logger = logger; } /// /// Start listening on the given port. Idempotent: if already running on the /// same (port, bindToLan) combination, no-op; otherwise stop + restart. /// /// TCP port to listen on. /// /// When true, binds to all interfaces (http://+:port/) so other /// machines on the LAN can reach the control surface — typical for /// "headless show machine + thin client controller" setups. When false /// (default), binds to 127.0.0.1 only. /// /// LAN binding requires either running TeamsISO as Administrator OR a /// one-time URL ACL reservation at the OS level: /// netsh http add urlacl url=http://+:9755/ user=Everyone /// If neither is in place the listener throws AccessDeniedException /// which we catch and surface as a logger warning. /// public void Start(int port, bool bindToLan = false) { if (IsRunning && Port == port && BoundToLan == bindToLan) return; Stop(); Port = port; BoundToLan = bindToLan; _listener = new HttpListener(); var prefix = bindToLan ? $"http://+:{port}/" : $"http://127.0.0.1:{port}/"; _listener.Prefixes.Add(prefix); try { _listener.Start(); } catch (HttpListenerException ex) { _logger?.LogWarning(ex, "Could not start control surface on {Prefix}. " + "If binding to LAN, run as Administrator once OR run: " + "netsh http add urlacl url=http://+:{Port}/ user=Everyone", prefix, port); _listener = null; return; } _cts = new CancellationTokenSource(); _acceptTask = Task.Run(() => AcceptLoopAsync(_cts.Token)); // Drive the WebSocket push loop on the UI dispatcher so we can read the // ObservableCollection-backed Participants list without thread races. 4Hz // is fast enough that operators see immediate feedback when they flip an // ISO on the Stream Deck without us spamming the wire when nothing's // changing — the snapshot serializer dedupes against the previous push. var dispatcher = System.Windows.Application.Current?.Dispatcher; if (dispatcher is not null) { _pushTimer = new DispatcherTimer(DispatcherPriority.Background, dispatcher) { Interval = TimeSpan.FromMilliseconds(250), }; _pushTimer.Tick += async (_, _) => await PushSnapshotIfChangedAsync(); _pushTimer.Start(); } IsRunning = true; _logger?.LogInformation("Control surface listening on {Prefix} (REST + ws)", prefix); } public void Stop() { if (!IsRunning) return; try { _pushTimer?.Stop(); } catch { /* ignore */ } _pushTimer = null; // Close + drop every connected WebSocket; clients will reconnect when the // operator re-enables the surface. foreach (var (id, ws) in _clients.ToArray()) { try { ws.Abort(); } catch { /* ignore */ } try { ws.Dispose(); } catch { /* ignore */ } _clients.TryRemove(id, out _); } try { _cts?.Cancel(); } catch { /* ignore */ } try { _listener?.Stop(); } catch { /* ignore */ } try { _listener?.Close(); } catch { /* ignore */ } try { _acceptTask?.Wait(TimeSpan.FromSeconds(2)); } catch { /* ignore */ } _listener = null; _cts?.Dispose(); _cts = null; _acceptTask = null; IsRunning = false; } public async ValueTask DisposeAsync() { Stop(); await Task.CompletedTask; } private async Task AcceptLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested && _listener is not null && _listener.IsListening) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch (HttpListenerException) { break; } // listener stopped catch (ObjectDisposedException) { break; } catch (InvalidOperationException) { break; } // Each request gets its own task so a slow handler doesn't head-of-line block // others. Handlers are short (no I/O beyond the controller call) so this is // fine without explicit concurrency limits. _ = Task.Run(() => HandleRequestAsync(ctx)); } } private async Task HandleRequestAsync(HttpListenerContext ctx) { var req = ctx.Request; var res = ctx.Response; // Tracks whether we should call res.Close() in the finally. WebSocket // upgrades transfer ownership of the connection to the WebSocket // instance — closing the response here would tear down the freshly- // upgraded socket immediately. So we skip the finally close on that // path. var closeResponseInFinally = true; try { res.Headers["Access-Control-Allow-Origin"] = "*"; if (req.HttpMethod == "OPTIONS") { res.Headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"; res.Headers["Access-Control-Allow-Headers"] = "Content-Type"; res.StatusCode = 204; return; } var path = req.Url?.AbsolutePath?.TrimEnd('/') ?? ""; // WebSocket upgrade: live state push for controllers that don't want // to poll. Returns immediately after upgrading; HandleWebSocketAsync // owns the connection until the client disconnects. if (req.IsWebSocketRequest && path == "/ws") { var wsContext = await ctx.AcceptWebSocketAsync(subProtocol: null); closeResponseInFinally = false; _ = Task.Run(() => HandleWebSocketAsync(wsContext.WebSocket)); return; } var body = await ReadBodyAsync(req); // GET /ui — embedded HTML control panel. Served as text/html // rather than JSON so a browser renders it directly. if (req.HttpMethod == "GET" && path == "/ui") { res.ContentType = "text/html; charset=utf-8"; var html = ControlPanelHtml.Get(); var bytes = System.Text.Encoding.UTF8.GetBytes(html); res.ContentLength64 = bytes.Length; await res.OutputStream.WriteAsync(bytes); return; } // GET /participants/{id}/thumbnail.bmp — small BMP of the latest // processed frame. Returns 404 when no pipeline is running for // this participant. The HTML control panel uses this URL with // a cache-busting query param every ~1s to drive live preview // tiles. BMP (not JPEG) because WPF imaging types NRE from // non-UI threads and BMP encodes in plain managed code; the // 40KB payload at 192-wide compresses fine over LAN gzip. // Old /thumbnail.jpg URL accepted for backward compat. if (req.HttpMethod == "GET" && path.StartsWith("/participants/", StringComparison.Ordinal) && (path.EndsWith("/thumbnail.bmp", StringComparison.Ordinal) || path.EndsWith("/thumbnail.jpg", StringComparison.Ordinal))) { var ext = path.EndsWith("/thumbnail.bmp", StringComparison.Ordinal) ? "/thumbnail.bmp" : "/thumbnail.jpg"; var idSegment = path.AsSpan("/participants/".Length, path.Length - "/participants/".Length - ext.Length).ToString(); if (!Guid.TryParse(idSegment, out var thumbId)) { res.StatusCode = 400; await WriteJsonAsync(res, new { error = "invalid id" }); return; } var bmp = TryEncodeThumbnailJpeg(thumbId); if (bmp is null) { res.StatusCode = 404; await WriteJsonAsync(res, new { error = "no frame", id = thumbId }); return; } res.ContentType = "image/bmp"; res.AddHeader("Cache-Control", "no-store, must-revalidate"); res.ContentLength64 = bmp.Length; await res.OutputStream.WriteAsync(bmp); return; } object? response = (req.HttpMethod, path) switch { ("GET", "" or "/") => GetServerInfo(), ("GET", "/participants") => GetParticipants(), ("POST", "/presets/refresh-discovery") => RefreshDiscovery(), ("POST", "/presets/stop-all") => await StopAllAsync(), ("POST", "/teams/mute") => InvokeTeams(TeamsControlBridge.ToggleMute, "mute"), ("POST", "/teams/camera") => InvokeTeams(TeamsControlBridge.ToggleCamera, "camera"), ("POST", "/teams/leave") => InvokeTeams(TeamsControlBridge.LeaveCall, "leave"), ("POST", "/teams/share") => InvokeTeams(TeamsControlBridge.OpenShareTray, "share"), ("POST", "/teams/raise-hand") => InvokeTeams(TeamsControlBridge.ToggleRaiseHand, "raise-hand"), // /recording routes removed alongside the rest of the recording surface. // Topology — read the machine NDI config to report whether raw // Teams NDI sources are hidden from the LAN, and let the // operator apply / restore without leaving the web UI. ("GET", "/topology") => GetTopology(), ("POST", "/topology/apply") => await ApplyTopologyAsync(), ("POST", "/topology/restore") => await RestoreTopologyAsync(), _ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal) && path.EndsWith("/override", StringComparison.Ordinal) => await SetIsoOverrideByIdAsync(path, body), _ when req.HttpMethod == "DELETE" && path.StartsWith("/participants/", StringComparison.Ordinal) && path.EndsWith("/override", StringComparison.Ordinal) => await ClearIsoOverrideByIdAsync(path), ("POST", "/notes") => AppendNote(body, req.QueryString), ("POST", "/participants/iso") => await ToggleIsoByNameAsync(body, req.QueryString), _ when req.HttpMethod == "POST" && path.StartsWith("/participants/", StringComparison.Ordinal) => await ToggleIsoByIdAsync(path, body, req.QueryString), _ when req.HttpMethod == "POST" && path.StartsWith("/presets/", StringComparison.Ordinal) && path.EndsWith("/apply", StringComparison.Ordinal) => await ApplyPresetAsync(path), _ => NotFound(), }; if (response is null) { res.StatusCode = 404; await WriteJsonAsync(res, new { error = "not found" }); return; } await WriteJsonAsync(res, response); } catch (Exception ex) { _logger?.LogWarning(ex, "Control surface request failed: {Path}", req.Url?.AbsolutePath); try { res.StatusCode = 500; await WriteJsonAsync(res, new { error = ex.Message }); } catch { /* defensive */ } } finally { if (closeResponseInFinally) { try { res.Close(); } catch { /* defensive */ } } } } // ─── handlers ─────────────────────────────────────────────────────── // // Endpoint bodies live in Services/ControlSurface/Endpoints/*.cs as // partials of this class. See HomeEndpoints, ParticipantsEndpoints, // PresetsEndpoints, TeamsEndpoints, TopologyEndpoints, NotesEndpoints, // and ThumbnailEndpoint. The WebSocket push surface is at // Services/ControlSurface/WebSocketHub.cs. [SuppressMessage("Performance", "CA1822", Justification = "Method group used in switch arm.")] private object NotFound() => new { error = "not found" }; // ─── helpers ──────────────────────────────────────────────────────── private static async Task ReadBodyAsync(HttpListenerRequest req) { if (req.HttpMethod != "POST" || req.ContentLength64 == 0) return default; using var sr = new StreamReader(req.InputStream, req.ContentEncoding ?? Encoding.UTF8); var raw = await sr.ReadToEndAsync(); if (string.IsNullOrWhiteSpace(raw)) return default; try { return JsonSerializer.Deserialize(raw); } catch { return default; } } private static async Task WriteJsonAsync(HttpListenerResponse res, object payload) { res.ContentType = "application/json; charset=utf-8"; var json = JsonSerializer.Serialize(payload, JsonOpts); var bytes = Encoding.UTF8.GetBytes(json); res.ContentLength64 = bytes.Length; await res.OutputStream.WriteAsync(bytes); } private static bool? TryGetBool(JsonElement body, System.Collections.Specialized.NameValueCollection query, string key) { if (body.ValueKind == JsonValueKind.Object && body.TryGetProperty(key, out var v) && v.ValueKind is JsonValueKind.True or JsonValueKind.False) return v.GetBoolean(); var q = query[key]; if (q is null) return null; return q.Equals("true", StringComparison.OrdinalIgnoreCase) || q == "1"; } private static string? TryGetString(JsonElement body, System.Collections.Specialized.NameValueCollection query, string key) { if (body.ValueKind == JsonValueKind.Object && body.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String) return v.GetString(); return query[key]; } }