From 83224dbd9bea2d46ea04ae11a294abb1cfa6ac82 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 10 May 2026 09:41:29 -0400 Subject: [PATCH] feat: REST control surface + lift preset-apply into PresetApplier --- docs/CONTROL-SURFACE.md | 276 +++++++ src/TeamsISO.App/PresetsDialog.xaml.cs | 426 ++++++++++ .../Services/ControlSurfaceServer.cs | 727 ++++++++++++++++++ src/TeamsISO.App/Services/PresetApplier.cs | 104 +++ 4 files changed, 1533 insertions(+) create mode 100644 docs/CONTROL-SURFACE.md create mode 100644 src/TeamsISO.App/PresetsDialog.xaml.cs create mode 100644 src/TeamsISO.App/Services/ControlSurfaceServer.cs create mode 100644 src/TeamsISO.App/Services/PresetApplier.cs diff --git a/docs/CONTROL-SURFACE.md b/docs/CONTROL-SURFACE.md new file mode 100644 index 0000000..b7377d6 --- /dev/null +++ b/docs/CONTROL-SURFACE.md @@ -0,0 +1,276 @@ +# TeamsISO Control Surface — REST API + +TeamsISO can expose a localhost HTTP server so external controllers +(Bitfocus Companion, Stream Deck plugins, Bome MIDI Translator, custom +node-RED flows, command-line scripts) can drive it without a UI binding. + +## Enabling + +1. Open TeamsISO → Settings → DISPLAY tab. +2. Tick "Control surface (Stream Deck / Companion)". +3. Default port is **9755**; change it via the port textbox if needed. +4. The server binds to `127.0.0.1` only — it is NOT reachable from the LAN. + If you need LAN access (e.g. a Stream Deck on a separate control PC), + front it with `ssh -L 9755:127.0.0.1:9755` or a localhost TCP bridge. + +When enabled, the toast confirms `Control surface listening on +http://127.0.0.1:9755/`. + +## Authentication + +None. The localhost-only bind is the security model. Any process on the +operator's machine can hit these endpoints, which is the same threat model +as a Stream Deck's USB connection. + +## Response shape + +All responses are `application/json` with `Access-Control-Allow-Origin: *` +so a browser-based control panel served from another origin can call the +endpoints. Most successful responses include `"ok": true` plus operation- +specific fields. Errors return HTTP 4xx/5xx with `{"error": "..."}`. + +## Endpoints + +### `GET /ui` + +Self-contained HTML control panel. Open this in a browser to drive +TeamsISO from a phone, tablet, or second monitor. Lists participants live +via the same `/ws` WebSocket the rest of the doc describes, and posts to +the REST endpoints when you click. Single page, no external dependencies, +loads in <50KB. + +### `GET /` + +Returns server info and an endpoint summary. Useful for "is the surface +alive?" probes. + +```json +{ + "product": "TeamsISO", + "version": "1.0.0.0", + "endpoints": ["GET /participants", "POST /participants/{id}/iso", ...] +} +``` + +### `GET /participants` + +Snapshot of the current participant list as the UI sees it. + +```json +{ + "participants": [ + { + "id": "1c3e2a8b-...-...", + "displayName": "Jane", + "isOnline": true, + "isEnabled": false, + "customName": null, + "stateLabel": "—" + } + ] +} +``` + +### `POST /participants/{id}/iso` + +Enable or disable an ISO by participant Id. Body or query string: + +```json +{ "enabled": true, "customName": "Host" } +``` + +`enabled` is optional — omitting it toggles the current state. `customName` +is optional and overrides the auto-generated NDI output name. + +```sh +curl -X POST 'http://127.0.0.1:9755/participants/1c3e2a8b-.../iso?enabled=true&customName=Host' +``` + +### `POST /participants/iso` + +Same as above but resolves by display name instead of Id. The Id varies +across meetings; the display name is the operator-stable identifier. + +```json +{ "displayName": "Jane", "enabled": true } +``` + +### `POST /presets/{name}/apply` + +Apply a saved preset to the live participant list. Walks every participant +in the meeting, matches by display name, sets the custom output name, and +reconciles each enable/disable via the engine. Same code path as the +Presets dialog and the auto-apply-on-launch flow (`PresetApplier.ApplyAsync`). + +```json +{ + "ok": true, + "name": "Friday Show", + "matched": 4, + "changed": 2, + "skipped": 1 +} +``` + +`matched` is how many participants in the preset were live in the meeting; +`changed` is how many actually flipped state; `skipped` is preset entries +with no live counterpart. + +### `POST /presets/refresh-discovery` + +Force NDI discovery to rebuild its finder. Useful after Apply Transcoder +Topology or when Teams restarts mid-show. Returns immediately; the rebuild +happens on the next poll tick. + +```sh +curl -X POST http://127.0.0.1:9755/presets/refresh-discovery +``` + +### `POST /presets/stop-all` + +Disable every running ISO. Equivalent to clicking "Stop all ISOs" in the +header. Returns the count that were running. + +### `POST /teams/mute` / `/camera` / `/share` / `/leave` / `/raise-hand` + +Drive the corresponding Microsoft Teams in-call control via UIAutomation. +Returns one of `Invoked` / `TeamsNotRunning` / `ControlNotFound` / +`InvokeFailed` in the `result` field. + +```sh +curl -X POST http://127.0.0.1:9755/teams/mute +``` + +### `POST /recording` + +Toggle per-output recording on or off. Body or query string: + +```json +{ "enabled": true, "directory": "D:/recordings/show-2026-05-09" } +``` + +`directory` is optional when `enabled=false`. Already-running ISOs are not +retroactively recorded — the operator should disable + re-enable a +participant to start recording it. + +### `POST /recording/marker` + +Drop a timestamped marker into every active recording. Body or query string +optionally carries a `label`; if omitted, the label defaults to +`Marker @ HH:mm:ss`. Markers land in each recording's `manifest.json` under +the `markers[]` array as `{ "offsetMs": 12345.6, "label": "Guest answer" }`. + +```sh +curl -X POST 'http://127.0.0.1:9755/recording/marker?label=Guest+answer' +``` + +### `POST /notes` + +Append a timestamped line to today's show-notes file at +`%LOCALAPPDATA%\TeamsISO\Notes\.md`. Body or query string carries +`text`. Each line is prefixed with `**HH:mm:ss** —`; the file is markdown so +it renders nicely in any editor. + +```sh +curl -X POST 'http://127.0.0.1:9755/notes?text=guest+segment+starts' +``` + +### `POST /recording/roll` + +Roll every active recording into a new chunk. Each running pipeline is +disabled (recorder finalizes its `manifest.json`), waits ~150ms, then re- +enabled (recorder opens a fresh subdirectory keyed by display name + +timestamp). Useful for chaptering between show segments — a Stream Deck +button mapped to this gives operators "next segment" without losing the +already-recorded footage. + +```sh +curl -X POST http://127.0.0.1:9755/recording/roll +``` + +Response: +```json +{ "ok": true, "action": "roll-recording", "rolled": 4 } +``` + +## WebSocket — live state push + +For controllers that want to light a button when an ISO goes LIVE without +polling, connect to: + +``` +ws://127.0.0.1:9755/ws +``` + +On connect, the server sends a participants snapshot. Whenever the snapshot +changes (participant joins/leaves, ISO toggled, custom name edited), a fresh +snapshot is pushed within 250ms. Format: + +```json +{ + "type": "participants", + "participants": [ + { "id": "...", "displayName": "Jane", "isOnline": true, + "isEnabled": true, "customName": "Host", "stateLabel": "LIVE" } + ] +} +``` + +Client→server messages are ignored for v1 — all commands go through REST. + +## OSC over UDP + +Same command surface, different transport. Enable the OSC bridge in the +DISPLAY tab (default port **9000** — TouchOSC's default). Bound to +`127.0.0.1` only. + +Address vocabulary: + +``` +/teamsiso/iso "DisplayName" {0|1} — toggle/set ISO by display name +/teamsiso/iso/by-id "guid" {0|1} — toggle/set by Id +/teamsiso/preset "Name" — apply preset +/teamsiso/teams/mute — UIA toggle mute +/teamsiso/teams/camera — UIA toggle camera +/teamsiso/teams/leave — UIA leave +/teamsiso/teams/share — UIA share tray +/teamsiso/teams/raise-hand — UIA raise hand +/teamsiso/refresh-discovery — rebuild NDI finder +/teamsiso/stop-all — disable every ISO +/teamsiso/recording {0|1} — recording on/off (default dir) +/teamsiso/recording/marker "Label" — drop a marker on every active recording +/teamsiso/recording/roll — roll every active recording into a new chunk +/teamsiso/notes "Free-form note" — append a timestamped line to today's notes +``` + +Companion → Surfaces → "Generic OSC" supports outbound OSC; bind a button +press to e.g. `/teamsiso/iso "Jane" 1`. TouchOSC layouts can use the same +addresses on the same UDP port. + +## Bitfocus Companion recipe + +Companion ships a generic HTTP module. Configure a button: + +- **Action:** `HTTP: HTTP POST request` +- **URL:** `http://127.0.0.1:9755/teams/mute` +- **Body type:** None + +Or for a participant-specific toggle: + +- **URL:** `http://127.0.0.1:9755/participants/iso?displayName=Jane&enabled=true` + +## Stream Deck XL recipe (without Companion) + +Use the "Web Requests" plugin (or any equivalent). Set the action to a POST +on the appropriate endpoint above. + +## Future work + +- **OSC bridge** over UDP 9000 with `/teamsiso/iso {id} {0|1}` etc. — same + command surface, different transport. Adapter sits in front of the REST + handlers. +- **Bidirectional state** via WebSocket — push `participants` updates so + controllers can light a button when an ISO is live without polling. +- **REST apply-preset** — duplicate the dialog's apply logic into + `IIsoController.ApplyPreset(name)` so the `/presets/{name}/apply` + endpoint becomes a real action. diff --git a/src/TeamsISO.App/PresetsDialog.xaml.cs b/src/TeamsISO.App/PresetsDialog.xaml.cs new file mode 100644 index 0000000..99830f2 --- /dev/null +++ b/src/TeamsISO.App/PresetsDialog.xaml.cs @@ -0,0 +1,426 @@ +using System.Collections.ObjectModel; +using System.Windows; +using System.Windows.Controls; +using TeamsISO.App.Services; +using TeamsISO.App.ViewModels; +using TeamsISO.Engine.Controller; + +namespace TeamsISO.App; + +/// +/// Modal dialog for saving and loading operator presets. Owned by +/// ; given a snapshot of the current +/// list and the +/// so it can re-apply assignments +/// (which requires calling EnableIsoAsync/DisableIsoAsync on the engine). +/// +public partial class PresetsDialog : Window +{ + private readonly IIsoController _controller; + private readonly IReadOnlyList _participants; + private readonly ToastViewModel? _toast; + + /// + /// Display-side wrapper for an . + /// Adds derived presentation-only properties so the ListBox template can + /// render without inline converters or value-conversion logic. + /// + public sealed class PresetRow + { + public OperatorPresetStore.Preset Preset { get; } + public string Name => Preset.Name; + public string SavedAtDisplay => Preset.SavedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm"); + public int AssignmentCount => Preset.Assignments.Count(a => a.Enabled); + public PresetRow(OperatorPresetStore.Preset preset) => Preset = preset; + } + + public ObservableCollection Rows { get; } = new(); + + public PresetsDialog( + IIsoController controller, + IReadOnlyList participants, + ToastViewModel? toast = null) + { + InitializeComponent(); + _controller = controller; + _participants = participants; + _toast = toast; + PresetsList.ItemsSource = Rows; + ReloadPresets(); + } + + /// Refresh the ListBox from disk and reflect emptiness in the empty-state TextBlock. + private void ReloadPresets() + { + Rows.Clear(); + foreach (var p in OperatorPresetStore.LoadAll().OrderByDescending(p => p.SavedAt)) + Rows.Add(new PresetRow(p)); + EmptyState.Visibility = Rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + UpdateButtonStates(); + } + + private void UpdateButtonStates() + { + var hasSelection = PresetsList.SelectedItem is PresetRow; + ApplyButton.IsEnabled = hasSelection; + DeleteButton.IsEnabled = hasSelection; + DuplicateButton.IsEnabled = hasSelection; + } + + private void OnSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (PresetsList.SelectedItem is PresetRow row) + { + // Mirror the selected name into the textbox so a re-save overwrites + // by default; operator can still type a new name to fork. + NameBox.Text = row.Name; + } + UpdateButtonStates(); + } + + private void OnSave(object sender, RoutedEventArgs e) + { + var name = NameBox.Text?.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + _toast?.Warn("Enter a name for the preset"); + NameBox.Focus(); + return; + } + + var assignments = _participants + .Select(p => new OperatorPresetStore.Assignment( + DisplayName: p.DisplayName, + CustomOutputName: string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName, + Enabled: p.IsEnabled)) + .ToList(); + + var existing = OperatorPresetStore.Find(name); + if (existing is not null) + { + var confirm = MessageBox.Show( + this, + $"A preset named \"{name}\" already exists. Overwrite it?", + "TeamsISO — Overwrite preset", + MessageBoxButton.YesNo, + MessageBoxImage.Question, + MessageBoxResult.No); + if (confirm != MessageBoxResult.Yes) return; + } + + try + { + OperatorPresetStore.Save(new OperatorPresetStore.Preset( + Name: name, + SavedAt: DateTimeOffset.Now, + Assignments: assignments)); + _toast?.Show($"Saved preset \"{name}\""); + ReloadPresets(); + } + catch (Exception ex) + { + MessageBox.Show( + this, + $"Could not save preset.\n\n{ex.Message}", + "TeamsISO — Save preset", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + } + + /// + /// Apply the selected preset: walks the current participants list, matching + /// by display name (the only stable join key across meetings — Ids are + /// regenerated each meeting). For each match, set the custom output name and + /// reconcile its enabled state with the preset by calling EnableIsoAsync / + /// DisableIsoAsync as needed. Participants in the preset who aren't in the + /// current meeting are silently skipped (and reported in the toast). + /// + private async void OnApply(object sender, RoutedEventArgs e) + { + if (PresetsList.SelectedItem is not PresetRow row) return; + + ApplyButton.IsEnabled = false; + try + { + // PresetApplier owns the apply loop — same code path the REST control + // surface and auto-apply-on-launch use. Dialog passes null dispatcher + // since OnApply already runs on the UI thread. + var result = await PresetApplier.ApplyAsync( + row.Preset, _participants, _controller, dispatcher: null); + + var summary = result.Skipped > 0 + ? $"Applied \"{row.Name}\" — {result.Changed} change(s); {result.Skipped} not in meeting" + : $"Applied \"{row.Name}\" — {result.Changed} change(s)"; + _toast?.Show(summary); + DialogResult = true; + Close(); + } + finally + { + ApplyButton.IsEnabled = true; + } + } + + /// + /// Duplicate the selected preset under a new name. We auto-suggest + /// "<original> (copy)" but pop a tiny input dialog so the operator + /// can pick something meaningful. WPF doesn't ship an InputBox; we + /// use a quick custom prompt below. + /// + private void OnDuplicate(object sender, RoutedEventArgs e) + { + if (PresetsList.SelectedItem is not PresetRow row) return; + + var defaultName = SuggestCopyName(row.Name); + var newName = PromptForName("Duplicate preset", "New name:", defaultName); + if (string.IsNullOrWhiteSpace(newName)) return; + + try + { + var existing = OperatorPresetStore.Find(newName); + if (existing is not null) + { + var confirm = MessageBox.Show( + this, + $"A preset named \"{newName}\" already exists. Overwrite it?", + "TeamsISO — Duplicate preset", + MessageBoxButton.YesNo, + MessageBoxImage.Question, + MessageBoxResult.No); + if (confirm != MessageBoxResult.Yes) return; + } + + // Re-using Save() with a fresh SavedAt timestamp — Save's overwrite + // semantics handle the name-collision case cleanly. + OperatorPresetStore.Save(new OperatorPresetStore.Preset( + Name: newName, + SavedAt: DateTimeOffset.Now, + Assignments: row.Preset.Assignments)); + _toast?.Show($"Duplicated to \"{newName}\""); + ReloadPresets(); + } + catch (Exception ex) + { + MessageBox.Show(this, + $"Could not duplicate preset.\n\n{ex.Message}", + "TeamsISO — Duplicate preset", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + } + + /// + /// Suggest a name for a copy: "Foo" → "Foo (copy)", "Foo (copy)" → "Foo (copy 2)". + /// Bumps the digit if the operator iterates from a copy. + /// + private static string SuggestCopyName(string original) + { + if (!original.EndsWith(")", StringComparison.Ordinal)) + return original + " (copy)"; + var match = System.Text.RegularExpressions.Regex.Match(original, @" \(copy(?: (\d+))?\)$"); + if (!match.Success) return original + " (copy)"; + var n = match.Groups[1].Success && int.TryParse(match.Groups[1].Value, out var parsed) ? parsed + 1 : 2; + return original[..(original.Length - match.Length)] + $" (copy {n})"; + } + + /// + /// Quick input dialog for a single string. WPF doesn't ship one, so we + /// build a minimal modal here. Keeps the dialog dependency-free. + /// + private string? PromptForName(string title, string prompt, string defaultValue) + { + var dlg = new System.Windows.Window + { + Title = title, + Owner = this, + Width = 400, + Height = 170, + WindowStartupLocation = System.Windows.WindowStartupLocation.CenterOwner, + ResizeMode = System.Windows.ResizeMode.NoResize, + Background = (System.Windows.Media.Brush)FindResource("Wd.Canvas"), + }; + var stack = new System.Windows.Controls.StackPanel { Margin = new System.Windows.Thickness(20) }; + stack.Children.Add(new System.Windows.Controls.TextBlock + { + Text = prompt, + Margin = new System.Windows.Thickness(0, 0, 0, 8), + Foreground = (System.Windows.Media.Brush)FindResource("Wd.Text.Primary"), + }); + var tb = new System.Windows.Controls.TextBox { Text = defaultValue, Padding = new System.Windows.Thickness(8, 6, 8, 6) }; + stack.Children.Add(tb); + var buttons = new System.Windows.Controls.StackPanel + { + Orientation = System.Windows.Controls.Orientation.Horizontal, + HorizontalAlignment = System.Windows.HorizontalAlignment.Right, + Margin = new System.Windows.Thickness(0, 16, 0, 0), + }; + var ok = new System.Windows.Controls.Button { Content = "OK", IsDefault = true, Padding = new System.Windows.Thickness(20, 6, 20, 6), Style = (System.Windows.Style)FindResource("Wd.Button.Primary") }; + var cancel = new System.Windows.Controls.Button { Content = "Cancel", IsCancel = true, Padding = new System.Windows.Thickness(14, 6, 14, 6), Margin = new System.Windows.Thickness(0, 0, 8, 0), Style = (System.Windows.Style)FindResource("Wd.Button.Ghost") }; + ok.Click += (_, _) => { dlg.DialogResult = true; dlg.Close(); }; + buttons.Children.Add(cancel); + buttons.Children.Add(ok); + stack.Children.Add(buttons); + dlg.Content = stack; + tb.Focus(); + tb.SelectAll(); + var result = dlg.ShowDialog(); + return result == true ? tb.Text.Trim() : null; + } + + private void OnDelete(object sender, RoutedEventArgs e) + { + if (PresetsList.SelectedItem is not PresetRow row) return; + + var confirm = MessageBox.Show( + this, + $"Delete preset \"{row.Name}\"? This cannot be undone.", + "TeamsISO — Delete preset", + MessageBoxButton.YesNo, + MessageBoxImage.Warning, + MessageBoxResult.No); + if (confirm != MessageBoxResult.Yes) return; + + try + { + OperatorPresetStore.Delete(row.Name); + _toast?.Show($"Deleted preset \"{row.Name}\""); + ReloadPresets(); + } + catch (Exception ex) + { + MessageBox.Show( + this, + $"Could not delete preset.\n\n{ex.Message}", + "TeamsISO — Delete preset", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + } + + private void OnCancel(object sender, RoutedEventArgs e) + { + DialogResult = false; + Close(); + } + + /// + /// Save every preset as a single .json bundle to a path the user picks via + /// SaveFileDialog. We use Microsoft.Win32.SaveFileDialog because it doesn't + /// drag in WinForms; the WPF host doesn't ship a built-in alternative. + /// + private void OnExport(object sender, RoutedEventArgs e) + { + var dlg = new Microsoft.Win32.SaveFileDialog + { + Title = "Export TeamsISO presets", + FileName = $"teamsiso-presets-{DateTimeOffset.Now:yyyy-MM-dd}.json", + Filter = "TeamsISO preset bundle (*.json)|*.json", + DefaultExt = "json", + }; + if (dlg.ShowDialog(this) != true) return; + + try + { + var json = OperatorPresetStore.ExportAllAsJson(); + System.IO.File.WriteAllText(dlg.FileName, json); + _toast?.Show($"Exported {Rows.Count} preset(s)"); + } + catch (Exception ex) + { + MessageBox.Show(this, + $"Could not export presets.\n\n{ex.Message}", + "TeamsISO — Export presets", + MessageBoxButton.OK, + MessageBoxImage.Warning); + } + } + + /// + /// Load a bundle from a path the user picks. On name collision we ask once + /// (covering all collisions) whether to overwrite — a per-preset prompt would + /// be exhausting for a 20-preset bundle. The Y/N here drives the overwrite + /// flag passed to . + /// + private void OnImport(object sender, RoutedEventArgs e) + { + var dlg = new Microsoft.Win32.OpenFileDialog + { + Title = "Import TeamsISO presets", + Filter = "TeamsISO preset bundle (*.json)|*.json|All files (*.*)|*.*", + }; + if (dlg.ShowDialog(this) != true) return; + + string json; + try { json = System.IO.File.ReadAllText(dlg.FileName); } + catch (Exception ex) + { + MessageBox.Show(this, + $"Could not read the file.\n\n{ex.Message}", + "TeamsISO — Import presets", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + + // Quick parse to sniff for collisions before asking the operator anything. + OperatorPresetStore.Bundle? bundle; + try { bundle = System.Text.Json.JsonSerializer.Deserialize(json); } + catch + { + MessageBox.Show(this, + "That file isn't a valid TeamsISO preset bundle.", + "TeamsISO — Import presets", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + if (bundle is null || bundle.Presets is null || bundle.Presets.Count == 0) + { + MessageBox.Show(this, + "The bundle is empty.", + "TeamsISO — Import presets", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + var existingNames = OperatorPresetStore.LoadAll() + .Select(p => p.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var collisions = bundle.Presets.Count(p => existingNames.Contains(p.Name)); + + var overwrite = false; + if (collisions > 0) + { + var choice = MessageBox.Show( + this, + $"{collisions} preset name(s) in this bundle already exist on this machine.\n\n" + + "Yes = overwrite local copies with the bundle's versions.\n" + + "No = keep local copies; only import new presets.", + "TeamsISO — Import presets", + MessageBoxButton.YesNoCancel, + MessageBoxImage.Question, + MessageBoxResult.No); + if (choice == MessageBoxResult.Cancel) return; + overwrite = choice == MessageBoxResult.Yes; + } + + var result = OperatorPresetStore.ImportBundle(json, overwrite); + if (result.Error is not null) + { + MessageBox.Show(this, + $"Import failed.\n\n{result.Error}", + "TeamsISO — Import presets", + MessageBoxButton.OK, + MessageBoxImage.Warning); + return; + } + + var summary = $"Imported — {result.Added} new"; + if (result.Overwritten > 0) summary += $", {result.Overwritten} overwritten"; + if (result.Skipped > 0) summary += $", {result.Skipped} skipped"; + _toast?.Show(summary); + ReloadPresets(); + } +} diff --git a/src/TeamsISO.App/Services/ControlSurfaceServer.cs b/src/TeamsISO.App/Services/ControlSurfaceServer.cs new file mode 100644 index 0000000..ccb7e63 --- /dev/null +++ b/src/TeamsISO.App/Services/ControlSurfaceServer.cs @@ -0,0 +1,727 @@ +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; + +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; + + /// + /// 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 a + /// different port, stop + restart on the new one. + /// + public void Start(int port) + { + if (IsRunning && Port == port) return; + Stop(); + + Port = port; + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + try + { + _listener.Start(); + } + catch (HttpListenerException ex) + { + _logger?.LogWarning(ex, "Could not start control surface on port {Port}.", 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 http://127.0.0.1:{Port}/ (REST + ws)", port); + } + + 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; + } + + 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"), + ("POST", "/recording") => SetRecording(body, req.QueryString), + ("POST", "/recording/marker") => DropMarker(body, req.QueryString), + ("POST", "/recording/roll") => await RollRecordingAsync(), + ("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 = new + { + enabled = _controller.RecordingEnabled, + directory = _controller.RecordingDirectory, + }, + 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 /recording (body: enabled + directory)", + "POST /recording/marker (body: label)", + "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 list = dispatcher.Invoke(() => 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 new { participants = list }; + } + + 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(), + }; + } + + private object SetRecording(JsonElement body, System.Collections.Specialized.NameValueCollection query) + { + var enabled = TryGetBool(body, query, "enabled") ?? false; + var directory = TryGetString(body, query, "directory"); + _controller.SetRecording(enabled, directory); + return new { ok = true, recording = new { enabled, directory } }; + } + + private object DropMarker(JsonElement body, System.Collections.Specialized.NameValueCollection query) + { + var label = TryGetString(body, query, "label") + ?? "Marker @ " + DateTimeOffset.Now.ToString("HH:mm:ss"); + _controller.AddRecordingMarker(label); + return new { ok = true, action = "marker", label }; + } + + 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 }; + } + + /// + /// Roll every active recording into a new chunk. Same code path as the UI's + /// RollRecordingCommand — disable + brief delay + re-enable each pipeline. + /// We marshal the participants snapshot through the dispatcher because + /// ObservableCollection isn't safe to enumerate from the request thread. + /// + private async Task RollRecordingAsync() + { + 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 enabled = await dispatcher.InvokeAsync(() => + vm.Participants.Where(p => p.IsEnabled).ToArray()); + var rolled = 0; + foreach (var p in enabled) + { + try + { + await _controller.DisableIsoAsync(p.Id, CancellationToken.None); + await Task.Delay(150); + var nameToUse = string.IsNullOrWhiteSpace(p.CustomName) + ? OutputNameTemplate.Render(OutputNameTemplate.Get(), p.Id, p.DisplayName) + : p.CustomName; + bool? recordOverride = p.RecordToDisk ? null : false; + await _controller.EnableIsoAsync(p.Id, nameToUse, recordOverride, CancellationToken.None); + rolled++; + } + catch { /* per-pipeline best-effort */ } + } + return new { ok = true, action = "roll-recording", rolled }; + } + + 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]; + } +} diff --git a/src/TeamsISO.App/Services/PresetApplier.cs b/src/TeamsISO.App/Services/PresetApplier.cs new file mode 100644 index 0000000..d68855c --- /dev/null +++ b/src/TeamsISO.App/Services/PresetApplier.cs @@ -0,0 +1,104 @@ +using System.Windows.Threading; +using TeamsISO.App.ViewModels; +using TeamsISO.Engine.Controller; + +namespace TeamsISO.App.Services; + +/// +/// Shared preset-application logic. Originally lived inline in +/// PresetsDialog.OnApply; lifted out so the REST control surface +/// () and the auto-apply-on-launch path +/// () can call the same +/// implementation. Single source of truth for "what does Apply mean." +/// +/// Application proceeds participant-by-participant, matching by display name +/// (the only stable join key across meetings since Ids regen each session). +/// For each match, the custom output name is updated and IsEnabled is +/// reconciled with the preset's value via +/// / . Per-participant failures are +/// caught and counted; one bad row never aborts applying the rest. +/// +public static class PresetApplier +{ + /// Result counts from an apply pass. + public sealed record ApplyResult(int Matched, int Changed, int Skipped); + + /// + /// Apply to the live + /// list. , when supplied, is used to marshal + /// IsEnabled / CustomName property writes onto the UI thread; pass null in + /// contexts that already run on the UI thread (e.g. the dialog's button click). + /// + public static async Task ApplyAsync( + Services.OperatorPresetStore.Preset preset, + IReadOnlyList participants, + IIsoController controller, + Dispatcher? dispatcher = null, + CancellationToken cancellationToken = default) + { + // Build the lookup once, case-insensitive — Teams display names are + // human-typed, so "Jane" and "jane" should match the same row. + var byName = preset.Assignments.ToDictionary( + a => a.DisplayName, + StringComparer.OrdinalIgnoreCase); + + var matched = 0; + var changed = 0; + + foreach (var p in participants) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!byName.TryGetValue(p.DisplayName, out var assignment)) continue; + matched++; + + await SetOnUiAsync(dispatcher, () => p.CustomName = assignment.CustomOutputName ?? string.Empty); + + if (assignment.Enabled && !p.IsEnabled) + { + try + { + await controller.EnableIsoAsync( + p.Id, + string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName, + cancellationToken); + await SetOnUiAsync(dispatcher, () => p.IsEnabled = true); + changed++; + } + catch + { + // Per-participant best-effort: the rest still get applied. + } + } + else if (!assignment.Enabled && p.IsEnabled) + { + try + { + await controller.DisableIsoAsync(p.Id, cancellationToken); + await SetOnUiAsync(dispatcher, () => p.IsEnabled = false); + changed++; + } + catch + { + /* defensive */ + } + } + } + + // Mark applied so auto-apply-on-launch picks the right preset next time. + try { Services.OperatorPresetStore.MarkApplied(preset.Name); } + catch { /* preference write is best-effort */ } + + var skipped = preset.Assignments.Count - matched; + return new ApplyResult(matched, changed, skipped); + } + + private static Task SetOnUiAsync(Dispatcher? dispatcher, Action action) + { + if (dispatcher is null || dispatcher.CheckAccess()) + { + action(); + return Task.CompletedTask; + } + return dispatcher.InvokeAsync(action).Task; + } +}