diff --git a/TRAY_MENU_CRASH_FIX.md b/TRAY_MENU_CRASH_FIX.md new file mode 100644 index 0000000..9fd0c56 --- /dev/null +++ b/TRAY_MENU_CRASH_FIX.md @@ -0,0 +1,139 @@ +# WinUI 3 Tray Menu MenuFlyout Crash - Solution Documentation + +## Problem Summary + +The WinUI 3 tray application was experiencing intermittent crashes when clicking the tray icon to show the menu. The crashes occurred in the native WinUI windowing layer (`Microsoft.UI.Windowing.Core.dll`) and bypassed all .NET exception handlers. + +### Crash Characteristics +- **Faulting module**: `Microsoft.UI.Windowing.Core.dll` (version 10.0.27108.1025) +- **Exception code**: `0xe0464645` (CLR exception marker) +- **Platform**: Windows 11 ARM64 +- **Windows App SDK**: 1.8.250906003 +- **Timing**: Crashes became more likely after idle periods or after opening/closing other windows + +## Root Cause + +WinUI 3's `MenuFlyout.ShowAt()` requires a valid UIElement with proper visual tree context to display correctly. When attempting to show a MenuFlyout directly from a TrayIcon event handler: + +1. The TrayIcon doesn't provide a proper WinUI UIElement context +2. MenuFlyout cannot find a valid visual tree to anchor to +3. This causes native-level crashes in the windowing layer + +### Research References + +Based on community research: +- [WinUIEx TrayIcon issues](https://github.com/dotMorten/WinUIEx/issues/244) - MenuFlyout positioning and stability issues +- [MenuFlyout crash in Windows App SDK](https://github.com/microsoft/microsoft-ui-xaml/issues/8954) - Known MenuFlyout crash issues +- [Stack Overflow discussion](https://stackoverflow.com/questions/79008202/) - MenuFlyout without Window or UIElement + +## Solution: Invisible Anchor Window + +Instead of showing the MenuFlyout directly from the tray icon, we now use a small invisible anchor window that provides the required UIElement context. + +### Implementation + +1. **TrayMenuAnchorWindow** (`Windows/TrayMenuAnchorWindow.xaml[.cs]`) + - A minimal 1x1 pixel window with a transparent Grid + - Configured to not appear in task switchers + - Positioned at the cursor location when showing the menu + - Reused across menu invocations to avoid creation/destruction overhead + +2. **App.xaml.cs Updates** + - Maintains strong reference to anchor window (`_trayMenuAnchor`) + - `ShowTrayMenuFlyoutWithAnchor()` method positions anchor and shows flyout + - Anchor window is created once on first use and kept alive + +### Key Code Pattern + +```csharp +// In App.xaml.cs +private TrayMenuAnchorWindow? _trayMenuAnchor; // Keep-alive for GC prevention + +private void ShowTrayMenuFlyoutWithAnchor() +{ + // Create anchor window once, reuse thereafter + if (_trayMenuAnchor == null) + { + _trayMenuAnchor = new TrayMenuAnchorWindow(); + } + + // Position at cursor + if (GetCursorPos(out POINT cursorPos)) + { + _trayMenuAnchor.PositionAtCursor(cursorPos.X, cursorPos.Y); + } + + // Show flyout anchored to window + var flyout = BuildTrayMenuFlyout(); + _trayMenuAnchor.ShowFlyout(flyout); +} +``` + +## Why This Works + +1. **Valid Visual Tree**: The anchor window provides a proper WinUI visual tree for MenuFlyout to attach to +2. **Proper Lifecycle**: Window is kept alive to prevent garbage collection issues +3. **Correct Positioning**: Window is positioned at cursor, so MenuFlyout appears in the right location +4. **Reusability**: Single window is reused, avoiding creation/destruction overhead that could trigger crashes + +## Alternatives Considered + +### 1. Custom Window Popup (Original Approach) +- **Tried**: Creating/destroying `TrayMenuWindow` on each click +- **Problem**: Rapid window creation/destruction triggered native crashes +- **Result**: Abandoned + +### 2. Window Reuse Pattern +- **Tried**: Hide() instead of Close() on deactivation +- **Problem**: Black square appeared instead of menu content +- **Result**: Abandoned + +### 3. Direct MenuFlyout Assignment +- **Tried**: `e.Flyout = BuildTrayMenuFlyout()` in tray event handlers +- **Problem**: No valid UIElement anchor, causing crashes +- **Result**: This was the problematic approach we replaced + +### 4. Native Win32 Popup Menu +- **Considered**: Using `TrackPopupMenu` Win32 API +- **Decision**: Rejected - would lose WinUI styling and XAML flexibility +- **Note**: Could be future fallback if anchor window approach fails + +## Testing Recommendations + +Since this is a race condition / timing-sensitive crash, testing should include: + +1. **Basic functionality**: Click tray icon multiple times in succession +2. **Idle scenario**: Leave app idle for several minutes, then click tray icon +3. **Window interaction**: Open/close Settings window, then click tray icon +4. **Rapid clicking**: Click tray icon rapidly to test window reuse +5. **Long session**: Run app for extended period with periodic tray clicks + +## Future Considerations + +### Monitoring +- Watch for any new crash reports in `%LOCALAPPDATA%\OpenClawTray\crash.log` +- Monitor Windows Event Viewer for exceptions in `Microsoft.UI.Windowing.Core.dll` + +### Potential Improvements +1. **Alternative Libraries**: Consider switching to H.NotifyIcon if issues persist +2. **SDK Updates**: Monitor Windows App SDK releases for native fixes +3. **Telemetry**: Add success/failure tracking for menu display operations + +## Related Issues + +- GitHub Issue: [link to be added when issue is created] +- WinUI GitHub: https://github.com/microsoft/microsoft-ui-xaml/issues/8954 +- WinUIEx GitHub: https://github.com/dotMorten/WinUIEx/issues/244 + +## Credits + +Solution based on community research and best practices from: +- WinUIEx documentation and issue discussions +- Microsoft Learn WinUI 3 documentation +- Community blog posts on WinUI 3 tray icon implementations +- Stack Overflow discussions on MenuFlyout anchoring + +--- + +**Last Updated**: 2026-01-30 +**Author**: GitHub Copilot (with human review) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 95eed74..036a320 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -13,6 +13,7 @@ using System.IO; using System.IO.Pipes; using System.Linq; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Updatum; @@ -59,6 +60,7 @@ public partial class App : Application private StatusDetailWindow? _statusDetailWindow; private NotificationHistoryWindow? _notificationHistoryWindow; private TrayMenuWindow? _trayMenuWindow; + private TrayMenuAnchorWindow? _trayMenuAnchor; // Keep-alive anchor for MenuFlyout private string[]? _startupArgs; private static readonly string CrashLogPath = Path.Combine( @@ -187,14 +189,67 @@ private void InitializeTrayIcon() private void OnTrayIconSelected(TrayIcon sender, TrayIconEventArgs e) { - // Left-click: show flyout menu (avoids window creation crash) - e.Flyout = BuildTrayMenuFlyout(); + // Left-click: show flyout menu using anchor window to prevent crash + ShowTrayMenuFlyoutWithAnchor(); } private void OnTrayContextMenu(TrayIcon sender, TrayIconEventArgs e) { - // Right-click: show flyout menu - e.Flyout = BuildTrayMenuFlyout(); + // Right-click: show flyout menu using anchor window to prevent crash + ShowTrayMenuFlyoutWithAnchor(); + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetCursorPos(out POINT lpPoint); + + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X; + public int Y; + } + + /// + /// Shows the tray menu using an invisible anchor window to prevent crashes. + /// + /// WinUI 3 MenuFlyout requires a valid UIElement anchor. TrayIcon doesn't provide + /// proper context, causing crashes. This method uses a tiny invisible window positioned + /// at the cursor as the anchor point. + /// + /// See TRAY_MENU_CRASH_FIX.md for detailed explanation. + /// + private void ShowTrayMenuFlyoutWithAnchor() + { + try + { + // Ensure anchor window exists (created once, reused) + if (_trayMenuAnchor == null) + { + _trayMenuAnchor = new TrayMenuAnchorWindow(); + } + + // Get cursor position for positioning the anchor window + if (GetCursorPos(out POINT cursorPos)) + { + // Position the tiny anchor window at cursor location + _trayMenuAnchor.PositionAtCursor(cursorPos.X, cursorPos.Y); + } + else + { + // Fallback: position offscreen if we can't get cursor + _trayMenuAnchor.PositionOffscreen(); + } + + // Build and show the flyout anchored to the window + var flyout = BuildTrayMenuFlyout(); + _trayMenuAnchor.ShowFlyout(flyout); + } + catch (Exception ex) + { + LogCrash("ShowTrayMenuFlyoutWithAnchor", ex); + Logger.Error($"Failed to show tray menu: {ex.Message}"); + } } private MenuFlyout BuildTrayMenuFlyout() diff --git a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuAnchorWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuAnchorWindow.xaml new file mode 100644 index 0000000..8d4b1a0 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuAnchorWindow.xaml @@ -0,0 +1,9 @@ + + + + + + diff --git a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuAnchorWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuAnchorWindow.xaml.cs new file mode 100644 index 0000000..b7b1b6a --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuAnchorWindow.xaml.cs @@ -0,0 +1,104 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System; +using System.Runtime.InteropServices; + +namespace OpenClawTray.Windows; + +/// +/// A minimal, invisible window used as an anchor point for displaying MenuFlyout from tray icon. +/// +/// BACKGROUND: +/// WinUI 3's MenuFlyout.ShowAt() requires a valid UIElement with proper visual tree context. +/// TrayIcon doesn't provide this context, causing crashes in Microsoft.UI.Windowing.Core.dll. +/// +/// SOLUTION: +/// This 1x1 pixel window is positioned at the cursor and provides the required anchor. +/// It's created once and reused to avoid creation/destruction overhead. +/// A strong reference is maintained in App to prevent garbage collection. +/// +/// See TRAY_MENU_CRASH_FIX.md for detailed documentation. +/// +public sealed partial class TrayMenuAnchorWindow : Window +{ + [DllImport("user32.dll")] + private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); + + [DllImport("user32.dll")] + private static extern int GetSystemMetrics(int nIndex); + + private const int SM_CXSCREEN = 0; + private const int SM_CYSCREEN = 1; + private const uint SWP_NOACTIVATE = 0x0010; + private const uint SWP_SHOWWINDOW = 0x0040; + private static readonly IntPtr HWND_TOPMOST = new IntPtr(-1); + + public TrayMenuAnchorWindow() + { + InitializeComponent(); + + // Configure window to be invisible but present + this.AppWindow.IsShownInSwitchers = false; + this.ExtendsContentIntoTitleBar = true; + this.SystemBackdrop = null; // No backdrop effect + + // Set to 1x1 size - minimal footprint + this.AppWindow.Resize(new Windows.Graphics.SizeInt32(1, 1)); + } + + /// + /// Positions the anchor window at the cursor location (for tray menu positioning) + /// + public void PositionAtCursor(int x, int y) + { + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + + // Position window at cursor location, topmost, without activating + SetWindowPos(hwnd, HWND_TOPMOST, x, y, 1, 1, SWP_NOACTIVATE | SWP_SHOWWINDOW); + } + + /// + /// Positions the anchor window off-screen (hidden but still valid for anchoring) + /// + public void PositionOffscreen() + { + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + + // Move far off-screen where it won't be visible + int screenWidth = GetSystemMetrics(SM_CXSCREEN); + int screenHeight = GetSystemMetrics(SM_CYSCREEN); + + SetWindowPos(hwnd, HWND_TOPMOST, screenWidth + 100, screenHeight + 100, 1, 1, SWP_NOACTIVATE | SWP_SHOWWINDOW); + } + + /// + /// Shows the flyout anchored to this window's root grid + /// + public void ShowFlyout(MenuFlyout flyout) + { + if (flyout == null) + throw new ArgumentNullException(nameof(flyout)); + + // Ensure the flyout closes when it loses focus + flyout.Closed += (s, e) => this.Hide(); + + // Show flyout anchored to the root grid of this window + flyout.ShowAt(RootGrid); + } + + /// + /// Hides the window (keeps it in memory for reuse) + /// + public void Hide() + { + try + { + // Move offscreen instead of truly hiding to keep it valid as an anchor + PositionOffscreen(); + } + catch + { + // Ignore errors during hide + } + } +}