feat(ui): system tray icon + WinForms/WPF namespace disambiguation

This commit is contained in:
Zac Gaetano 2026-05-10 09:41:33 -04:00
parent 832aad6a14
commit fdd1d1bbfc
3 changed files with 228 additions and 0 deletions

View file

@ -0,0 +1,14 @@
// Project-wide using aliases.
//
// Why: enabling <UseWindowsForms>true</UseWindowsForms> for the system-tray
// NotifyIcon brings in System.Windows.Forms.Application and
// System.Windows.Forms.MessageBox, both of which collide with their WPF
// counterparts (System.Windows.*). Every existing call site was written
// for the WPF type. Aliasing globally here is one declaration that keeps
// all the call sites compiling without per-file pollution.
//
// If you ever need the WinForms types, qualify them explicitly as
// `System.Windows.Forms.MessageBox` etc.
global using Application = System.Windows.Application;
global using MessageBox = System.Windows.MessageBox;

View file

@ -0,0 +1,140 @@
using System.Drawing;
using System.IO;
using System.Reflection;
using System.Runtime.Versioning;
using System.Windows;
using WinForms = System.Windows.Forms;
namespace TeamsISO.App.Services;
/// <summary>
/// Wraps a WinForms <see cref="WinForms.NotifyIcon"/> so the WPF host can
/// minimize-to-tray during long shows. Operators with a Stream Deck setup
/// often want TeamsISO running but invisible — the tray icon keeps the
/// process alive (and the engine routing live) while the window stays
/// hidden.
///
/// Lifecycle pattern: instantiate from <c>App.OnStartup</c> after the main
/// window exists; dispose from <c>App.OnExit</c>. The host hooks the main
/// window's <c>StateChanged</c> to detect minimize and toggles
/// <c>WindowState.Minimized</c> + <c>ShowInTaskbar=false</c> + <c>Hide()</c>.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class TrayIconHost : IDisposable
{
private readonly Window _mainWindow;
private readonly WinForms.NotifyIcon _notifyIcon;
private bool _enabled;
public TrayIconHost(Window mainWindow)
{
_mainWindow = mainWindow;
_notifyIcon = new WinForms.NotifyIcon
{
Text = "TeamsISO",
Icon = LoadEmbeddedIcon(),
Visible = false,
};
_notifyIcon.DoubleClick += (_, _) => RestoreFromTray();
_notifyIcon.ContextMenuStrip = BuildMenu();
}
/// <summary>
/// Toggles the minimize-to-tray behavior. When on, minimizing the window
/// hides it and shows a tray icon; when off, minimize is normal Windows
/// behavior. Read by the operator's checkbox in DISPLAY settings; the
/// setting persists via <see cref="UIPreferences"/>.
/// </summary>
public bool Enabled
{
get => _enabled;
set
{
if (_enabled == value) return;
_enabled = value;
if (value)
{
_mainWindow.StateChanged += OnMainWindowStateChanged;
}
else
{
_mainWindow.StateChanged -= OnMainWindowStateChanged;
// If we're currently minimized + hidden, restore so the user
// doesn't lose the window when they disable the setting.
RestoreFromTray();
_notifyIcon.Visible = false;
}
}
}
private void OnMainWindowStateChanged(object? sender, EventArgs e)
{
if (_mainWindow.WindowState != WindowState.Minimized) return;
// Hide from taskbar + hide the window, show the tray icon.
_mainWindow.ShowInTaskbar = false;
_mainWindow.Hide();
_notifyIcon.Visible = true;
_notifyIcon.ShowBalloonTip(
timeout: 1500,
tipTitle: "TeamsISO is still running",
tipText: "Engine + control surface are live. Double-click the tray icon to restore the window.",
tipIcon: WinForms.ToolTipIcon.Info);
}
private void RestoreFromTray()
{
_mainWindow.Show();
_mainWindow.WindowState = WindowState.Normal;
_mainWindow.ShowInTaskbar = true;
_mainWindow.Activate();
_notifyIcon.Visible = false;
}
private WinForms.ContextMenuStrip BuildMenu()
{
var menu = new WinForms.ContextMenuStrip();
menu.Items.Add("Show TeamsISO", null, (_, _) => RestoreFromTray());
menu.Items.Add("-");
menu.Items.Add("Stop all ISOs", null, (_, _) =>
{
// Reach into the VM via the main window. Using string-keyed
// command lookup would be more decoupled but adds overhead.
if (_mainWindow.DataContext is ViewModels.MainViewModel vm
&& vm.StopAllIsosCommand.CanExecute(null))
{
vm.StopAllIsosCommand.Execute(null);
}
});
menu.Items.Add("-");
menu.Items.Add("Exit TeamsISO", null, (_, _) => System.Windows.Application.Current.Shutdown());
return menu;
}
/// <summary>
/// Load the bundled teamsiso.ico from this assembly's resources. We use
/// the embedded resource rather than the file-system path because the
/// app may be run from any CWD (via the MSI install or a developer dotnet run).
/// </summary>
private static Icon LoadEmbeddedIcon()
{
try
{
var asm = Assembly.GetExecutingAssembly();
var uri = new Uri("pack://application:,,,/Assets/teamsiso.ico");
using var stream = System.Windows.Application.GetResourceStream(uri)?.Stream;
if (stream is not null) return new Icon(stream);
}
catch
{
// Fall through to the OS default
}
return SystemIcons.Application;
}
public void Dispose()
{
try { _mainWindow.StateChanged -= OnMainWindowStateChanged; } catch { /* ignore */ }
try { _notifyIcon.Visible = false; } catch { /* ignore */ }
try { _notifyIcon.Dispose(); } catch { /* ignore */ }
}
}

View file

@ -0,0 +1,74 @@
using System.IO;
using System.Text.Json;
namespace TeamsISO.App.Services;
/// <summary>
/// Persistent UI-side toggles that don't belong in <see cref="TeamsISO.Engine.Persistence.ConfigStore"/>
/// (which is the engine's domain model — framerate, NDI groups, ISO assignments).
///
/// Each toggle is a property on a single record persisted as JSON at
/// <c>%LOCALAPPDATA%\TeamsISO\ui-prefs.json</c>. Defaults match the original
/// in-memory behavior: HideLocalSelf=true (filter the operator's own preview
/// out of the participants list) and AutoDisableOnDeparture=false (a participant
/// going offline doesn't tear down their pipeline by default — operators
/// usually want to keep the routing in case they reconnect).
///
/// Centralizing these here means the settings VM doesn't have to plumb
/// individual Set methods to dedicated services for every new bool.
/// </summary>
public static class UIPreferences
{
private static readonly object _gate = new();
private static string PrefsPath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO", "ui-prefs.json");
/// <summary>
/// Sort modes for the participants DataGrid. <see cref="JoinOrder"/> is the default
/// and matches the engine's discovery order (operators with custom Stream Deck
/// layouts sometimes prefer Alphabetical for stability across meetings).
/// </summary>
public enum SortMode { JoinOrder, Alphabetical, OnlineFirst }
/// <summary>The on-disk shape. New fields added here become opt-in for older files via default values.</summary>
public sealed record Prefs(
bool HideLocalSelf = true,
bool AutoDisableOnDeparture = false,
SortMode ParticipantSort = SortMode.JoinOrder,
bool MinimizeToTray = false);
public static Prefs Load()
{
try
{
if (!File.Exists(PrefsPath)) return new Prefs();
var json = File.ReadAllText(PrefsPath);
return JsonSerializer.Deserialize<Prefs>(json) ?? new Prefs();
}
catch
{
return new Prefs();
}
}
public static void Save(Prefs prefs)
{
try
{
lock (_gate)
{
var dir = Path.GetDirectoryName(PrefsPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var json = JsonSerializer.Serialize(prefs, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(PrefsPath, json);
}
}
catch
{
// Disk full / permission denied — in-memory state still holds for this session.
}
}
}