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. /// public sealed 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 ─────────────────────────────────────────────────────── private object GetServerInfo() { // Best-effort engine snapshot — wrapped in try/catch so a transient // controller error doesn't 500 the homepage poll. var settings = TryRead(() => _controller.GlobalSettings); var groups = TryRead(() => _controller.GroupSettings); return new { product = "TeamsISO", version = typeof(ControlSurfaceServer).Assembly.GetName().Version?.ToString() ?? "unknown", engine = new { framerateHz = settings?.FramerateHz, targetResolution = settings?.Resolution.ToString(), aspectMode = settings?.Aspect.ToString(), audioMode = settings?.Audio.ToString(), discoveryGroups = groups?.DiscoveryGroups, outputGroups = groups?.OutputGroups, }, // recording status fields removed alongside the rest of the recording surface. endpoints = new[] { "GET / (this)", "GET /ui (HTML control panel)", "GET /participants", "GET /ws (WebSocket: live participant snapshots)", "POST /participants/{id}/iso", "POST /participants/iso (body: displayName + enabled)", "POST /presets/{name}/apply", "POST /presets/refresh-discovery", "POST /presets/stop-all", "POST /teams/mute, /camera, /leave, /share, /raise-hand", "POST /notes (body: text)", }, }; } private static T? TryRead(Func reader) where T : class { try { return reader(); } catch { return null; } } private object GetParticipants() { var vm = _viewModel(); if (vm is null) return new { participants = Array.Empty() }; // Synchronously snapshot on the UI thread — ObservableCollection isn't safe // to enumerate from this request handler's thread-pool task, and the // ParticipantViewModel property reads chase data-binding state. var dispatcher = System.Windows.Application.Current?.Dispatcher; if (dispatcher is null) return new { participants = Array.Empty() }; var globals = _controller.GlobalSettings; var list = dispatcher.Invoke(() => vm.Participants.Select(p => { var ovr = _controller.GetIsoOverride(p.Id); return (object)new { id = p.Id, displayName = p.DisplayName, isOnline = p.IsOnline, isEnabled = p.IsEnabled, customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName, stateLabel = p.StateLabel, // Effective settings = override if set, else globals. The web // UI uses this to show the current per-row values without a // separate round-trip to /global. effective = new { framerate = (ovr ?? globals).Framerate.ToString(), resolution = (ovr ?? globals).Resolution.ToString(), aspect = (ovr ?? globals).Aspect.ToString(), audio = (ovr ?? globals).Audio.ToString(), isOverride = ovr is not null, }, }; }).ToArray()); return new { participants = list, globals = new { framerate = globals.Framerate.ToString(), resolution = globals.Resolution.ToString(), aspect = globals.Aspect.ToString(), audio = globals.Audio.ToString(), } }; } /// /// POST /participants/{id}/override — set or replace the per-pipeline /// override. Body fields: framerate (enum string), resolution (enum /// string), aspect (enum string), audio (enum string). All fields are /// optional; missing fields fall back to the current global value. /// private async Task SetIsoOverrideByIdAsync(string path, JsonElement body) { var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override") return new { ok = false, error = "expected /participants/{id}/override" }; if (!Guid.TryParse(segments[1], out var id)) return new { ok = false, error = "invalid id" }; var g = _controller.GlobalSettings; var framerate = TryParseEnum(body, "framerate", g.Framerate); var resolution = TryParseEnum(body, "resolution", g.Resolution); var aspect = TryParseEnum(body, "aspect", g.Aspect); var audio = TryParseEnum(body, "audio", g.Audio); var ovr = new FrameProcessingSettings(framerate, resolution, aspect, audio); await _controller.SetIsoOverrideAsync(id, ovr, CancellationToken.None); return new { ok = true, id, effective = new { framerate = ovr.Framerate.ToString(), resolution = ovr.Resolution.ToString(), aspect = ovr.Aspect.ToString(), audio = ovr.Audio.ToString(), isOverride = true, } }; } /// DELETE /participants/{id}/override — pipeline reverts to global settings. private async Task ClearIsoOverrideByIdAsync(string path) { var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "override") return new { ok = false, error = "expected /participants/{id}/override" }; if (!Guid.TryParse(segments[1], out var id)) return new { ok = false, error = "invalid id" }; await _controller.SetIsoOverrideAsync(id, null, CancellationToken.None); return new { ok = true, id, cleared = true }; } /// /// Parse an enum value from a JSON body, falling back to a default when /// the field is missing or the value doesn't match any enum member. /// Case-insensitive. Used by SetIsoOverrideByIdAsync for the four /// FrameProcessingSettings enums. /// private static TEnum TryParseEnum(JsonElement body, string field, TEnum fallback) where TEnum : struct, Enum { if (body.ValueKind != JsonValueKind.Object) return fallback; if (!body.TryGetProperty(field, out var prop)) return fallback; if (prop.ValueKind != JsonValueKind.String) return fallback; var s = prop.GetString(); if (string.IsNullOrEmpty(s)) return fallback; return Enum.TryParse(s, ignoreCase: true, out var result) ? result : fallback; } private object RefreshDiscovery() { _controller.RefreshDiscovery(); return new { ok = true, action = "refresh-discovery" }; } private async Task StopAllAsync() { var vm = _viewModel(); if (vm is null) return new { ok = false, error = "view-model not ready" }; var dispatcher = System.Windows.Application.Current?.Dispatcher; if (dispatcher is null) return new { ok = false, error = "dispatcher not ready" }; // Snapshot the enabled set on the UI thread — ObservableCollection isn't // safe to enumerate from a thread-pool task, and reading the IsEnabled // property indirectly walks the data-binding system. var enabled = await dispatcher.InvokeAsync(() => vm.Participants.Where(p => p.IsEnabled).ToArray()); foreach (var p in enabled) { try { await _controller.DisableIsoAsync(p.Id, CancellationToken.None); } catch { /* defensive */ } await dispatcher.InvokeAsync(() => p.IsEnabled = false); } return new { ok = true, action = "stop-all", count = enabled.Length }; } private object InvokeTeams(Func invoke, string action) { var result = invoke(); return new { ok = result == TeamsControlBridge.InvokeResult.Invoked, action, result = result.ToString(), }; } // SetRecording and DropMarker methods removed alongside the rest of the recording surface. /// /// Report the current NDI machine topology. "mode" is "hidden" when local /// senders are confined to the private group (raw Teams sources invisible /// to the rest of the LAN), "public" otherwise. Reads the machine NDI /// config file directly — no caching, so the result reflects whatever /// state the file is in right now (including manual edits). /// private object GetTopology() { try { var (mode, sends, recvs) = NdiAccessManagerConfig.ReadCurrent(); return new { mode, senders = sends, receivers = recvs, configPath = NdiAccessManagerConfig.ConfigPath, }; } catch (Exception ex) { return new { ok = false, error = ex.Message }; } } /// /// Apply the transcoder topology: machine senders → teamsiso-input, /// receivers → public + teamsiso-input; engine groups updated to /// match (discover from teamsiso-input, broadcast on public). Operator /// MUST restart Teams afterward for it to read the new NDI config. /// private async Task ApplyTopologyAsync() { var result = NdiAccessManagerConfig.ApplyTranscoderTopology(); if (!result.Success) { return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath }; } // Mirror what the WPF settings VM does so the engine groups + machine // config stay in lockstep. var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings( DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup, OutputGroups: "public"); await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None); return new { ok = true, mode = "hidden", backupPath = result.BackupPath, note = "Restart Microsoft Teams for the new NDI config to take effect there.", }; } /// /// Restore the machine NDI defaults: senders + receivers both on /// public. Engine groups go back to null/defaults too. Operator /// must restart Teams for it to broadcast on public again. /// private async Task RestoreTopologyAsync() { var result = NdiAccessManagerConfig.RestoreDefaults(); if (!result.Success) { return new { ok = false, error = result.ErrorMessage, configPath = result.ConfigPath }; } var ourGroups = new TeamsISO.Engine.Domain.NdiGroupSettings( DiscoveryGroups: null, OutputGroups: null); await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None); return new { ok = true, mode = "public", backupPath = result.BackupPath, note = "Restart Microsoft Teams for the new NDI config to take effect there.", }; } /// /// Encode the engine's most recent processed frame for the given /// participant as a JPEG. Returns null when no pipeline is running for /// this participant or the frame can't be encoded for any reason. /// private byte[]? TryEncodeThumbnailJpeg(Guid participantId) { // Encode as a raw 32-bpp BMP. BMP is trivial to write byte-by-byte // and every browser decodes it. JPEG would be smaller, but the // System.Windows.Media.Imaging path NREs on non-UI threads and // marshaling 1Hz JPEG encodes through the WPF dispatcher hurts // responsiveness. ~40KB per 192-wide BMP is fine over LAN. try { var frame = _controller.GetLatestProcessedFrame(participantId); if (frame is null) { _logger?.LogDebug("Thumbnail: no frame for {Id}", participantId); return null; } if (frame.Pixels.Length == 0) { _logger?.LogDebug("Thumbnail: empty pixel buffer for {Id} ({W}x{H})", participantId, frame.Width, frame.Height); return null; } // Nearest-neighbor downscale to ~192 wide. Source is BGRA32. const int targetWidth = 192; var ratio = (double)frame.Height / frame.Width; var targetHeight = Math.Max(1, (int)(targetWidth * ratio)); return EncodeBmpDownscaled(frame.Pixels.Span, frame.Width, frame.Height, targetWidth, targetHeight); } catch (Exception ex) { _logger?.LogWarning(ex, "Thumbnail encode failed for {Id}", participantId); return null; } } /// /// Nearest-neighbor downscale of a BGRA32 source then write a 32-bpp /// top-down BMP. Returns the full BMP file bytes including the 14-byte /// BMP header and 40-byte BITMAPINFOHEADER. Browsers handle it directly /// (no JPEG / PNG codec needed in-process). /// private static byte[] EncodeBmpDownscaled(ReadOnlySpan srcBgra, int srcW, int srcH, int dstW, int dstH) { var pixelBytes = dstW * dstH * 4; var bmp = new byte[54 + pixelBytes]; // BMP file header (14 bytes): 'BM', file size, reserved, pixel offset. bmp[0] = (byte)'B'; bmp[1] = (byte)'M'; WriteUInt32LE(bmp, 2, (uint)bmp.Length); WriteUInt32LE(bmp, 6, 0); WriteUInt32LE(bmp, 10, 54); // DIB header (BITMAPINFOHEADER, 40 bytes): negative height = top-down. WriteUInt32LE(bmp, 14, 40); WriteInt32LE(bmp, 18, dstW); WriteInt32LE(bmp, 22, -dstH); WriteUInt16LE(bmp, 26, 1); WriteUInt16LE(bmp, 28, 32); WriteUInt32LE(bmp, 30, 0); WriteUInt32LE(bmp, 34, (uint)pixelBytes); WriteUInt32LE(bmp, 38, 2835); WriteUInt32LE(bmp, 42, 2835); WriteUInt32LE(bmp, 46, 0); WriteUInt32LE(bmp, 50, 0); // Nearest-neighbor downscale, top-down (matches negative-height header). var srcStride = srcW * 4; var dstOffset = 54; for (var dy = 0; dy < dstH; dy++) { var sy = (int)((long)dy * srcH / dstH); for (var dx = 0; dx < dstW; dx++) { var sx = (int)((long)dx * srcW / dstW); var si = sy * srcStride + sx * 4; bmp[dstOffset++] = srcBgra[si]; bmp[dstOffset++] = srcBgra[si + 1]; bmp[dstOffset++] = srcBgra[si + 2]; bmp[dstOffset++] = srcBgra[si + 3]; } } return bmp; } private static void WriteUInt16LE(byte[] buf, int offset, ushort value) { buf[offset] = (byte)(value & 0xFF); buf[offset + 1] = (byte)((value >> 8) & 0xFF); } private static void WriteInt32LE(byte[] buf, int offset, int value) => WriteUInt32LE(buf, offset, (uint)value); private static void WriteUInt32LE(byte[] buf, int offset, uint value) { buf[offset] = (byte)(value & 0xFF); buf[offset + 1] = (byte)((value >> 8) & 0xFF); buf[offset + 2] = (byte)((value >> 16) & 0xFF); buf[offset + 3] = (byte)((value >> 24) & 0xFF); } // Legacy WPF-imaging path kept dead-coded for posterity. The BMP path // above is what's wired through the endpoint. If we ever want JPEG // again, marshal this to the dispatcher and call from there. private byte[]? TryEncodeThumbnailJpeg_WpfDeadCode(Guid participantId) { try { var frame = _controller.GetLatestProcessedFrame(participantId); if (frame is null) return null; // 192-wide thumbnail at the source aspect. BGRA32 input. const int targetWidth = 192; var ratio = (double)frame.Height / frame.Width; var targetHeight = Math.Max(1, (int)(targetWidth * ratio)); // WPF imaging is NOT free-threaded by default: BitmapSource and // friends own DispatcherObject affinity until Freeze() drops it. // The control surface handler runs on an HttpListener thread (NOT // the UI dispatcher), so every intermediate bitmap MUST be frozen // before the next call touches it — otherwise we get a NRE deep // in MIL when JpegBitmapEncoder.Save tries to walk the frame // chain across thread boundaries. var stride = frame.Width * 4; var source = System.Windows.Media.Imaging.BitmapSource.Create( frame.Width, frame.Height, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null, frame.Pixels.ToArray(), stride); if (source.CanFreeze) source.Freeze(); var transform = new System.Windows.Media.ScaleTransform( (double)targetWidth / frame.Width, (double)targetHeight / frame.Height); if (transform.CanFreeze) transform.Freeze(); var scaled = new System.Windows.Media.Imaging.TransformedBitmap(source, transform); if (scaled.CanFreeze) scaled.Freeze(); var bitmapFrame = System.Windows.Media.Imaging.BitmapFrame.Create(scaled); if (bitmapFrame.CanFreeze) bitmapFrame.Freeze(); using var ms = new System.IO.MemoryStream(); var encoder = new System.Windows.Media.Imaging.JpegBitmapEncoder { QualityLevel = 60 }; encoder.Frames.Add(bitmapFrame); encoder.Save(ms); return ms.ToArray(); } catch (Exception ex) { _logger?.LogDebug(ex, "Thumbnail encode failed for {Id}", participantId); return null; } } private object AppendNote(JsonElement body, System.Collections.Specialized.NameValueCollection query) { var text = TryGetString(body, query, "text"); if (string.IsNullOrWhiteSpace(text)) return new { ok = false, error = "text required" }; var ok = NotesService.Append(text); return new { ok, action = "note", path = NotesService.TodayPath }; } // RollRecordingAsync handler removed alongside the rest of the recording surface. private async Task ToggleIsoByIdAsync(string path, JsonElement body, System.Collections.Specialized.NameValueCollection query) { // path = /participants//iso var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); if (segments.Length != 3 || segments[0] != "participants" || segments[2] != "iso") return NotFound(); if (!Guid.TryParse(segments[1], out var id)) return new { ok = false, error = "invalid id" }; return await ToggleByIdAsync(id, body, query); } private async Task ToggleIsoByNameAsync(JsonElement body, System.Collections.Specialized.NameValueCollection query) { var displayName = TryGetString(body, query, "displayName"); if (string.IsNullOrWhiteSpace(displayName)) return new { ok = false, error = "displayName required" }; var vm = _viewModel(); var dispatcher = System.Windows.Application.Current?.Dispatcher; if (vm is null || dispatcher is null) return new { ok = false, error = "view-model not ready" }; var p = await dispatcher.InvokeAsync(() => vm.Participants.FirstOrDefault(x => string.Equals(x.DisplayName, displayName, StringComparison.OrdinalIgnoreCase))); if (p is null) return new { ok = false, error = "participant not found", displayName }; return await ToggleByIdAsync(p.Id, body, query); } private async Task ToggleByIdAsync(Guid id, JsonElement body, System.Collections.Specialized.NameValueCollection query) { var enabled = TryGetBool(body, query, "enabled"); var customName = TryGetString(body, query, "customName"); var vm = _viewModel(); var dispatcher = System.Windows.Application.Current?.Dispatcher; if (vm is null || dispatcher is null) return new { ok = false, error = "view-model not ready" }; // Look up the VM and snapshot its current state on the UI thread — // ObservableCollection enumeration and view-model property reads both // need to happen there. var lookup = await dispatcher.InvokeAsync(() => { var p = vm.Participants.FirstOrDefault(x => x.Id == id); return p is null ? null : new { Pvm = p, p.IsEnabled, p.CustomName }; }); if (lookup is null) return new { ok = false, error = "participant not found", id }; var target = enabled ?? !lookup.IsEnabled; var nameToUse = !string.IsNullOrEmpty(customName) ? customName : lookup.CustomName; if (target == lookup.IsEnabled && string.IsNullOrEmpty(customName)) return new { ok = true, id, enabled = lookup.IsEnabled, action = "noop" }; // Apply CustomName change first (if any) on the UI thread so a subsequent // EnableIsoAsync sees the new name. if (!string.IsNullOrEmpty(customName)) await dispatcher.InvokeAsync(() => lookup.Pvm.CustomName = customName); if (target) { await _controller.EnableIsoAsync(id, string.IsNullOrWhiteSpace(nameToUse) ? null : nameToUse, CancellationToken.None); await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = true); } else { await _controller.DisableIsoAsync(id, CancellationToken.None); await dispatcher.InvokeAsync(() => lookup.Pvm.IsEnabled = false); } return new { ok = true, id, enabled = target }; } private async Task ApplyPresetAsync(string path) { // path = /presets//apply var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); if (segments.Length != 3 || segments[0] != "presets" || segments[2] != "apply") return NotFound(); var name = Uri.UnescapeDataString(segments[1]); var preset = OperatorPresetStore.Find(name); if (preset is null) return new { ok = false, error = "preset not found", name }; var vm = _viewModel(); var dispatcher = System.Windows.Application.Current?.Dispatcher; if (vm is null || dispatcher is null) return new { ok = false, error = "view-model not ready" }; // Snapshot participants on the UI thread — ObservableCollection enumeration // and ParticipantViewModel state reads both need to happen there. // PresetApplier marshals subsequent property writes via the dispatcher. var snapshot = await dispatcher.InvokeAsync(() => vm.Participants.ToList()); var result = await PresetApplier.ApplyAsync( preset, snapshot, _controller, dispatcher); return new { ok = true, name = preset.Name, matched = result.Matched, changed = result.Changed, skipped = result.Skipped, }; } [SuppressMessage("Performance", "CA1822", Justification = "Method group used in switch arm.")] private object NotFound() => new { error = "not found" }; // ─── WebSocket push ───────────────────────────────────────────────── /// /// Owns a single client connection until it closes. Sends an immediate /// snapshot on connect (so the client doesn't have to wait up to 250ms /// for the next push tick), then sits in a receive loop draining any /// incoming text — we ignore client→server messages for v1 since all /// commands are REST. The receive loop is the canonical way to detect /// graceful close: when WebSocket.ReceiveAsync returns CloseReceived, /// we close back and remove the client. /// private async Task HandleWebSocketAsync(WebSocket ws) { var clientId = Guid.NewGuid(); _clients[clientId] = ws; _logger?.LogInformation("WebSocket client {Id} connected.", clientId); try { // Initial snapshot — fetch synchronously on the UI thread so the // ObservableCollection isn't enumerated cross-thread. await SendAsync(ws, await GetSnapshotJsonAsync()); var buf = new byte[1024]; while (ws.State == WebSocketState.Open) { var result = await ws.ReceiveAsync(new ArraySegment(buf), CancellationToken.None); if (result.MessageType == WebSocketMessageType.Close) { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); break; } // Ignore any client-sent messages for now; future bidirectional // commands could route through here. } } catch (WebSocketException) { /* client crashed; drop */ } catch (ObjectDisposedException) { /* Stop() aborted us */ } catch (OperationCanceledException) { /* server shutting down */ } finally { _clients.TryRemove(clientId, out _); // Don't double-dispose: Stop() already disposed the WebSocket if it's // tearing us down. Aborting an already-disposed socket is a no-op // throw which we catch + ignore. try { ws.Dispose(); } catch { /* defensive */ } _logger?.LogInformation("WebSocket client {Id} disconnected.", clientId); } } /// /// Dispatcher-tick handler. Reads the current participants snapshot, and if /// it differs from what we last pushed, broadcasts the new JSON to every /// connected client. Diffing on the JSON string is cheap and saves wire /// bytes when nothing's actually changing — typical operator workflow has /// long periods of no state churn between meetings. /// private async Task PushSnapshotIfChangedAsync() { if (_clients.IsEmpty) return; string snapshot; try { snapshot = await GetSnapshotJsonAsync(); } catch { return; } if (snapshot == _lastPushedSnapshot) return; _lastPushedSnapshot = snapshot; var bytes = Encoding.UTF8.GetBytes(snapshot); foreach (var (id, ws) in _clients.ToArray()) { if (ws.State != WebSocketState.Open) { _clients.TryRemove(id, out _); continue; } try { await ws.SendAsync( new ArraySegment(bytes), WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None); } catch { _clients.TryRemove(id, out _); try { ws.Dispose(); } catch { /* defensive */ } } } } private static async Task SendAsync(WebSocket ws, string text) { var bytes = Encoding.UTF8.GetBytes(text); await ws.SendAsync( new ArraySegment(bytes), WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None); } /// /// Build the same payload as GET /participants but as a JSON string /// for direct WebSocket Send. Reads the ObservableCollection via the UI /// dispatcher because WPF's ObservableCollection isn't thread-safe to /// enumerate from a non-UI thread. /// private async Task GetSnapshotJsonAsync() { var dispatcher = System.Windows.Application.Current?.Dispatcher; var participants = dispatcher is null ? Array.Empty() : await dispatcher.InvokeAsync(() => { var vm = _viewModel(); if (vm is null) return Array.Empty(); return vm.Participants.Select(p => (object)new { id = p.Id, displayName = p.DisplayName, isOnline = p.IsOnline, isEnabled = p.IsEnabled, customName = string.IsNullOrEmpty(p.CustomName) ? null : p.CustomName, stateLabel = p.StateLabel, }).ToArray(); }); return JsonSerializer.Serialize(new { type = "participants", participants }, JsonOpts); } // ─── 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]; } }