From e8f52a3153d26e0f25afbf59a7c5692f7919efea Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Fri, 8 May 2026 13:50:19 -0400 Subject: [PATCH] feat: app icon, FPS, drops counter, --version, About dialog, Stop Teams toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six related polish items, all building on tonight's groundwork. 1. App icon: teamsiso.ico generated from dragon-mark.png at 7 sizes (16-256), wired as ApplicationIcon in the WPF csproj, MainWindow.Icon, AboutWindow.Icon, and ARPPRODUCTICON in the WiX MSI. Taskbar / window / Add-Remove-Programs all show the dragon mark now. 2. Running incoming FPS: ring buffer of last 30 frame timestamps in IsoPipeline; ComputeFps() returns moving-average rate. Surfaced on IsoHealthStats.IncomingFps and shown in the Source column of the participants DataGrid as 'WxH · 59.94 fps'. Resets cleanly on every supervisor restart. 3. Drops counter: FrameProcessor.Stats already aggregated FramesDropped (closest-frame strategy when the receiver outpaces the processor) and FramesDuplicated; just plumbed _liveProcessor through IsoPipeline so GetStats() can read them. Exposed in the Live column under the in/out counters as a coral-tinted 'drop N'. 4. Console --version flag: prints engine version (with embedded git SHA), .NET version, OS, NDI runtime banner, expected prefix, exit-code legend, plus a wilddragon.net link. Useful for support tickets. 5. About dialog: chromeless modal with the dragon mark + version / .NET / OS / NDI runtime fields and a link to wilddragon.net. Triggered by clicking the rail logo. 6. Teams launcher Stop toggle: TeamsLauncher gains IsRunning() and StopAll(). The rail's Teams button now toggles — if Teams is up, ask to close all Teams windows via WM_CLOSE; otherwise launch as before. Confirms before stopping so we don't kill the user's call mid-transition. Tests: 74/74 unit + 9/9 NDI integration green throughout. MSI builds clean and now embeds the dragon icon for ARP. --- installer/Package.wxs | 9 + installer/TeamsISO.Installer.wixproj | 2 +- src/TeamsISO.App/AboutWindow.xaml | 171 ++++++++++++++++++ src/TeamsISO.App/AboutWindow.xaml.cs | 71 ++++++++ src/TeamsISO.App/Assets/teamsiso.ico | Bin 0 -> 39687 bytes src/TeamsISO.App/MainWindow.xaml | 46 +++-- src/TeamsISO.App/MainWindow.xaml.cs | 36 +++- src/TeamsISO.App/Services/TeamsLauncher.cs | 53 ++++++ src/TeamsISO.App/TeamsISO.App.csproj | 3 +- .../ViewModels/ParticipantViewModel.cs | 16 ++ src/TeamsISO.Console/Program.cs | 58 ++++++ src/TeamsISO.Engine/Pipeline/IsoPipeline.cs | 89 ++++++++- 12 files changed, 522 insertions(+), 32 deletions(-) create mode 100644 src/TeamsISO.App/AboutWindow.xaml create mode 100644 src/TeamsISO.App/AboutWindow.xaml.cs create mode 100644 src/TeamsISO.App/Assets/teamsiso.ico diff --git a/installer/Package.wxs b/installer/Package.wxs index b1b7233..79de7cd 100644 --- a/installer/Package.wxs +++ b/installer/Package.wxs @@ -58,6 +58,15 @@ + + + + - PublishDir=$(PublishDir) + PublishDir=$(PublishDir);AssetsDir=$(MSBuildThisFileDirectory)..\src\TeamsISO.App\Assets\ false diff --git a/src/TeamsISO.App/AboutWindow.xaml b/src/TeamsISO.App/AboutWindow.xaml new file mode 100644 index 0000000..38edd77 --- /dev/null +++ b/src/TeamsISO.App/AboutWindow.xaml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + wilddragon.net + + + + + Margin="0,0,0,12"/> - + Foreground="{DynamicResource Wd.Text.Tertiary}"> + + + + - + - + + + - - + Foreground="{DynamicResource Wd.Accent.Coral}"> + + diff --git a/src/TeamsISO.App/MainWindow.xaml.cs b/src/TeamsISO.App/MainWindow.xaml.cs index a2c9030..e6c739d 100644 --- a/src/TeamsISO.App/MainWindow.xaml.cs +++ b/src/TeamsISO.App/MainWindow.xaml.cs @@ -29,13 +29,43 @@ public partial class MainWindow : Window /// Custom close button. private void OnClose(object sender, RoutedEventArgs e) => Close(); + /// Opens the About dialog — version, NDI runtime, build SHA. + private void OnAboutClick(object sender, RoutedEventArgs e) + { + var about = new AboutWindow { Owner = this }; + about.ShowDialog(); + } + /// - /// First step toward the Embedded-Teams roadmap (Phase E.1) — launches the MS - /// Teams desktop client as a subprocess so the operator doesn't have to switch - /// apps to start a meeting. + /// Toggle behavior: if Teams is already running, ask to stop it; otherwise + /// launch via TeamsLauncher's fallback chain. First step toward the + /// Embedded-Teams roadmap (Phase E.1). /// private void OnLaunchTeamsClick(object sender, RoutedEventArgs e) { + if (TeamsLauncher.IsRunning()) + { + var confirm = MessageBox.Show( + "Microsoft Teams is currently running.\n\nClose all Teams windows now?", + "TeamsISO — Stop Teams", + MessageBoxButton.YesNo, + MessageBoxImage.Question); + if (confirm != MessageBoxResult.Yes) return; + + var asked = TeamsLauncher.StopAll(); + if (TeamsLauncher.IsRunning()) + { + MessageBox.Show( + asked == 0 + ? "No Teams windows responded to close." + : $"Sent close to {asked} Teams window(s); some may still be exiting.", + "TeamsISO — Stop Teams", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + return; + } + if (!TeamsLauncher.TryLaunch(out var error)) { MessageBox.Show( diff --git a/src/TeamsISO.App/Services/TeamsLauncher.cs b/src/TeamsISO.App/Services/TeamsLauncher.cs index 5f6be4b..d32b1ad 100644 --- a/src/TeamsISO.App/Services/TeamsLauncher.cs +++ b/src/TeamsISO.App/Services/TeamsLauncher.cs @@ -22,6 +22,24 @@ namespace TeamsISO.App.Services; /// public static class TeamsLauncher { + /// + /// Heuristic process-name candidates we'll consider as "the Teams process" when + /// the rail toggle wants to find a running instance. New MSTeams comes first. + /// + private static readonly string[] TeamsProcessNames = + { + "ms-teams", // new MSTeams binary basename + "msteams", // alternate basename observed on some installs + "Teams", // classic Teams desktop client + }; + + /// + /// True if any process matching the known Teams binary basenames is running. + /// Used by the rail to decide whether to show "Launch Teams" vs "Stop Teams". + /// + public static bool IsRunning() => + TeamsProcessNames.Any(n => Process.GetProcessesByName(n).Length > 0); + /// /// Launches Teams. Returns true if a launch was started successfully (the /// process may take a few seconds to actually appear). False if every @@ -69,6 +87,41 @@ public static class TeamsLauncher return false; } + /// + /// Asks every running Teams process to close gracefully via WM_CLOSE + /// (CloseMainWindow). Returns the count of processes that exited cleanly within + /// . Stragglers are NOT force-killed — Teams' own + /// "are you sure" prompt may legitimately keep a process alive briefly, and we + /// don't want to nuke the user's call mid-transition. + /// + public static int StopAll(TimeSpan? gracePeriod = null) + { + var grace = gracePeriod ?? TimeSpan.FromSeconds(3); + var deadline = DateTime.UtcNow + grace; + var asked = 0; + foreach (var name in TeamsProcessNames) + { + foreach (var p in Process.GetProcessesByName(name)) + { + try + { + if (p.HasExited) { p.Dispose(); continue; } + if (p.MainWindowHandle != IntPtr.Zero) + { + p.CloseMainWindow(); + asked++; + } + } + catch { /* defensive: process may have died between enumeration and signal */ } + finally { p.Dispose(); } + } + } + // Best-effort wait so the rail can flip its icon promptly. + while (DateTime.UtcNow < deadline && IsRunning()) + Thread.Sleep(150); + return asked; + } + private static bool TryStart(string target, bool useShell) { try diff --git a/src/TeamsISO.App/TeamsISO.App.csproj b/src/TeamsISO.App/TeamsISO.App.csproj index d4a1c29..9b9eab7 100644 --- a/src/TeamsISO.App/TeamsISO.App.csproj +++ b/src/TeamsISO.App/TeamsISO.App.csproj @@ -7,7 +7,7 @@ TeamsISO.App TeamsISO true - + Assets\teamsiso.ico @@ -19,6 +19,7 @@ + diff --git a/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs index 9a1d67a..a4d1d5e 100644 --- a/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs +++ b/src/TeamsISO.App/ViewModels/ParticipantViewModel.cs @@ -38,7 +38,9 @@ public sealed class ParticipantViewModel : ObservableObject private long _framesIn; private long _framesOut; + private long _framesDropped; private string _incomingResolution = "—"; + private string _incomingFps = "—"; /// Number of frames the receiver has captured so far. public long FramesIn { get => _framesIn; set => SetField(ref _framesIn, value); } @@ -46,17 +48,31 @@ public sealed class ParticipantViewModel : ObservableObject /// Number of frames the sender has emitted so far. public long FramesOut { get => _framesOut; set => SetField(ref _framesOut, value); } + /// Frames dropped by the closest-frame strategy when the receiver outpaces the processor. + public long FramesDropped { get => _framesDropped; set => SetField(ref _framesDropped, value); } + /// Source resolution as "WxH", or em-dash when no frames have been seen yet. public string IncomingResolution { get => _incomingResolution; set => SetField(ref _incomingResolution, value); } + /// + /// Live incoming framerate as "59.94 fps", or em-dash when fewer than 2 frames + /// have been observed since the pipeline started. Computed in the engine via a + /// 30-frame moving window. + /// + public string IncomingFps { get => _incomingFps; set => SetField(ref _incomingFps, value); } + /// Updates the live stats display from a controller-side snapshot. public void UpdateStats(IsoHealthStats stats) { FramesIn = stats.FramesIn; FramesOut = stats.FramesOut; + FramesDropped = stats.FramesDropped; IncomingResolution = stats.IncomingWidth > 0 && stats.IncomingHeight > 0 ? $"{stats.IncomingWidth}×{stats.IncomingHeight}" : "—"; + IncomingFps = stats.IncomingFps > 0 + ? $"{stats.IncomingFps:0.0} fps" + : "—"; } public bool IsProcessing diff --git a/src/TeamsISO.Console/Program.cs b/src/TeamsISO.Console/Program.cs index 0732991..540d08a 100644 --- a/src/TeamsISO.Console/Program.cs +++ b/src/TeamsISO.Console/Program.cs @@ -26,6 +26,8 @@ using SysConsole = System.Console; /// teamsiso-console --list-sources # diagnostic: print every raw NDI source string visible /// on the network for ~5s, then exit. Useful for debugging /// why expected Teams sources aren't being classified. +/// teamsiso-console --version # print engine version, NDI runtime version, exit codes, +/// then exit 0. Useful for support requests. /// public static class Program { @@ -33,6 +35,14 @@ public static class Program { var enableAll = args.Contains("--enable-all", StringComparer.OrdinalIgnoreCase); var listSources = args.Contains("--list-sources", StringComparer.OrdinalIgnoreCase); + var version = args.Contains("--version", StringComparer.OrdinalIgnoreCase) + || args.Contains("-v", StringComparer.OrdinalIgnoreCase); + + if (version) + { + return PrintVersion(); + } + using var loggerFactory = EngineLogging.CreateConsole(LogLevel.Information); var logger = loggerFactory.CreateLogger("TeamsISO.Console"); @@ -136,6 +146,54 @@ public static class Program return 0; } + /// + /// Prints version + diagnostic info. Always exits 0; the strings are intended to + /// be pasted into a support ticket. + /// + private static int PrintVersion() + { + var asm = typeof(Program).Assembly; + var asmVersion = asm.GetName().Version?.ToString() ?? "unknown"; + var info = asm.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false) + .OfType() + .FirstOrDefault()?.InformationalVersion ?? asmVersion; + + SysConsole.WriteLine($"TeamsISO Console"); + SysConsole.WriteLine($" Version: {info}"); + SysConsole.WriteLine($" Engine: {asmVersion} (TeamsISO.Engine + TeamsISO.Engine.NdiInterop)"); + SysConsole.WriteLine($" Runtime: .NET {Environment.Version}"); + SysConsole.WriteLine($" OS: {Environment.OSVersion}"); + SysConsole.WriteLine(); + + if (OperatingSystem.IsWindows()) + { + try + { + using var lf = EngineLogging.CreateConsole(LogLevel.Warning); + using var interop = new NdiInteropPInvoke(lf.CreateLogger()); + SysConsole.WriteLine($" NDI runtime: {interop.GetRuntimeVersion()}"); + SysConsole.WriteLine($" Expects prefix: {NdiVersion.ExpectedRuntimeVersionPrefix}"); + } + catch (Exception ex) + { + SysConsole.WriteLine($" NDI runtime: NOT INITIALIZED ({ex.Message})"); + } + } + else + { + SysConsole.WriteLine($" NDI runtime: requires Windows"); + } + + SysConsole.WriteLine(); + SysConsole.WriteLine($"Exit codes:"); + SysConsole.WriteLine($" 0 clean exit"); + SysConsole.WriteLine($" 1 not running on Windows"); + SysConsole.WriteLine($" 2 NDI runtime initialization failed (install from https://ndi.video/tools/)"); + SysConsole.WriteLine(); + SysConsole.WriteLine($"Wild Dragon LLC · https://wilddragon.net"); + return 0; + } + /// /// Diagnostic mode: enumerates every raw NDI source string visible to the local /// NDI finder for ~5 seconds, prints each unique one, then exits. Bypasses the diff --git a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs index 27471f8..252c2fc 100644 --- a/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs +++ b/src/TeamsISO.Engine/Pipeline/IsoPipeline.cs @@ -20,10 +20,12 @@ public sealed class IsoPipeline : IAsyncDisposable private Task? _supervisorTask; private int _consecutiveFailures; - // Refs to the currently-live receiver and sender, set by the inner loop on each - // restart. Reads via Volatile.Read are safe from any thread (UI's stats poll). + // Refs to the currently-live receiver, sender, and frame processor, set by the + // inner loop on each restart. Reads via Volatile.Read are safe from any thread + // (UI's stats poll). private NdiReceiver? _liveReceiver; private NdiSender? _liveSender; + private FrameProcessor? _liveProcessor; // Last-frame metadata, snapshotted out of the RawFrame on capture so we don't // hold a reference to the frame's pixel buffer past its useful life. Two ints @@ -33,6 +35,15 @@ public sealed class IsoPipeline : IAsyncDisposable private int _lastHeight; private DateTimeOffset? _lastReceivedAt; + // Ring buffer of the last 30 incoming-frame timestamps for live fps display. + // Updated on the receiver's capture thread (single writer) and read by the UI + // poll thread (single reader); we use a lock for the snapshot path because + // an array-of-ticks can't be torn-read atomically. + private readonly long[] _frameTimes = new long[30]; + private int _frameTimesHead; + private int _frameTimesCount; + private readonly object _frameTimesGate = new(); + public Guid ParticipantId { get; } public IsoState State { get; private set; } = IsoState.Idle; public int ConsecutiveFailures => _consecutiveFailures; @@ -47,6 +58,7 @@ public sealed class IsoPipeline : IAsyncDisposable { var receiver = Volatile.Read(ref _liveReceiver); var sender = Volatile.Read(ref _liveSender); + var processor = Volatile.Read(ref _liveProcessor); var w = Volatile.Read(ref _lastWidth); var h = Volatile.Read(ref _lastHeight); var lastAt = _lastReceivedAt; @@ -54,17 +66,68 @@ public sealed class IsoPipeline : IAsyncDisposable if (receiver is null || sender is null) return Domain.IsoHealthStats.Empty; + // FrameProcessor.Stats already aggregates FramesDropped (older frames dropped + // by the closest-frame strategy when the input channel had backlog) and + // FramesDuplicated (last-frame re-emits when no new frame arrived this tick). + var procStats = processor?.Stats; + return new Domain.IsoHealthStats( FramesIn: receiver.FramesCaptured, FramesOut: sender.FramesSent, - FramesDropped: 0, // FrameProcessor currently doesn't surface drops; wire later - FramesDuplicated: 0, // same — last-frame re-emits aren't counted yet + FramesDropped: procStats?.FramesDropped ?? 0, + FramesDuplicated: procStats?.FramesDuplicated ?? 0, LastFrameAt: lastAt, - IncomingFps: 0, // running rate computation is a follow-up + IncomingFps: ComputeFps(), IncomingWidth: w, IncomingHeight: h); } + /// + /// Computes a moving-average incoming framerate from the last N frame timestamps. + /// Rate = (count - 1) / (newest - oldest). Returns 0 if fewer than 2 frames are + /// recorded or if the window is degenerate (clock skew, all-equal timestamps). + /// + private double ComputeFps() + { + long oldest, newest; + int count; + lock (_frameTimesGate) + { + count = _frameTimesCount; + if (count < 2) return 0; + // Oldest is at the slot AFTER head when buffer is full; otherwise at index 0. + var oldestIdx = count < _frameTimes.Length + ? 0 + : _frameTimesHead; + var newestIdx = (_frameTimesHead - 1 + _frameTimes.Length) % _frameTimes.Length; + oldest = _frameTimes[oldestIdx]; + newest = _frameTimes[newestIdx]; + } + var deltaTicks = newest - oldest; + if (deltaTicks <= 0) return 0; + var seconds = deltaTicks / (double)TimeSpan.TicksPerSecond; + return (count - 1) / seconds; + } + + private void RecordFrameTimestamp(long ticks) + { + lock (_frameTimesGate) + { + _frameTimes[_frameTimesHead] = ticks; + _frameTimesHead = (_frameTimesHead + 1) % _frameTimes.Length; + if (_frameTimesCount < _frameTimes.Length) _frameTimesCount++; + } + } + + private void ResetFrameTimestamps() + { + lock (_frameTimesGate) + { + _frameTimesHead = 0; + _frameTimesCount = 0; + } + } + /// /// Test ctor. The caller supplies the inner runner directly so failures and lifetimes /// can be controlled from a unit test. @@ -105,15 +168,19 @@ public sealed class IsoPipeline : IAsyncDisposable { _runInner = ct => RunInnerPipelineAsync( config, interop, scaler, frameClock, loggerFactory, ct, - onLive: (recv, send) => + onLive: (recv, send, proc) => { Volatile.Write(ref _liveReceiver, recv); Volatile.Write(ref _liveSender, send); + Volatile.Write(ref _liveProcessor, proc); + ResetFrameTimestamps(); // fresh window on every supervisor restart }, onClear: () => { Volatile.Write(ref _liveReceiver, null); Volatile.Write(ref _liveSender, null); + Volatile.Write(ref _liveProcessor, null); + ResetFrameTimestamps(); }, onFrame: frame => { @@ -122,7 +189,9 @@ public sealed class IsoPipeline : IAsyncDisposable // late stats read can never resurrect a dropped frame's pixel buffer. Volatile.Write(ref _lastWidth, frame.Width); Volatile.Write(ref _lastHeight, frame.Height); - _lastReceivedAt = DateTimeOffset.UtcNow; + var nowTicks = DateTimeOffset.UtcNow.UtcTicks; + _lastReceivedAt = new DateTimeOffset(nowTicks, TimeSpan.Zero); + RecordFrameTimestamp(nowTicks); }); } @@ -220,7 +289,7 @@ public sealed class IsoPipeline : IAsyncDisposable IFrameClock frameClock, ILoggerFactory loggerFactory, CancellationToken ct, - Action? onLive = null, + Action? onLive = null, Action? onClear = null, Action? onFrame = null) { @@ -249,13 +318,13 @@ public sealed class IsoPipeline : IAsyncDisposable interop, config.OutputName, processedChannel.Reader, loggerFactory.CreateLogger(), config.OutputGroups); - onLive?.Invoke(receiver, sender); - var processor = new FrameProcessor( config.Settings, scaler, new SolidFrameRenderer(), frameClock, rawChannel.Reader, processedChannel.Writer, config.SlateThreshold, loggerFactory.CreateLogger()); + onLive?.Invoke(receiver, sender, processor); + var receiverTask = receiver.RunAsync(ct); var senderTask = sender.RunAsync(ct); var processorTask = ProcessorLoopAsync(processor, frameClock, ct);