dragon-iso/src/Dragon-ISO.App/Services/TrayIconHost.cs
Zac Gaetano edb7975039
Some checks failed
CI / build-and-test (push) Failing after 29s
Release / build-msi (push) Failing after 21s
rebrand: rename all TeamsISO source paths to Dragon-ISO
- Rename solution files: TeamsISO.sln/slnf -> Dragon-ISO.sln/slnf
- Rename all src/TeamsISO.* directories and project files
  to src/Dragon-ISO.* equivalents
- Update .gitignore to exclude build/test output logs
- Update ci.yml, CHANGELOG.md, build-and-test.ps1, docs references

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 11:18:27 -04:00

140 lines
4.9 KiB
C#

using System.Drawing;
using System.IO;
using System.Reflection;
using System.Runtime.Versioning;
using System.Windows;
using WinForms = System.Windows.Forms;
namespace DragonISO.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 Dragon-ISO 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 = "Dragon-ISO",
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: "Dragon-ISO 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 Dragon-ISO", 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 Dragon-ISO", null, (_, _) => System.Windows.Application.Current.Shutdown());
return menu;
}
/// <summary>
/// Load the bundled DragonISO.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/DragonISO.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 */ }
}
}