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