using System.Drawing; using System.Runtime.InteropServices; using H.NotifyIcon.Core; using LlamaApp.Common; using Microsoft.UI.Dispatching; namespace LlamaApp; /// /// Owns the system-tray (notification area) icon for LlamaApp. /// /// Left-click makes the flyout visible — /// a borderless Mica panel pinned to the bottom-right of the screen (just above /// the taskbar, next to the tray), mirroring the macOS menu-bar app's popup on /// Windows 11. The flyout auto-hides when it loses focus (clicking elsewhere), /// and a short deactivation grace period keeps a left-click that *dismissed* it /// from bouncing it straight back open. /// /// Right-click shows a native Win32 context menu with /// Open and Exit — the standard Windows tray affordance. /// /// This uses the low-level H.NotifyIcon.Core API directly (rather than /// the optional H.NotifyIcon.WinUI XAML control) so we only depend on /// the already-referenced core package. /// /// Why the base and not /// TrayIconWithContextMenu: Shell_NotifyIcon(NIM_ADD) fails /// while the notification area doesn't exist yet — e.g. when the app /// auto-starts at logon before explorer.exe is up, or while explorer is /// restarting. TrayIconWithContextMenu.Create() runs on its own /// background thread (for the context-menu message loop), so that failure is /// an exception thrown where no app code can catch it — it takes the whole /// process down (InvalidOperationException: TryCreate failed, CLR crash /// 0xe0434352). The base runs synchronously /// on the calling thread, so the same failure is caught and retried here /// () and can never crash the app. /// The right-click menu — the only thing the subclass would have added — is /// shown manually via the public API /// (). The message window lives on the UI thread, /// pumped by the WinUI message loop — the same pattern the library's own /// WPF/WinUI integrations use. /// /// Explorer restarts wipe every tray icon; the taskbar then /// broadcasts TaskbarCreated and each app must re-add its icon. /// H.NotifyIcon 2.4.1 raises the event but leaves the re-adding to the app /// (). /// internal sealed class TrayIconManager : IDisposable { private readonly MainWindow _window; private readonly DispatcherQueue _dispatcher; private readonly Icon _icon; private readonly TrayIcon _trayIcon; private readonly PopupMenu _contextMenu; private bool _disposed; private int _recreating; public TrayIconManager(MainWindow window) { _window = window; _dispatcher = DispatcherQueue.GetForCurrentThread() ?? throw new InvalidOperationException( "TrayIconManager must be constructed on the UI thread."); // The Assets folder is copied next to the executable, so resolve the // icon relative to the app's base directory rather than the CWD. var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "llama.ico"); _icon = new Icon(iconPath); _trayIcon = new TrayIcon("LlamaApp") { ToolTip = "LlamaApp", Icon = _icon.Handle, }; _trayIcon.MessageWindow.SubscribeToMouseEventReceived(OnMouseEvent); _trayIcon.MessageWindow.SubscribeToTaskbarCreated(OnTaskbarCreated); // Shown manually on right-click (see ShowContextMenu). _contextMenu = BuildContextMenu(); // Create the icon, retrying until the shell accepts it (see the class // remarks). Fire-and-forget: attempts are separated by awaits that // yield the UI thread, so a slow logon doesn't block startup, and the // rest of the app (flyout, overlay, hotkey) works without the icon // meanwhile. _ = CreateTrayIconWithRetryAsync("startup"); } /// /// Calls the real on the UI thread, retrying /// on failure. Shell_NotifyIcon(NIM_ADD) fails while the /// notification area doesn't exist yet (logon race, explorer restart), so /// the first attempts may fail — the exception is caught HERE (unlike with /// TrayIconWithContextMenu, whose background-thread creation made /// it an unhandleable process crash) and retried for ~60s before giving up /// gracefully: a machine without an interactive shell (kiosk, Server Core) /// just runs without a tray icon. /// private async Task CreateTrayIconWithRetryAsync(string reason) { // One loop at a time: a taskbar restart arriving while initial retries // are in flight just lets the in-flight loop's next attempt re-add. if (Interlocked.Exchange(ref _recreating, 1) == 1) return; try { for (var attempt = 1; attempt <= 60 && !_disposed; attempt++) { try { // TryRemove is a no-op before the first successful create; // afterwards it resets IsCreated (best-effort delete — the // icon is usually already gone) so Create re-adds it. That // makes this same loop serve both initial creation and the // taskbar-restart re-add. _trayIcon.TryRemove(); _trayIcon.Create(); Log.Info(attempt > 1 ? $"tray icon created ({reason}, after {attempt} attempts)" : "tray icon created"); return; } catch (ObjectDisposedException) { return; // shutting down — stop quietly } catch (Exception ex) { if (attempt is 1 or 10 or 30) Log.Warn(ex, $"tray icon creation failed ({reason}, attempt {attempt}); retrying"); await Task.Delay(1000); } } if (!_disposed) Log.Error($"tray icon could not be created ({reason}); running without a tray icon"); } finally { Interlocked.Exchange(ref _recreating, 0); } } /// /// Re-adds the icon after explorer (re)created the taskbar — every tray /// icon is wiped at that point. The broadcast can arrive while the /// notification area is still initializing, so this goes through the same /// retrying path as initial creation. /// private void OnTaskbarCreated(object? sender, EventArgs e) { Log.Info("taskbar (re)created; re-adding tray icon"); _ = CreateTrayIconWithRetryAsync("taskbar restart"); } /// /// The minimal native context menu shown on right-click — the standard /// Windows tray affordance: Open reveals the flyout, Exit /// quits the app. /// private PopupMenu BuildContextMenu() { var menu = new PopupMenu(); menu.Items.Add(new PopupMenuItem("Open", (_, _) => Enqueue(() => _window.ShowAsFlyout(CursorPoint)))); menu.Items.Add(new PopupMenuSeparator()); menu.Items.Add(new PopupMenuItem("Exit", (_, _) => Enqueue(RequestExit))); return menu; } /// /// The most recent tray-icon click point, captured from the mouse event so /// the Open context-menu item (whose callback receives no position) /// can still hand a reference point to /// for monitor selection. /// private Point CursorPoint { get; set; } private void OnMouseEvent(object? sender, MessageWindow.MouseEventReceivedEventArgs e) { // The message window lives on the UI thread, so this already runs // there — no marshaling needed. // Record the click location for every button so the Open entry works // regardless of which click preceded the context menu. CursorPoint = e.Point; switch (e.MouseEvent) { case MouseEvent.IconLeftMouseUp: // Left-click makes the flyout visible. If it was *just* // dismissed by this very click (clicking the icon deactivates // the open flyout, which auto-hides it), the grace period keeps // it closed instead of bouncing it back open. if (_window.WasJustHiddenByDeactivate) return; _window.ShowAsFlyout(e.Point); break; case MouseEvent.IconRightMouseUp: ShowContextMenu(); break; } } /// /// Shows the native context menu at the cursor — exactly what /// TrayIconWithContextMenu does internally. The owner window must /// be moved to the foreground first, otherwise the menu doesn't dismiss /// when the user clicks elsewhere. blocks in /// a modal menu loop (TPM_RETURNCMD) until an item is chosen or the /// menu is dismissed; the chosen item's callback runs before it returns. /// private void ShowContextMenu() { if (!_trayIcon.IsCreated) return; var pos = CurrentCursorPoint(); _ = SetForegroundWindow(_trayIcon.WindowHandle); _contextMenu.Show(_trayIcon.WindowHandle, pos.X, pos.Y); } /// /// Shows the flyout pinned to the monitor the cursor is currently on — for /// activations that carry no position of their own (toast-notification /// clicks, single-instance redirects). /// public void ShowFlyout() { Enqueue(() => _window.ShowAsFlyout(CurrentCursorPoint())); } private static Point CurrentCursorPoint() { GetCursorPos(out var p); return new Point(p.X, p.Y); } [StructLayout(LayoutKind.Sequential)] private struct POINT { public int X, Y; } [DllImport("user32.dll", ExactSpelling = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool GetCursorPos(out POINT lpPoint); [DllImport("user32.dll", ExactSpelling = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool SetForegroundWindow(nint hWnd); /// /// Shuts the app down: removes the tray icon (so it doesn't linger in the /// notification area), then closes the window, and exits. /// public void RequestExit() { Log.Info("app exit requested"); // Unregister the toast-notification activator before going down. Notifications.Unregister(); // Stop the llama server first so it doesn't outlive the app (and leave // the port bound). StopServer only kills a MANAGED server — one an app // instance started, tracked via the .llama.pid file (crash-safe); a // server the user started manually is left running. Best-effort: // Environment.Exit below guarantees the app itself terminates regardless. try { Llama.LlamaManager.Shared.StopServer(); } catch (Exception ex) { Log.Warn(ex, "best-effort server stop on exit failed"); } // Removing the tray icon first avoids a stray icon lingering in the // notification area after the process is gone. Letting the window close // normally (AllowClose) bypasses its "hide instead of close" guard; // Environment.Exit guarantees the process terminates even though the // tray message window is still alive. try { _trayIcon.Dispose(); _icon.Dispose(); } catch (Exception ex) { // Best-effort cleanup during shutdown. Log.Warn(ex, "best-effort tray icon dispose on exit failed"); } _window.AllowClose = true; try { _window.Close(); } catch { /* best-effort */ } Environment.Exit(0); } /// /// Runs on the UI thread. Public entry points /// (toast-notification clicks, single-instance redirects) can arrive on /// any thread; marshaling keeps all WinUI window access on the UI thread. /// private void Enqueue(Action action) { if (_dispatcher.HasThreadAccess) action(); else _dispatcher.TryEnqueue(() => action()); } public void Dispose() { if (_disposed) return; _disposed = true; _trayIcon.Dispose(); _icon.Dispose(); } }