ControlSurfaceServer.cs was 1061 lines / 47KB — a single class hosting the HttpListener loop, the route dispatch, and every endpoint body in between. Splits the class via partial-class into a thin host file plus one partial per route group, all under Services/ControlSurface/. * Services/ControlSurfaceServer.cs (was 1061L → now 400L) — kept here: Start / Stop / DisposeAsync (the listener lifecycle), AcceptLoopAsync, HandleRequestAsync (the route table itself, with its CORS preflight + WebSocket upgrade + JSON dispatch), the response helpers (ReadBodyAsync / WriteJsonAsync / TryGetBool / TryGetString), the NotFound switch-arm, and the JsonSerializerOptions singleton. * Services/ControlSurface/Endpoints/HomeEndpoints.cs — GetServerInfo, TryRead helper. * Services/ControlSurface/Endpoints/ParticipantsEndpoints.cs (the biggest split) — GetParticipants, SetIsoOverrideByIdAsync, ClearIsoOverrideByIdAsync, TryParseEnum, ToggleIsoByIdAsync, ToggleIsoByNameAsync, ToggleByIdAsync. Together: every /participants/* handler. * Services/ControlSurface/Endpoints/PresetsEndpoints.cs — RefreshDiscovery, StopAllAsync, ApplyPresetAsync. * Services/ControlSurface/Endpoints/TeamsEndpoints.cs — InvokeTeams (the helper that maps a TeamsControlBridge result to the JSON body). * Services/ControlSurface/Endpoints/TopologyEndpoints.cs — GetTopology, ApplyTopologyAsync, RestoreTopologyAsync. * Services/ControlSurface/Endpoints/NotesEndpoints.cs — AppendNote. * Services/ControlSurface/Endpoints/ThumbnailEndpoint.cs — TryEncodeThumbnailJpeg (which is actually the BMP path now) + EncodeBmpDownscaled + the LE byte writers. The legacy TryEncodeThumbnailJpeg_WpfDeadCode helper that was dead-coded "for posterity" is gone — no call sites; we removed-comments-on-removed- code is the anti-pattern we wanted to fix. * Services/ControlSurface/WebSocketHub.cs — HandleWebSocketAsync, PushSnapshotIfChangedAsync, SendAsync, GetSnapshotJsonAsync. The push-timer wiring stays in the host's Start() so the lifetime is obvious where the connection is opened. No behavior change. The route table in HandleRequestAsync still dispatches by (HttpMethod, path) — only the handler bodies moved. Build clean; 56 + 104 tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
400 lines
18 KiB
C#
400 lines
18 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
// 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<MainViewModel?> _viewModel;
|
|
private readonly ILogger<ControlSurfaceServer>? _logger;
|
|
private HttpListener? _listener;
|
|
private CancellationTokenSource? _cts;
|
|
private Task? _acceptTask;
|
|
private DispatcherTimer? _pushTimer;
|
|
private readonly ConcurrentDictionary<Guid, WebSocket> _clients = new();
|
|
private string _lastPushedSnapshot = string.Empty;
|
|
|
|
public bool IsRunning { get; private set; }
|
|
public int Port { get; private set; } = DefaultPort;
|
|
/// <summary>True when the listener is bound to all interfaces (LAN-reachable) rather than just 127.0.0.1.</summary>
|
|
public bool BoundToLan { get; private set; }
|
|
|
|
/// <summary>
|
|
/// JSON serializer options shared across all responses. Camel-case property
|
|
/// naming matches Companion's request shape and what most JS clients expect.
|
|
/// </summary>
|
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
};
|
|
|
|
public ControlSurfaceServer(
|
|
IIsoController controller,
|
|
Func<MainViewModel?> viewModel,
|
|
ILogger<ControlSurfaceServer>? logger = null)
|
|
{
|
|
_controller = controller;
|
|
_viewModel = viewModel;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Start listening on the given port. Idempotent: if already running on the
|
|
/// same (port, bindToLan) combination, no-op; otherwise stop + restart.
|
|
/// </summary>
|
|
/// <param name="port">TCP port to listen on.</param>
|
|
/// <param name="bindToLan">
|
|
/// When true, binds to all interfaces (<c>http://+:port/</c>) 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 <c>127.0.0.1</c> only.
|
|
///
|
|
/// LAN binding requires either running TeamsISO as Administrator OR a
|
|
/// one-time URL ACL reservation at the OS level:
|
|
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
|
|
/// If neither is in place the listener throws AccessDeniedException
|
|
/// which we catch and surface as a logger warning.
|
|
/// </summary>
|
|
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<JsonElement> 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<JsonElement>(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];
|
|
}
|
|
}
|