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; /// /// Wraps a WinForms 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 App.OnStartup after the main /// window exists; dispose from App.OnExit. The host hooks the main /// window's StateChanged to detect minimize and toggles /// WindowState.Minimized + ShowInTaskbar=false + Hide(). /// [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(); } /// /// 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 . /// 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; } /// /// 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). /// 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 */ } } }