teamsiso/src/TeamsISO.App/ViewModels/GlobalSettingsViewModel.cs
Zac Gaetano d9eb02a9af
Some checks failed
CI / build-and-test (push) Failing after 26s
Fix GetLanIPv4 to skip Tailscale/VPN/APIPA addresses
On a Windows host with both Ethernet (10.0.0.123) and Tailscale (169.254.83.107 link-local), the original first-hit-wins picker returned the Tailscale address — useless for the headless-host + thin-client scenario the LAN-reachable mode is designed for.

New picker prefers physical NICs (Ethernet/GigabitEthernet/Wireless80211), skips Tunnel-typed virtuals, and ranks: physical-routable > virtual-routable > APIPA. Verified against this host: now returns 10.0.0.123 instead of 169.254.83.107.
2026-05-10 13:11:11 -04:00

576 lines
25 KiB
C#

using System.IO;
using System.Windows;
using TeamsISO.App.Services;
using TeamsISO.Engine.Controller;
using TeamsISO.Engine.Domain;
namespace TeamsISO.App.ViewModels;
/// <summary>
/// Bindings for the global settings panel: framerate, resolution, aspect, audio,
/// NDI groups (discovery + output), and the participant-list hide-Local toggle.
/// </summary>
public sealed class GlobalSettingsViewModel : ObservableObject
{
private readonly IIsoController _controller;
private readonly ToastViewModel? _toast;
private TargetFramerate _framerate;
private TargetResolution _resolution;
private AspectMode _aspect;
private AudioMode _audio;
private string _discoveryGroups;
private string _outputGroups;
private bool _hideLocalSelf = true;
private bool _autoDisableOnDeparture = false;
private bool _autoApplyLastPreset;
private bool _recordIsosToDisk;
private string _recordingDirectory = string.Empty;
private bool _controlSurfaceEnabled;
private int _controlSurfacePort = ControlSurfaceServer.DefaultPort;
private bool _oscBridgeEnabled;
private int _oscBridgePort = OscBridge.DefaultPort;
private bool _updateCheckOnLaunch = UpdateChecker.LaunchCheckEnabled;
private string _outputNameTemplate = TeamsISO.App.Services.OutputNameTemplate.Get();
private UIPreferences.SortMode _participantSort = UIPreferences.SortMode.JoinOrder;
private bool _minimizeToTray;
private bool _controlSurfaceLanReachable;
public GlobalSettingsViewModel(IIsoController controller, ToastViewModel? toast = null)
{
_controller = controller;
_toast = toast;
var current = controller.GlobalSettings;
_framerate = current.Framerate;
_resolution = current.Resolution;
_aspect = current.Aspect;
_audio = current.Audio;
var groups = controller.GroupSettings;
_discoveryGroups = groups.DiscoveryGroups ?? string.Empty;
_outputGroups = groups.OutputGroups ?? string.Empty;
// Restore persisted UI toggles so the operator's preference survives
// process restarts. UIPreferences keeps a tiny JSON file under
// %LOCALAPPDATA%\TeamsISO\ui-prefs.json — defaults match the original
// in-memory init (HideLocalSelf=true, AutoDisableOnDeparture=false).
var uiPrefs = UIPreferences.Load();
_hideLocalSelf = uiPrefs.HideLocalSelf;
_autoDisableOnDeparture = uiPrefs.AutoDisableOnDeparture;
_participantSort = uiPrefs.ParticipantSort;
_minimizeToTray = uiPrefs.MinimizeToTray;
_controlSurfaceLanReachable = uiPrefs.ControlSurfaceLanReachable;
// Bring the auto-apply flag in from the presets store so the checkbox
// reflects the user's prior choice when the settings panel opens.
try { _autoApplyLastPreset = OperatorPresetStore.GetStartupPreference().AutoApplyOnStartup; }
catch { /* best-effort — disk read failures shouldn't block UI startup */ }
// Default recording directory: %USERPROFILE%\Videos\TeamsISO\<today's date>.
// Operator can override via the textbox. Date in the path keeps recordings
// from a long-running show day organized without us having to scan + rotate.
_recordingDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyVideos),
"TeamsISO",
DateTimeOffset.Now.ToString("yyyy-MM-dd"));
_recordIsosToDisk = controller.RecordingEnabled;
if (!string.IsNullOrEmpty(controller.RecordingDirectory))
_recordingDirectory = controller.RecordingDirectory;
ApplyCommand = new AsyncRelayCommand(ApplyAsync);
ApplyTranscoderTopologyCommand = new AsyncRelayCommand(ApplyTranscoderTopologyAsync);
ResetOutputDefaultsCommand = new RelayCommand(ResetOutputDefaults);
CopyControlSurfaceUrlCommand = new RelayCommand(() =>
{
try
{
System.Windows.Clipboard.SetText(ControlSurfaceUrl);
_toast?.Show($"Copied: {ControlSurfaceUrl}");
}
catch
{
// Clipboard occasionally errors when something else has it locked.
}
});
}
private void ResetOutputDefaults()
{
var confirm = MessageBox.Show(
"Reset framerate, resolution, aspect and audio to TeamsISO defaults?\n\n" +
"This won't touch your NDI group configuration or display toggles.",
"TeamsISO — Reset output defaults",
MessageBoxButton.YesNo,
MessageBoxImage.Question,
MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
var defaults = FrameProcessingSettings.Default;
Framerate = defaults.Framerate;
Resolution = defaults.Resolution;
Aspect = defaults.Aspect;
Audio = defaults.Audio;
_toast?.Show("Output settings reset to defaults — click Apply Changes to commit");
}
public IEnumerable<TargetFramerate> AvailableFramerates => Enum.GetValues<TargetFramerate>();
public IEnumerable<TargetResolution> AvailableResolutions => Enum.GetValues<TargetResolution>();
public IEnumerable<AspectMode> AvailableAspectModes => Enum.GetValues<AspectMode>();
public IEnumerable<AudioMode> AvailableAudioModes => Enum.GetValues<AudioMode>();
public TargetFramerate Framerate { get => _framerate; set => SetField(ref _framerate, value); }
public TargetResolution Resolution { get => _resolution; set => SetField(ref _resolution, value); }
public AspectMode Aspect { get => _aspect; set => SetField(ref _aspect, value); }
public AudioMode Audio { get => _audio; set => SetField(ref _audio, value); }
/// <summary>NDI discovery group(s) — comma-separated. Empty = default (Public).</summary>
public string DiscoveryGroups { get => _discoveryGroups; set => SetField(ref _discoveryGroups, value); }
/// <summary>NDI output group(s) — comma-separated. Empty = default (Public).</summary>
public string OutputGroups { get => _outputGroups; set => SetField(ref _outputGroups, value); }
/// <summary>
/// Hide the user's own self-preview ("(Local)") from the participants list.
/// On by default — operators rarely want to ISO-route their own preview.
/// Read by <see cref="MainViewModel"/> when filtering the list it presents.
/// Persisted to <c>ui-prefs.json</c> via <see cref="UIPreferences"/>.
/// </summary>
public bool HideLocalSelf
{
get => _hideLocalSelf;
set
{
if (SetField(ref _hideLocalSelf, value)) PersistUiPrefs();
}
}
/// <summary>
/// When a participant leaves the meeting (their NDI source disappears),
/// automatically tear down their ISO pipeline. Off by default so transient
/// drops don't lose the operator's routing — but useful for clean
/// show-end behavior. Read by MainViewModel when reconciling departures.
/// Persisted to <c>ui-prefs.json</c>.
/// </summary>
public bool AutoDisableOnDeparture
{
get => _autoDisableOnDeparture;
set
{
if (SetField(ref _autoDisableOnDeparture, value)) PersistUiPrefs();
}
}
/// <summary>
/// Available sort modes for the dropdown in DISPLAY settings.
/// </summary>
public IEnumerable<UIPreferences.SortMode> AvailableSortModes => Enum.GetValues<UIPreferences.SortMode>();
/// <summary>
/// How the participants DataGrid is sorted. Persisted across launches via
/// <see cref="UIPreferences"/>. Reaches into <see cref="MainViewModel.SetSortMode"/>
/// on Application.Current to actually apply the sort to the live view —
/// the settings VM doesn't directly know about the main VM but App holds
/// both and exposes the main window via its DataContext.
/// </summary>
public UIPreferences.SortMode ParticipantSort
{
get => _participantSort;
set
{
if (!SetField(ref _participantSort, value)) return;
PersistUiPrefs();
// Apply to the live view immediately. App.MainWindow.DataContext
// is the MainViewModel; cast and call.
var main = (Application.Current?.MainWindow?.DataContext) as MainViewModel;
main?.SetSortMode(value);
}
}
/// <summary>
/// Minimize-to-tray behavior. When on, minimizing the main window hides
/// it from the taskbar and shows a tray icon (double-click to restore).
/// Right-click menu on the tray icon offers "Show", "Stop all ISOs", "Exit".
/// Useful for long unattended shows where the operator wants TeamsISO
/// running but invisible.
/// </summary>
public bool MinimizeToTray
{
get => _minimizeToTray;
set
{
if (!SetField(ref _minimizeToTray, value)) return;
PersistUiPrefs();
// Reach into the App-owned tray host. App constructs it after the
// main window exists, so the cast is safe at any time the settings
// panel is interactable.
var tray = (Application.Current as App)?.TrayIcon;
if (tray is not null) tray.Enabled = value;
_toast?.Show(value
? "Minimize-to-tray enabled — minimizing now hides the window"
: "Minimize-to-tray disabled");
}
}
/// <summary>
/// Snapshot the current persistable UI state to disk. Called from any
/// <see cref="UIPreferences.Prefs"/>-backed setter. Best-effort — disk
/// failures don't surface to the operator (the in-memory state still
/// reflects their click for this session).
/// </summary>
private void PersistUiPrefs() =>
UIPreferences.Save(new UIPreferences.Prefs(
HideLocalSelf: _hideLocalSelf,
AutoDisableOnDeparture: _autoDisableOnDeparture,
ParticipantSort: _participantSort,
MinimizeToTray: _minimizeToTray,
ControlSurfaceLanReachable: _controlSurfaceLanReachable));
/// <summary>
/// Record each newly-enabled ISO's normalized output to disk under
/// <see cref="RecordingDirectory"/>. Already-running ISOs are not retroactively
/// recorded — the operator should disable + re-enable them. Outputs raw BGRA
/// + manifest.json + convert.cmd; running convert.cmd produces a final
/// H.264 .mkv via FFmpeg.
/// </summary>
public bool RecordIsosToDisk
{
get => _recordIsosToDisk;
set
{
if (SetField(ref _recordIsosToDisk, value))
{
_controller.SetRecording(value, _recordingDirectory);
_toast?.Show(value
? "Recording on — newly-enabled ISOs will write to disk"
: "Recording off");
}
}
}
/// <summary>
/// Output root for recorder files. Each ISO writes a subdirectory keyed by
/// participant display name. Default: <c>%USERPROFILE%\Videos\TeamsISO\&lt;date&gt;</c>.
/// </summary>
public string RecordingDirectory
{
get => _recordingDirectory;
set
{
if (SetField(ref _recordingDirectory, value) && _recordIsosToDisk)
_controller.SetRecording(true, value);
}
}
/// <summary>
/// REST control surface — Stream Deck / Companion / thin-client controllers.
/// Off by default. Bind address depends on <see cref="ControlSurfaceLanReachable"/>:
/// loopback (127.0.0.1) by default, or all-interfaces when LAN-reachable.
/// </summary>
public bool ControlSurfaceEnabled
{
get => _controlSurfaceEnabled;
set
{
if (!SetField(ref _controlSurfaceEnabled, value)) return;
var srv = (Application.Current as App)?.ControlSurface;
if (srv is null) return;
if (value) srv.Start(_controlSurfacePort, _controlSurfaceLanReachable);
else srv.Stop();
_toast?.Show(value
? $"Control surface listening on {ControlSurfaceUrl}"
: "Control surface stopped");
}
}
/// <summary>
/// Port the control surface binds to. Editable while the surface is off; while on,
/// changing the port stops + restarts the listener on the new port.
/// </summary>
public int ControlSurfacePort
{
get => _controlSurfacePort;
set
{
if (!SetField(ref _controlSurfacePort, value)) return;
OnPropertyChanged(nameof(ControlSurfaceUrl));
if (!_controlSurfaceEnabled) return;
var srv = (Application.Current as App)?.ControlSurface;
srv?.Start(value, _controlSurfaceLanReachable);
_toast?.Show($"Control surface restarted on {ControlSurfaceUrl}");
}
}
/// <summary>
/// LAN-reachable mode. When false (default), control surface binds to
/// 127.0.0.1 — only this machine. When true, binds to all interfaces so
/// a thin-client controller on a phone or another laptop can drive
/// TeamsISO. The OSC bridge follows suit if it's running.
///
/// Important: HttpListener requires either Administrator privilege OR a
/// one-time URL ACL reservation for non-loopback prefixes:
/// <code>netsh http add urlacl url=http://+:9755/ user=Everyone</code>
/// (run from an elevated PowerShell). Without that the listener throws
/// AccessDeniedException on Start; the failure surfaces as a logger
/// warning with the exact netsh command.
/// </summary>
public bool ControlSurfaceLanReachable
{
get => _controlSurfaceLanReachable;
set
{
if (!SetField(ref _controlSurfaceLanReachable, value)) return;
PersistUiPrefs();
OnPropertyChanged(nameof(ControlSurfaceUrl));
if (!_controlSurfaceEnabled) return;
var srv = (Application.Current as App)?.ControlSurface;
srv?.Start(_controlSurfacePort, value);
var osc = (Application.Current as App)?.OscBridge;
if (osc?.IsRunning == true) osc.Start(_oscBridgePort, value);
_toast?.Show(value
? $"Control surface now LAN-reachable: {ControlSurfaceUrl}"
: "Control surface now loopback-only");
}
}
/// <summary>
/// Friendly URL of the running surface, for the settings panel + status
/// bar tooltip. Resolves to the first non-loopback IPv4 address when
/// LAN-reachable; loopback otherwise. Computed on demand because the
/// LAN IP may change between settings opens (Wi-Fi swap, VPN connect).
/// </summary>
public string ControlSurfaceUrl
{
get
{
var host = _controlSurfaceLanReachable
? GetLanIPv4() ?? "127.0.0.1"
: "127.0.0.1";
return $"http://{host}:{_controlSurfacePort}/ui";
}
}
/// <summary>
/// Best-effort routable IPv4 address suitable for showing the operator a
/// "paste me into the thin client" URL. Skips:
/// • loopback interfaces (127.x)
/// • tunnel/virtual interfaces (NetworkInterfaceType.Tunnel — e.g. WSL,
/// Hyper-V, Tailscale, OpenVPN-style virtuals)
/// • APIPA/link-local addresses (169.254.x — assigned when DHCP fails;
/// a host with one of these AND a real DHCP lease should pick the lease)
/// Prefers Ethernet/Wi-Fi over everything else, then falls back to the
/// first non-link-local non-loopback IPv4. Returns null only if no
/// usable address exists at all.
/// </summary>
private static string? GetLanIPv4()
{
try
{
string? linkLocalFallback = null;
string? otherFallback = null;
foreach (var ni in System.Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces())
{
if (ni.OperationalStatus != System.Net.NetworkInformation.OperationalStatus.Up) continue;
if (ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Loopback) continue;
if (ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Tunnel) continue;
var isPhysical =
ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Ethernet ||
ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.GigabitEthernet ||
ni.NetworkInterfaceType == System.Net.NetworkInformation.NetworkInterfaceType.Wireless80211;
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
{
if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
if (System.Net.IPAddress.IsLoopback(ua.Address)) continue;
var addr = ua.Address.ToString();
var isLinkLocal = addr.StartsWith("169.254.", StringComparison.Ordinal);
if (isPhysical && !isLinkLocal) return addr; // best
if (!isLinkLocal) otherFallback ??= addr; // routable but virtual NIC
if (isLinkLocal) linkLocalFallback ??= addr; // worst
}
}
return otherFallback ?? linkLocalFallback;
}
catch { /* best-effort */ }
return null;
}
/// <summary>
/// OSC bridge over UDP — same command surface as the REST endpoints,
/// reachable from Companion / TouchOSC / lighting consoles. Off by default;
/// bound to 127.0.0.1 only.
/// </summary>
public bool OscBridgeEnabled
{
get => _oscBridgeEnabled;
set
{
if (!SetField(ref _oscBridgeEnabled, value)) return;
var bridge = (Application.Current as App)?.OscBridge;
if (bridge is null) return;
if (value) bridge.Start(_oscBridgePort, _controlSurfaceLanReachable);
else bridge.Stop();
var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
_toast?.Show(value
? $"OSC bridge listening on udp://{host}:{_oscBridgePort}/"
: "OSC bridge stopped");
}
}
/// <summary>OSC bridge UDP port. Default 9000 (TouchOSC's default).</summary>
public int OscBridgePort
{
get => _oscBridgePort;
set
{
if (!SetField(ref _oscBridgePort, value)) return;
if (!_oscBridgeEnabled) return;
var bridge = (Application.Current as App)?.OscBridge;
bridge?.Start(value, _controlSurfaceLanReachable);
var host = _controlSurfaceLanReachable ? "0.0.0.0" : "127.0.0.1";
_toast?.Show($"OSC bridge restarted on udp://{host}:{value}/");
}
}
/// <summary>
/// Output-name template applied when the operator enables an ISO without
/// a per-participant CustomName. Default <c>"TEAMSISO_{guid}"</c> matches
/// the engine's hard-coded behavior; switch to <c>"TEAMSISO_{name}"</c>
/// for human-readable NDI source names. See <see cref="OutputNameTemplate"/>
/// for the supported tokens.
/// </summary>
public string OutputNameTemplate
{
get => _outputNameTemplate;
set
{
if (SetField(ref _outputNameTemplate, value))
{
TeamsISO.App.Services.OutputNameTemplate.Set(value ?? string.Empty);
}
}
}
/// <summary>
/// Background update check on launch. Throttled to once per 24h via a
/// timestamp file. When a newer release is found, surfaces a non-modal
/// banner with a "Get update" button. Off-by-default would be friendlier
/// for paranoid setups; on-by-default is friendlier for adoption.
/// </summary>
public bool UpdateCheckOnLaunch
{
get => _updateCheckOnLaunch;
set
{
if (SetField(ref _updateCheckOnLaunch, value))
{
UpdateChecker.LaunchCheckEnabled = value;
_toast?.Show(value
? "Update checks enabled — runs once per 24h on launch"
: "Update checks disabled");
}
}
}
/// <summary>
/// On launch, automatically re-apply the most recently applied operator preset.
/// Closes the loop on the recurring-show workflow: the operator clicks Apply
/// once, and from that point on TeamsISO restores the same routing on every
/// subsequent launch as soon as the matching participants come online.
/// Persisted to <c>presets.json</c>'s <c>AutoApplyOnStartup</c> field via
/// <see cref="OperatorPresetStore"/>.
/// </summary>
public bool AutoApplyLastPreset
{
get => _autoApplyLastPreset;
set
{
if (SetField(ref _autoApplyLastPreset, value))
{
try { OperatorPresetStore.SetAutoApplyOnStartup(value); }
catch { /* best-effort */ }
}
}
}
public AsyncRelayCommand ApplyCommand { get; }
/// <summary>
/// Copy the current control-surface URL to the clipboard. Operators on a
/// thin-client setup tap this, then paste into a phone browser. Bound to
/// a small button next to the LAN-reachable toggle.
/// </summary>
public RelayCommand CopyControlSurfaceUrlCommand { get; }
/// <summary>
/// Restore Output settings (framerate, resolution, aspect, audio) to the engine's
/// shipping defaults. Doesn't touch NDI groups (those are intentionally sticky —
/// the operator's transcoder topology is a per-machine setting that survives
/// preferences resets) and doesn't touch Display toggles. Confirms first.
/// </summary>
public RelayCommand ResetOutputDefaultsCommand { get; }
/// <summary>
/// One-click "set up the transcoder topology" — writes ndi-config.v1.json so all
/// local senders broadcast on a private group ("teamsiso-input") while local
/// receivers can see both that and "public", then sets the engine's discovery and
/// output groups to align (engine receives from the private group, emits on Public).
/// User has to restart Teams for the new ndi-config.v1.json to take effect there.
/// </summary>
public AsyncRelayCommand ApplyTranscoderTopologyCommand { get; }
private async Task ApplyAsync()
{
var settings = new FrameProcessingSettings(_framerate, _resolution, _aspect, _audio);
await _controller.SetGlobalSettingsAsync(settings, CancellationToken.None);
var groups = new NdiGroupSettings(
DiscoveryGroups: string.IsNullOrWhiteSpace(_discoveryGroups) ? null : _discoveryGroups.Trim(),
OutputGroups: string.IsNullOrWhiteSpace(_outputGroups) ? null : _outputGroups.Trim());
await _controller.SetGroupSettingsAsync(groups, CancellationToken.None);
_toast?.Show("Settings saved");
}
private async Task ApplyTranscoderTopologyAsync()
{
// 1. Update the machine-wide NDI config so Teams' raw broadcasts go to the
// private group instead of polluting Public.
var result = NdiAccessManagerConfig.ApplyTranscoderTopology();
if (!result.Success)
{
MessageBox.Show(
$"Could not write NDI Access Manager config.\n\n{result.ErrorMessage}\n\nPath: {result.ConfigPath}",
"TeamsISO — Apply transcoder topology",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
// 2. Update the engine: receive only from the private group, emit on Public.
var ourGroups = new NdiGroupSettings(
DiscoveryGroups: NdiAccessManagerConfig.TranscoderInputGroup,
OutputGroups: "public");
await _controller.SetGroupSettingsAsync(ourGroups, CancellationToken.None);
// 3. Reflect the new values in the bound text boxes.
DiscoveryGroups = NdiAccessManagerConfig.TranscoderInputGroup;
OutputGroups = "public";
var backupNote = result.BackupPath is null
? "No prior NDI config existed; a fresh one was created."
: $"A backup of your prior NDI config was saved to:\n{result.BackupPath}";
MessageBox.Show(
"Transcoder topology applied. ✓\n\n" +
"• Local senders (Teams, etc.) will broadcast on group 'teamsiso-input'.\n" +
"• Local receivers will see both 'public' and 'teamsiso-input'.\n" +
"• TeamsISO will discover from 'teamsiso-input' and re-emit on 'public'.\n\n" +
"RESTART Microsoft Teams for the new NDI config to take effect there.\n\n" +
backupNote,
"TeamsISO — Apply transcoder topology",
MessageBoxButton.OK,
MessageBoxImage.Information);
_toast?.Show("Transcoder topology applied — restart Teams to take effect");
}
}