Merge remote-tracking branch 'origin/winui-test'

This commit is contained in:
Scott Hanselman 2026-01-29 15:17:03 -08:00
commit 8e4be37f45
31 changed files with 3658 additions and 1 deletions

78
XAML_COMPILER_BUG.md Normal file
View File

@ -0,0 +1,78 @@
# WinUI 3 XAML Compiler Bug Report: Silent Crash on Type Mismatch
## Summary
The WinUI 3 XAML compiler (`XamlCompiler.exe`) crashes with **exit code 1 and produces no error message** when the XAML root element type doesn't match the code-behind base class. This creates a frustrating debugging experience as developers receive no indication of what's wrong.
## Reproduction
**Minimal repro:** `D:\github\XamlCompilerCrashRepro.zip` (6 files, ~2KB)
### Steps
1. Create a WinUI 3 project with WinUIEx package
2. Create `MainWindow.xaml` using `<Window>` as root:
```xml
<Window x:Class="CrashRepro.MainWindow" ...>
```
3. Create `MainWindow.xaml.cs` inheriting from `WindowEx`:
```csharp
public sealed partial class MainWindow : WindowEx { ... }
```
4. Run `dotnet build`
### Expected
Clear error message like: *"Type mismatch: XAML root element 'Window' doesn't match code-behind base class 'WinUIEx.WindowEx'"*
### Actual
```
error MSB3073: The command "XamlCompiler.exe input.json output.json" exited with code 1.
```
No `output.json` file is created. No additional error details.
## Environment
| Component | Version |
|-----------|---------|
| Windows App SDK | 1.6.250602001 (also 1.8.x) |
| WinUIEx | 2.5.0+ |
| .NET | 9.0 |
| OS | Windows 11 (ARM64 and x64) |
## Workaround
Ensure XAML and code-behind types match:
**Option A:** Both use `Window`
```xml
<Window x:Class="...">
```
```csharp
public partial class MainWindow : Window
```
**Option B:** Both use `WindowEx`
```xml
<winex:WindowEx x:Class="..." xmlns:winex="using:WinUIEx">
```
```csharp
public partial class MainWindow : WindowEx
```
## Impact
- **Severity:** Medium-High (blocks development, wastes debugging time)
- **Discoverability:** Very poor (no error message)
- **Affected scenarios:** Any derived Window type (WindowEx, custom base classes)
## Related Issues
This may be related to existing XAML compiler issues around error reporting:
- microsoft/microsoft-ui-xaml#10027
- microsoft/microsoft-ui-xaml#9813
## Suggested Fix
The XAML compiler should:
1. Detect when the partial class base type differs from the XAML root element
2. Produce a clear error message with file/line information
3. Write error details to `output.json` even on failure

1
input.json.bak Normal file

File diff suppressed because one or more lines are too long

View File

@ -5,5 +5,6 @@
</Project>
<Project Path="src/Moltbot.Shared/Moltbot.Shared.csproj" />
<Project Path="src/Moltbot.Tray/Moltbot.Tray.csproj" />
<Project Path="src/Moltbot.Tray.WinUI/Moltbot.Tray.WinUI.csproj" />
</Folder>
</Solution>

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFrameworks>net9.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Moltbot.Shared</RootNamespace>

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<Application
x:Class="MoltbotTray.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:MoltbotTray">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
<!-- Custom Moltbot Theme Resources -->
<SolidColorBrush x:Key="LobsterAccentBrush" Color="#E74C3C" />
<SolidColorBrush x:Key="LobsterAccentBrushHover" Color="#C0392B" />
<!-- Custom Button Style -->
<Style x:Key="AccentButtonStyle" TargetType="Button">
<Setter Property="Background" Value="{StaticResource LobsterAccentBrush}" />
<Setter Property="Foreground" Value="White" />
<Setter Property="CornerRadius" Value="4" />
</Style>
</ResourceDictionary>
</Application.Resources>
</Application>

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,33 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Updatum;
namespace MoltbotTray.Dialogs;
public sealed class DownloadProgressDialog
{
private Window? _window;
private readonly UpdatumManager? _updater;
public DownloadProgressDialog(UpdatumManager updater)
{
_updater = updater;
}
public void ShowAsync()
{
_window = new Window { Title = "Downloading Update..." };
var panel = new StackPanel { Padding = new Thickness(20) };
var progressText = new TextBlock { Text = "Downloading update...", Margin = new Thickness(0, 0, 0, 10) };
var progressBar = new ProgressBar { IsIndeterminate = true };
panel.Children.Add(progressText);
panel.Children.Add(progressBar);
_window.Content = panel;
_window.Activate();
}
public void Close() => _window?.Close();
}

View File

@ -0,0 +1,139 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Moltbot.Shared;
using MoltbotTray.Helpers;
using MoltbotTray.Services;
using System;
using System.Threading.Tasks;
using WinUIEx;
namespace MoltbotTray.Dialogs;
/// <summary>
/// Quick send dialog for sending messages to Moltbot.
/// </summary>
public sealed class QuickSendDialog : WindowEx
{
private readonly MoltbotGatewayClient _client;
private readonly TextBox _messageTextBox;
private readonly Button _sendButton;
private readonly TextBlock _statusText;
private bool _isSending;
public QuickSendDialog(MoltbotGatewayClient client, string? prefillMessage = null)
{
_client = client;
// Window setup
Title = "Quick Send — Moltbot";
this.SetWindowSize(400, 200);
this.CenterOnScreen();
this.SetIcon(IconHelper.GetStatusIconPath(ConnectionStatus.Connected));
// Build UI programmatically (simple dialog)
var root = new StackPanel
{
Spacing = 12,
Padding = new Thickness(24)
};
var header = new TextBlock
{
Text = "📤 Quick Send",
Style = (Style)Application.Current.Resources["SubtitleTextBlockStyle"]
};
root.Children.Add(header);
_messageTextBox = new TextBox
{
PlaceholderText = "Type your message...",
AcceptsReturn = false,
Text = prefillMessage ?? ""
};
_messageTextBox.KeyDown += OnKeyDown;
root.Children.Add(_messageTextBox);
var buttonPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right
};
_statusText = new TextBlock
{
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 12, 0)
};
buttonPanel.Children.Add(_statusText);
var cancelButton = new Button { Content = "Cancel" };
cancelButton.Click += (s, e) => Close();
buttonPanel.Children.Add(cancelButton);
_sendButton = new Button
{
Content = "Send",
Style = (Style)Application.Current.Resources["AccentButtonStyle"]
};
_sendButton.Click += OnSendClick;
buttonPanel.Children.Add(_sendButton);
root.Children.Add(buttonPanel);
Content = root;
// Focus the text box when shown
Activated += (s, e) => _messageTextBox.Focus(FocusState.Programmatic);
}
private async void OnKeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Key == global::Windows.System.VirtualKey.Enter && !_isSending)
{
e.Handled = true;
await SendMessageAsync();
}
else if (e.Key == global::Windows.System.VirtualKey.Escape)
{
Close();
}
}
private async void OnSendClick(object sender, RoutedEventArgs e)
{
await SendMessageAsync();
}
private async Task SendMessageAsync()
{
var message = _messageTextBox.Text?.Trim();
if (string.IsNullOrEmpty(message)) return;
_isSending = true;
_sendButton.IsEnabled = false;
_messageTextBox.IsEnabled = false;
_statusText.Text = "Sending...";
try
{
await _client.SendChatMessageAsync(message);
Logger.Info($"Quick send: {message}");
Close();
}
catch (Exception ex)
{
Logger.Error($"Quick send failed: {ex.Message}");
_statusText.Text = "❌ Failed";
_sendButton.IsEnabled = true;
_messageTextBox.IsEnabled = true;
_isSending = false;
}
}
public new void ShowAsync()
{
Activate();
}
}

View File

@ -0,0 +1,103 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System;
using System.Threading.Tasks;
namespace MoltbotTray.Dialogs;
public enum UpdateDialogResult
{
Download,
Skip,
RemindLater
}
/// <summary>
/// Dialog showing available update with release notes.
/// </summary>
public sealed class UpdateDialog
{
private readonly string _version;
private readonly string _changelog;
private ContentDialog? _dialog;
public UpdateDialogResult Result { get; private set; } = UpdateDialogResult.RemindLater;
public UpdateDialog(string version, string changelog)
{
_version = version;
_changelog = changelog;
}
public async Task<UpdateDialogResult> ShowAsync()
{
// Create a temporary window to host the dialog
var window = new Window();
window.Content = new Grid();
window.Activate();
// Build dialog content
var content = new StackPanel
{
Spacing = 16,
MaxWidth = 450
};
// Version header
content.Children.Add(new TextBlock
{
Text = $"🎉 Version {_version} is available!",
Style = (Style)Application.Current.Resources["SubtitleTextBlockStyle"]
});
// Current version
var currentVersion = typeof(UpdateDialog).Assembly.GetName().Version?.ToString() ?? "Unknown";
content.Children.Add(new TextBlock
{
Text = $"Current version: {currentVersion}",
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]
});
// Changelog
content.Children.Add(new TextBlock
{
Text = "What's New:",
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold
});
var changelogScroll = new ScrollViewer
{
MaxHeight = 200,
Content = new TextBlock
{
Text = _changelog,
TextWrapping = TextWrapping.Wrap
}
};
content.Children.Add(changelogScroll);
// Create dialog
_dialog = new ContentDialog
{
Title = "Update Available",
Content = content,
PrimaryButtonText = "Download & Install",
SecondaryButtonText = "Remind Me Later",
CloseButtonText = "Skip This Version",
DefaultButton = ContentDialogButton.Primary,
XamlRoot = window.Content.XamlRoot
};
var dialogResult = await _dialog.ShowAsync();
window.Close();
Result = dialogResult switch
{
ContentDialogResult.Primary => UpdateDialogResult.Download,
ContentDialogResult.Secondary => UpdateDialogResult.RemindLater,
_ => UpdateDialogResult.Skip
};
return Result;
}
}

View File

@ -0,0 +1,128 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System;
using System.Threading.Tasks;
using WinUIEx;
namespace MoltbotTray.Dialogs;
/// <summary>
/// First-run welcome dialog for new users.
/// </summary>
public sealed class WelcomeDialog : WindowEx
{
private readonly TaskCompletionSource<ContentDialogResult> _tcs = new();
private ContentDialogResult _result = ContentDialogResult.None;
public WelcomeDialog()
{
Title = "Welcome to Moltbot";
this.SetWindowSize(480, 440);
this.CenterOnScreen();
this.SetIcon("Assets\\moltbot.ico");
// Build UI directly in the window (no ContentDialog needed)
var root = new Grid
{
Padding = new Thickness(32),
RowSpacing = 16
};
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
// Lobster header
var headerPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 12,
HorizontalAlignment = HorizontalAlignment.Center
};
headerPanel.Children.Add(new TextBlock
{
Text = "🦞",
FontSize = 48
});
headerPanel.Children.Add(new TextBlock
{
Text = "Welcome to Moltbot!",
Style = (Style)Application.Current.Resources["TitleTextBlockStyle"],
VerticalAlignment = VerticalAlignment.Center
});
Grid.SetRow(headerPanel, 0);
root.Children.Add(headerPanel);
// Content
var content = new StackPanel { Spacing = 16 };
content.Children.Add(new TextBlock
{
Text = "Moltbot Tray is your Windows companion for Moltbot, the AI-powered personal assistant.",
TextWrapping = TextWrapping.Wrap
});
var gettingStarted = new StackPanel { Spacing = 8 };
gettingStarted.Children.Add(new TextBlock
{
Text = "To get started, you'll need:",
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold
});
var bulletList = new StackPanel { Spacing = 4, Margin = new Thickness(16, 0, 0, 0) };
bulletList.Children.Add(new TextBlock { Text = "• A running Moltbot gateway" });
bulletList.Children.Add(new TextBlock { Text = "• Your API token from the dashboard" });
gettingStarted.Children.Add(bulletList);
content.Children.Add(gettingStarted);
var docsButton = new HyperlinkButton
{
Content = "📚 View Documentation",
NavigateUri = new Uri("https://docs.molt.bot/web/dashboard")
};
content.Children.Add(docsButton);
Grid.SetRow(content, 1);
root.Children.Add(content);
// Buttons
var buttonPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8
};
var laterButton = new Button { Content = "Later" };
laterButton.Click += (s, e) =>
{
_result = ContentDialogResult.None;
Close();
};
buttonPanel.Children.Add(laterButton);
var settingsButton = new Button
{
Content = "Open Settings",
Style = (Style)Application.Current.Resources["AccentButtonStyle"]
};
settingsButton.Click += (s, e) =>
{
_result = ContentDialogResult.Primary;
Close();
};
buttonPanel.Children.Add(settingsButton);
Grid.SetRow(buttonPanel, 2);
root.Children.Add(buttonPanel);
Content = root;
Closed += (s, e) => _tcs.TrySetResult(_result);
}
public new Task<ContentDialogResult> ShowAsync()
{
Activate();
return _tcs.Task;
}
}

View File

@ -0,0 +1,145 @@
using Moltbot.Shared;
using System;
using System.Drawing;
using System.IO;
using System.Runtime.InteropServices;
namespace MoltbotTray.Helpers;
/// <summary>
/// Provides icon resources for the tray application.
/// Creates dynamic status icons with lobster pixel art.
/// </summary>
public static class IconHelper
{
private static readonly string AssetsPath = Path.Combine(AppContext.BaseDirectory, "Assets");
private static readonly string IconsPath = Path.Combine(AssetsPath, "Icons");
// Icon cache
private static Icon? _connectedIcon;
private static Icon? _disconnectedIcon;
private static Icon? _activityIcon;
private static Icon? _errorIcon;
private static Icon? _appIcon;
public static string GetStatusIconPath(ConnectionStatus status)
{
var iconName = status switch
{
ConnectionStatus.Connected => "StatusConnected.ico",
ConnectionStatus.Connecting => "StatusConnecting.ico",
ConnectionStatus.Error => "StatusError.ico",
_ => "StatusDisconnected.ico"
};
var path = Path.Combine(IconsPath, iconName);
// If specific icon doesn't exist, fall back to main icon
if (!File.Exists(path))
{
path = Path.Combine(AssetsPath, "moltbot.ico");
}
return path;
}
public static Icon GetStatusIcon(ConnectionStatus status)
{
return status switch
{
ConnectionStatus.Connected => GetOrCreateIcon(ref _connectedIcon, ConnectionStatus.Connected),
ConnectionStatus.Connecting => GetOrCreateIcon(ref _activityIcon, ConnectionStatus.Connecting),
ConnectionStatus.Error => GetOrCreateIcon(ref _errorIcon, ConnectionStatus.Error),
_ => GetOrCreateIcon(ref _disconnectedIcon, ConnectionStatus.Disconnected)
};
}
public static Icon GetAppIcon()
{
if (_appIcon != null) return _appIcon;
var iconPath = Path.Combine(AssetsPath, "moltbot.ico");
if (File.Exists(iconPath))
{
_appIcon = new Icon(iconPath);
}
else
{
_appIcon = CreateLobsterIcon(Color.FromArgb(255, 99, 71)); // Lobster red
}
return _appIcon;
}
private static Icon GetOrCreateIcon(ref Icon? cached, ConnectionStatus status)
{
if (cached != null) return cached;
var iconPath = GetStatusIconPath(status);
if (File.Exists(iconPath))
{
cached = new Icon(iconPath);
}
else
{
// Generate dynamic icon
var color = status switch
{
ConnectionStatus.Connected => Color.FromArgb(76, 175, 80), // Green
ConnectionStatus.Connecting => Color.FromArgb(255, 193, 7), // Amber
ConnectionStatus.Error => Color.FromArgb(244, 67, 54), // Red
_ => Color.FromArgb(158, 158, 158) // Gray
};
cached = CreateLobsterIcon(color);
}
return cached;
}
/// <summary>
/// Creates a simple colored lobster icon programmatically.
/// Uses pixel art style matching the original WinForms version.
/// </summary>
public static Icon CreateLobsterIcon(Color color)
{
const int size = 16;
using var bitmap = new Bitmap(size, size);
using var g = Graphics.FromImage(bitmap);
g.Clear(Color.Transparent);
// Simple lobster silhouette (pixel art style)
using var brush = new SolidBrush(color);
// Body
g.FillRectangle(brush, 6, 6, 4, 6);
// Claws
g.FillRectangle(brush, 3, 4, 2, 2);
g.FillRectangle(brush, 11, 4, 2, 2);
g.FillRectangle(brush, 4, 6, 2, 2);
g.FillRectangle(brush, 10, 6, 2, 2);
// Tail
g.FillRectangle(brush, 7, 12, 2, 3);
g.FillRectangle(brush, 5, 14, 6, 1);
// Eyes
using var eyeBrush = new SolidBrush(Color.White);
g.FillRectangle(eyeBrush, 6, 5, 1, 1);
g.FillRectangle(eyeBrush, 9, 5, 1, 1);
// Convert bitmap to icon
var hIcon = bitmap.GetHicon();
var icon = Icon.FromHandle(hIcon);
// Clone to own the icon data
var result = (Icon)icon.Clone();
DestroyIcon(hIcon);
return result;
}
[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern bool DestroyIcon(IntPtr handle);
}

View File

@ -0,0 +1,57 @@
using Microsoft.UI.Xaml;
using Microsoft.Win32;
using Windows.UI;
namespace MoltbotTray.Helpers;
/// <summary>
/// Helpers for detecting and applying Windows theme (dark/light mode).
/// </summary>
public static class ThemeHelper
{
public static bool IsDarkMode()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
var value = key?.GetValue("AppsUseLightTheme");
return value is int i && i == 0;
}
catch
{
return false;
}
}
public static ElementTheme GetCurrentTheme()
{
return IsDarkMode() ? ElementTheme.Dark : ElementTheme.Light;
}
public static Color GetAccentColor()
{
// Lobster red accent
return Color.FromArgb(255, 255, 99, 71);
}
public static Color GetBackgroundColor()
{
return IsDarkMode()
? Color.FromArgb(255, 32, 32, 32)
: Color.FromArgb(255, 249, 249, 249);
}
public static Color GetForegroundColor()
{
return IsDarkMode()
? Color.FromArgb(255, 255, 255, 255)
: Color.FromArgb(255, 28, 28, 28);
}
public static Color GetSubtleTextColor()
{
return IsDarkMode()
? Color.FromArgb(255, 180, 180, 180)
: Color.FromArgb(255, 100, 100, 100);
}
}

View File

@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
<UseWinUI>true</UseWinUI>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<WindowsPackageType>None</WindowsPackageType>
<EnableMsixTooling>true</EnableMsixTooling>
<ApplicationIcon>Assets\moltbot.ico</ApplicationIcon>
<RootNamespace>MoltbotTray</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Moltbot.Shared\Moltbot.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.250906003" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4654" />
<PackageReference Include="WinUIEx" Version="2.9.0" />
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
<PackageReference Include="Updatum" Version="1.3.4" />
</ItemGroup>
<ItemGroup>
<Content Include="Assets\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,51 @@
using Microsoft.Win32;
using System;
namespace MoltbotTray.Services;
/// <summary>
/// Manages Windows auto-start registry entries.
/// </summary>
public static class AutoStartManager
{
private const string RegistryKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run";
private const string AppName = "MoltbotTray";
public static bool IsAutoStartEnabled()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(RegistryKey, false);
return key?.GetValue(AppName) != null;
}
catch
{
return false;
}
}
public static void SetAutoStart(bool enable)
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(RegistryKey, true);
if (key == null) return;
if (enable)
{
var exePath = Environment.ProcessPath ?? System.Reflection.Assembly.GetExecutingAssembly().Location;
key.SetValue(AppName, $"\"{exePath}\"");
Logger.Info("Auto-start enabled");
}
else
{
key.DeleteValue(AppName, false);
Logger.Info("Auto-start disabled");
}
}
catch (Exception ex)
{
Logger.Error($"Failed to set auto-start: {ex.Message}");
}
}
}

View File

@ -0,0 +1,121 @@
using Microsoft.Win32;
using System;
using System.Threading.Tasks;
namespace MoltbotTray.Services;
/// <summary>
/// Handles moltbot:// deep link URI scheme registration and processing.
/// </summary>
public static class DeepLinkHandler
{
private const string UriScheme = "moltbot";
private const string UriSchemeKey = @"SOFTWARE\Classes\moltbot";
public static void RegisterUriScheme()
{
try
{
var exePath = Environment.ProcessPath ?? System.Reflection.Assembly.GetExecutingAssembly().Location;
using var key = Registry.CurrentUser.CreateSubKey(UriSchemeKey);
key?.SetValue("", "URL:Moltbot Protocol");
key?.SetValue("URL Protocol", "");
using var iconKey = key?.CreateSubKey("DefaultIcon");
iconKey?.SetValue("", $"\"{exePath}\",0");
using var commandKey = key?.CreateSubKey(@"shell\open\command");
commandKey?.SetValue("", $"\"{exePath}\" \"%1\"");
Logger.Info("URI scheme registered: moltbot://");
}
catch (Exception ex)
{
Logger.Warn($"Failed to register URI scheme: {ex.Message}");
}
}
public static void Handle(string uri, DeepLinkActions actions)
{
if (!uri.StartsWith("moltbot://", StringComparison.OrdinalIgnoreCase))
return;
var path = uri["moltbot://".Length..].TrimEnd('/');
var queryIndex = path.IndexOf('?');
var query = queryIndex >= 0 ? path[(queryIndex + 1)..] : "";
path = queryIndex >= 0 ? path[..queryIndex] : path;
Logger.Info($"Handling deep link: {path}");
switch (path.ToLowerInvariant())
{
case "settings":
actions.OpenSettings?.Invoke();
break;
case "chat":
actions.OpenChat?.Invoke();
break;
case "dashboard":
actions.OpenDashboard?.Invoke(null);
break;
case var p when p.StartsWith("dashboard/"):
var dashboardPath = p["dashboard/".Length..];
actions.OpenDashboard?.Invoke(dashboardPath);
break;
case "send":
var sendMessage = GetQueryParam(query, "message");
actions.OpenQuickSend?.Invoke(sendMessage);
break;
case "agent":
var agentMessage = GetQueryParam(query, "message");
if (!string.IsNullOrEmpty(agentMessage))
{
_ = Task.Run(async () =>
{
try
{
await actions.SendMessage!(agentMessage);
Logger.Info($"Sent message via deep link: {agentMessage}");
}
catch (Exception ex)
{
Logger.Error($"Failed to send message: {ex.Message}");
}
});
}
break;
default:
Logger.Warn($"Unknown deep link path: {path}");
break;
}
}
private static string? GetQueryParam(string query, string key)
{
foreach (var part in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var kv = part.Split('=', 2);
if (kv.Length == 2 && kv[0].Equals(key, StringComparison.OrdinalIgnoreCase))
{
return Uri.UnescapeDataString(kv[1]);
}
}
return null;
}
}
public class DeepLinkActions
{
public Action? OpenSettings { get; set; }
public Action? OpenChat { get; set; }
public Action<string?>? OpenDashboard { get; set; }
public Action<string?>? OpenQuickSend { get; set; }
public Func<string, Task>? SendMessage { get; set; }
}

View File

@ -0,0 +1,227 @@
using System;
using System.Runtime.InteropServices;
namespace MoltbotTray.Services;
/// <summary>
/// Registers and handles global hotkeys using P/Invoke.
/// Default: Ctrl+Alt+Shift+C for Quick Send.
/// </summary>
public class GlobalHotkeyService : IDisposable
{
private const int HOTKEY_ID = 9001;
private const uint MOD_CONTROL = 0x0002;
private const uint MOD_ALT = 0x0001;
private const uint MOD_SHIFT = 0x0004;
private const uint VK_C = 0x43;
private const int WM_HOTKEY = 0x0312;
[DllImport("user32.dll")]
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll")]
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr CreateWindowEx(
uint dwExStyle, string lpClassName, string lpWindowName,
uint dwStyle, int x, int y, int nWidth, int nHeight,
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
[DllImport("user32.dll")]
private static extern bool DestroyWindow(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern IntPtr DefWindowProc(IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll", SetLastError = true)]
private static extern ushort RegisterClass(ref WNDCLASS lpWndClass);
[DllImport("kernel32.dll")]
private static extern IntPtr GetModuleHandle(string? lpModuleName);
[DllImport("user32.dll")]
private static extern bool GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[DllImport("user32.dll")]
private static extern bool TranslateMessage(ref MSG lpMsg);
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
[DllImport("user32.dll")]
private static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
private const uint WM_QUIT = 0x0012;
private const uint WM_USER = 0x0400;
private delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
[StructLayout(LayoutKind.Sequential)]
private struct WNDCLASS
{
public uint style;
public IntPtr lpfnWndProc;
public int cbClsExtra;
public int cbWndExtra;
public IntPtr hInstance;
public IntPtr hIcon;
public IntPtr hCursor;
public IntPtr hbrBackground;
public string? lpszMenuName;
public string lpszClassName;
}
[StructLayout(LayoutKind.Sequential)]
private struct MSG
{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public POINT pt;
}
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int x;
public int y;
}
private IntPtr _hwnd;
private bool _registered;
private bool _disposed;
private Thread? _messageThread;
private WndProcDelegate? _wndProcDelegate; // prevent GC collection
private volatile bool _running;
public event EventHandler? HotkeyPressed;
public GlobalHotkeyService()
{
}
public bool Register()
{
if (_registered) return true;
try
{
// Create message window on a dedicated thread with message loop
_running = true;
_messageThread = new Thread(MessageLoop) { IsBackground = true, Name = "HotkeyMessageLoop" };
_messageThread.Start();
// Wait briefly for window creation
Thread.Sleep(100);
if (_hwnd == IntPtr.Zero)
{
Logger.Warn("Failed to create hotkey message window");
return false;
}
_registered = RegisterHotKey(_hwnd, HOTKEY_ID, MOD_CONTROL | MOD_ALT | MOD_SHIFT, VK_C);
if (_registered)
{
Logger.Info("Global hotkey registered: Ctrl+Alt+Shift+C");
}
else
{
Logger.Warn("Failed to register global hotkey (may be in use by another app)");
}
return _registered;
}
catch (Exception ex)
{
Logger.Error($"Hotkey registration error: {ex.Message}");
return false;
}
}
private void MessageLoop()
{
try
{
// Create window class
_wndProcDelegate = WndProc;
var wndClass = new WNDCLASS
{
lpfnWndProc = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate),
hInstance = GetModuleHandle(null),
lpszClassName = "MoltbotHotkeyWindow"
};
RegisterClass(ref wndClass);
// Create message-only window (HWND_MESSAGE parent)
_hwnd = CreateWindowEx(0, "MoltbotHotkeyWindow", "", 0, 0, 0, 0, 0,
new IntPtr(-3), // HWND_MESSAGE
IntPtr.Zero, GetModuleHandle(null), IntPtr.Zero);
// Message loop
while (_running && GetMessage(out MSG msg, IntPtr.Zero, 0, 0))
{
TranslateMessage(ref msg);
DispatchMessage(ref msg);
}
}
catch (Exception ex)
{
Logger.Error($"Hotkey message loop error: {ex.Message}");
}
}
private IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam)
{
if (msg == WM_HOTKEY && wParam.ToInt32() == HOTKEY_ID)
{
OnHotkeyPressed();
}
return DefWindowProc(hWnd, msg, wParam, lParam);
}
public void Unregister()
{
if (!_registered) return;
try
{
if (_hwnd != IntPtr.Zero)
{
UnregisterHotKey(_hwnd, HOTKEY_ID);
}
_registered = false;
Logger.Info("Global hotkey unregistered");
}
catch (Exception ex)
{
Logger.Warn($"Hotkey unregistration error: {ex.Message}");
}
}
internal void OnHotkeyPressed()
{
HotkeyPressed?.Invoke(this, EventArgs.Empty);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
Unregister();
_running = false;
if (_hwnd != IntPtr.Zero)
{
PostMessage(_hwnd, WM_QUIT, IntPtr.Zero, IntPtr.Zero);
DestroyWindow(_hwnd);
_hwnd = IntPtr.Zero;
}
_messageThread?.Join(1000);
}
}

View File

@ -0,0 +1,63 @@
using System;
using System.IO;
namespace MoltbotTray.Services;
/// <summary>
/// Simple file logger for the tray application.
/// </summary>
public static class Logger
{
private static readonly object _lock = new();
private static readonly string _logDirectory;
private static readonly string _logFilePath;
static Logger()
{
_logDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MoltbotTray");
Directory.CreateDirectory(_logDirectory);
_logFilePath = Path.Combine(_logDirectory, "moltbot-tray.log");
// Rotate log if too large (> 5MB)
try
{
var fileInfo = new FileInfo(_logFilePath);
if (fileInfo.Exists && fileInfo.Length > 5 * 1024 * 1024)
{
var backupPath = Path.Combine(_logDirectory, "moltbot-tray.log.old");
if (File.Exists(backupPath)) File.Delete(backupPath);
File.Move(_logFilePath, backupPath);
}
}
catch { }
}
public static string LogFilePath => _logFilePath;
public static void Info(string message) => Log("INFO", message);
public static void Warn(string message) => Log("WARN", message);
public static void Error(string message) => Log("ERROR", message);
public static void Debug(string message) => Log("DEBUG", message);
private static void Log(string level, string message)
{
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
var line = $"[{timestamp}] [{level}] {message}";
lock (_lock)
{
try
{
File.AppendAllText(_logFilePath, line + Environment.NewLine);
}
catch { }
}
#if DEBUG
System.Diagnostics.Debug.WriteLine(line);
#endif
}
}

View File

@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace MoltbotTray.Services;
/// <summary>
/// Stores notification history in memory with a configurable limit.
/// </summary>
public static class NotificationHistoryService
{
private static readonly List<NotificationHistoryItem> _history = new();
private static readonly object _lock = new();
private const int MaxHistory = 100;
public static void AddNotification(GatewayNotification notification)
{
lock (_lock)
{
_history.Insert(0, new NotificationHistoryItem
{
Timestamp = DateTime.Now,
Title = notification.Title ?? "Moltbot",
Message = notification.Message ?? "",
Category = notification.Category,
ActionUrl = notification.ActionUrl
});
// Trim to max
while (_history.Count > MaxHistory)
{
_history.RemoveAt(_history.Count - 1);
}
}
}
public static IReadOnlyList<NotificationHistoryItem> GetHistory()
{
lock (_lock)
{
return _history.ToList();
}
}
public static void Clear()
{
lock (_lock)
{
_history.Clear();
}
}
}
public class NotificationHistoryItem
{
public DateTime Timestamp { get; set; }
public string Title { get; set; } = "";
public string Message { get; set; } = "";
public string? Category { get; set; }
public string? ActionUrl { get; set; }
}
// Local notification model
public class GatewayNotification
{
public string? Title { get; set; }
public string? Message { get; set; }
public string? Category { get; set; }
public string? ActionUrl { get; set; }
}

View File

@ -0,0 +1,131 @@
using System;
using System.IO;
using System.Text.Json;
namespace MoltbotTray.Services;
/// <summary>
/// Manages application settings with JSON persistence.
/// </summary>
public class SettingsManager
{
private static readonly string SettingsDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"MoltbotTray");
private static readonly string SettingsFilePath = Path.Combine(SettingsDirectory, "settings.json");
// Connection
public string GatewayUrl { get; set; } = "ws://localhost:18789";
public string Token { get; set; } = "";
// Startup
public bool AutoStart { get; set; } = false;
public bool GlobalHotkeyEnabled { get; set; } = true;
// Notifications
public bool ShowNotifications { get; set; } = true;
public string NotificationSound { get; set; } = "Default";
// Notification filters
public bool NotifyHealth { get; set; } = true;
public bool NotifyUrgent { get; set; } = true;
public bool NotifyReminder { get; set; } = true;
public bool NotifyEmail { get; set; } = true;
public bool NotifyCalendar { get; set; } = true;
public bool NotifyBuild { get; set; } = true;
public bool NotifyStock { get; set; } = true;
public bool NotifyInfo { get; set; } = true;
public SettingsManager()
{
Load();
}
public void Load()
{
try
{
if (File.Exists(SettingsFilePath))
{
var json = File.ReadAllText(SettingsFilePath);
var loaded = JsonSerializer.Deserialize<SettingsData>(json);
if (loaded != null)
{
GatewayUrl = loaded.GatewayUrl ?? GatewayUrl;
Token = loaded.Token ?? Token;
AutoStart = loaded.AutoStart;
GlobalHotkeyEnabled = loaded.GlobalHotkeyEnabled;
ShowNotifications = loaded.ShowNotifications;
NotificationSound = loaded.NotificationSound ?? NotificationSound;
NotifyHealth = loaded.NotifyHealth;
NotifyUrgent = loaded.NotifyUrgent;
NotifyReminder = loaded.NotifyReminder;
NotifyEmail = loaded.NotifyEmail;
NotifyCalendar = loaded.NotifyCalendar;
NotifyBuild = loaded.NotifyBuild;
NotifyStock = loaded.NotifyStock;
NotifyInfo = loaded.NotifyInfo;
}
}
}
catch (Exception ex)
{
Logger.Warn($"Failed to load settings: {ex.Message}");
}
}
public void Save()
{
try
{
Directory.CreateDirectory(SettingsDirectory);
var data = new SettingsData
{
GatewayUrl = GatewayUrl,
Token = Token,
AutoStart = AutoStart,
GlobalHotkeyEnabled = GlobalHotkeyEnabled,
ShowNotifications = ShowNotifications,
NotificationSound = NotificationSound,
NotifyHealth = NotifyHealth,
NotifyUrgent = NotifyUrgent,
NotifyReminder = NotifyReminder,
NotifyEmail = NotifyEmail,
NotifyCalendar = NotifyCalendar,
NotifyBuild = NotifyBuild,
NotifyStock = NotifyStock,
NotifyInfo = NotifyInfo
};
var options = new JsonSerializerOptions { WriteIndented = true };
var json = JsonSerializer.Serialize(data, options);
File.WriteAllText(SettingsFilePath, json);
Logger.Info("Settings saved");
}
catch (Exception ex)
{
Logger.Error($"Failed to save settings: {ex.Message}");
}
}
private class SettingsData
{
public string? GatewayUrl { get; set; }
public string? Token { get; set; }
public bool AutoStart { get; set; }
public bool GlobalHotkeyEnabled { get; set; } = true;
public bool ShowNotifications { get; set; } = true;
public string? NotificationSound { get; set; }
public bool NotifyHealth { get; set; } = true;
public bool NotifyUrgent { get; set; } = true;
public bool NotifyReminder { get; set; } = true;
public bool NotifyEmail { get; set; } = true;
public bool NotifyCalendar { get; set; } = true;
public bool NotifyBuild { get; set; } = true;
public bool NotifyStock { get; set; } = true;
public bool NotifyInfo { get; set; } = true;
}
}

View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<winex:WindowEx
x:Class="MoltbotTray.Windows.NotificationHistoryWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:winex="using:WinUIEx"
Title="Notification History — Moltbot Tray">
<Grid Padding="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Header -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="12" Margin="0,0,0,16">
<TextBlock Text="📋 Notification History" Style="{StaticResource SubtitleTextBlockStyle}"/>
<TextBlock x:Name="CountText" Text="(0)" VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
</StackPanel>
<!-- List -->
<ListView x:Name="NotificationList" Grid.Row="1"
SelectionMode="Single"
ItemClick="OnItemClick"
IsItemClickEnabled="True">
<ListView.ItemTemplate>
<DataTemplate>
<Grid Padding="12" Margin="0,2" CornerRadius="4"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding Title}"
FontWeight="SemiBold"/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding TimeAgo}"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
<TextBlock Grid.Row="1" Grid.ColumnSpan="2" Text="{Binding Message}"
TextWrapping="Wrap" MaxLines="2"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
<StackPanel Grid.Row="2" Grid.ColumnSpan="2" Orientation="Horizontal"
Spacing="8" Margin="0,4,0,0">
<Border Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
CornerRadius="2" Padding="4,2">
<TextBlock Text="{Binding Category}"
Style="{StaticResource CaptionTextBlockStyle}"/>
</Border>
</StackPanel>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<!-- Empty state -->
<StackPanel x:Name="EmptyState" Grid.Row="1"
VerticalAlignment="Center" HorizontalAlignment="Center"
Visibility="Collapsed">
<TextBlock Text="📭" FontSize="48" HorizontalAlignment="Center"/>
<TextBlock Text="No notifications yet"
Style="{StaticResource BodyTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
</StackPanel>
<!-- Footer -->
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" Margin="0,16,0,0">
<Button Content="Clear All" Click="OnClearAll"/>
<Button Content="Close" Click="OnClose"/>
</StackPanel>
</Grid>
</winex:WindowEx>

View File

@ -0,0 +1,106 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Moltbot.Shared;
using MoltbotTray.Helpers;
using MoltbotTray.Services;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using WinUIEx;
namespace MoltbotTray.Windows;
public sealed partial class NotificationHistoryWindow : WindowEx
{
public bool IsClosed { get; private set; }
public NotificationHistoryWindow()
{
InitializeComponent();
// Window configuration
this.SetWindowSize(450, 600);
this.CenterOnScreen();
this.SetIcon(IconHelper.GetStatusIconPath(ConnectionStatus.Connected));
Closed += (s, e) => IsClosed = true;
LoadNotifications();
}
private void LoadNotifications()
{
var history = NotificationHistoryService.GetHistory();
if (history.Count == 0)
{
NotificationList.Visibility = Visibility.Collapsed;
EmptyState.Visibility = Visibility.Visible;
CountText.Text = "(0)";
return;
}
NotificationList.Visibility = Visibility.Visible;
EmptyState.Visibility = Visibility.Collapsed;
CountText.Text = $"({history.Count})";
NotificationList.ItemsSource = history.Select(n => new NotificationViewModel
{
Title = n.Title,
Message = n.Message,
Category = n.Category ?? "",
TimeAgo = GetTimeAgo(n.Timestamp),
ActionUrl = n.ActionUrl,
CategoryVisibility = string.IsNullOrEmpty(n.Category) ? Visibility.Collapsed : Visibility.Visible,
LinkVisibility = string.IsNullOrEmpty(n.ActionUrl) ? Visibility.Collapsed : Visibility.Visible
}).ToList();
}
private static string GetTimeAgo(DateTime timestamp)
{
var diff = DateTime.Now - timestamp;
if (diff.TotalMinutes < 1) return "Just now";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago";
return timestamp.ToString("MMM d, HH:mm");
}
private void OnItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is NotificationViewModel vm && !string.IsNullOrEmpty(vm.ActionUrl))
{
try
{
Process.Start(new ProcessStartInfo(vm.ActionUrl) { UseShellExecute = true });
}
catch (Exception ex)
{
Logger.Error($"Failed to open URL: {ex.Message}");
}
}
}
private void OnClearAll(object sender, RoutedEventArgs e)
{
NotificationHistoryService.Clear();
LoadNotifications();
}
private void OnClose(object sender, RoutedEventArgs e)
{
Close();
}
private class NotificationViewModel
{
public string Title { get; set; } = "";
public string Message { get; set; } = "";
public string Category { get; set; } = "";
public string TimeAgo { get; set; } = "";
public string? ActionUrl { get; set; }
public Visibility CategoryVisibility { get; set; }
public Visibility LinkVisibility { get; set; }
}
}

View File

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<winex:WindowEx
x:Class="MoltbotTray.Windows.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:winex="using:WinUIEx"
Title="Settings — Moltbot Tray">
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="24">
<StackPanel Spacing="24" MaxWidth="450">
<!-- Connection Section -->
<StackPanel Spacing="8">
<TextBlock Text="CONNECTION" Style="{StaticResource CaptionTextBlockStyle}"
Foreground="#E74C3C" FontWeight="Bold"/>
<TextBox x:Name="GatewayUrlTextBox" Header="Gateway URL"
PlaceholderText="ws://localhost:18789"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox x:Name="TokenTextBox" Header="Token"
PlaceholderText="Your API token" Width="300"/>
<Button x:Name="TestConnectionButton" Content="Test"
VerticalAlignment="Bottom" Click="OnTestConnection"/>
</StackPanel>
<TextBlock x:Name="StatusLabel" Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
</StackPanel>
<!-- Startup Section -->
<StackPanel Spacing="8">
<TextBlock Text="STARTUP" Style="{StaticResource CaptionTextBlockStyle}"
Foreground="#E74C3C" FontWeight="Bold"/>
<ToggleSwitch x:Name="AutoStartToggle" Header="Start automatically with Windows"/>
<ToggleSwitch x:Name="GlobalHotkeyToggle" Header="Global hotkey (Ctrl+Alt+Shift+C → Quick Send)"/>
</StackPanel>
<!-- Notifications Section -->
<StackPanel Spacing="8">
<TextBlock Text="NOTIFICATIONS" Style="{StaticResource CaptionTextBlockStyle}"
Foreground="#E74C3C" FontWeight="Bold"/>
<ToggleSwitch x:Name="NotificationsToggle" Header="Show notifications"/>
<ComboBox x:Name="NotificationSoundComboBox" Header="Sound" Width="200">
<ComboBoxItem Content="Default"/>
<ComboBoxItem Content="None"/>
<ComboBoxItem Content="Subtle"/>
</ComboBox>
<TextBlock Text="Show notifications for:" Margin="0,8,0,4"/>
<StackPanel Spacing="4">
<CheckBox x:Name="NotifyHealthCb" Content="Health alerts"/>
<CheckBox x:Name="NotifyUrgentCb" Content="Urgent messages"/>
<CheckBox x:Name="NotifyReminderCb" Content="Reminders"/>
<CheckBox x:Name="NotifyEmailCb" Content="Email summaries"/>
<CheckBox x:Name="NotifyCalendarCb" Content="Calendar events"/>
<CheckBox x:Name="NotifyBuildCb" Content="Build notifications"/>
<CheckBox x:Name="NotifyStockCb" Content="Stock alerts"/>
<CheckBox x:Name="NotifyInfoCb" Content="Info messages"/>
</StackPanel>
</StackPanel>
<!-- Test Notification -->
<Button x:Name="TestNotificationButton" Content="Send Test Notification"
Click="OnTestNotification"/>
<!-- Buttons -->
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
<Button Content="Cancel" Click="OnCancel" Width="80"/>
<Button x:Name="SaveButton" Content="Save"
Click="OnSave" Width="80" Style="{ThemeResource AccentButtonStyle}"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</winex:WindowEx>

View File

@ -0,0 +1,175 @@
using Microsoft.Toolkit.Uwp.Notifications;
using Microsoft.UI.Xaml;
using Moltbot.Shared;
using MoltbotTray.Helpers;
using MoltbotTray.Services;
using System;
using System.Threading.Tasks;
using WinUIEx;
namespace MoltbotTray.Windows;
public sealed partial class SettingsWindow : WindowEx
{
private readonly SettingsManager _settings;
public bool IsClosed { get; private set; }
public event EventHandler? SettingsSaved;
public SettingsWindow(SettingsManager settings)
{
_settings = settings;
InitializeComponent();
// Window configuration
this.SetWindowSize(480, 700);
this.CenterOnScreen();
this.SetIcon(IconHelper.GetStatusIconPath(ConnectionStatus.Connected));
LoadSettings();
Closed += (s, e) => IsClosed = true;
}
private void LoadSettings()
{
GatewayUrlTextBox.Text = _settings.GatewayUrl;
TokenTextBox.Text = _settings.Token;
AutoStartToggle.IsOn = _settings.AutoStart;
GlobalHotkeyToggle.IsOn = _settings.GlobalHotkeyEnabled;
NotificationsToggle.IsOn = _settings.ShowNotifications;
// Set sound combo
for (int i = 0; i < NotificationSoundComboBox.Items.Count; i++)
{
if (NotificationSoundComboBox.Items[i] is Microsoft.UI.Xaml.Controls.ComboBoxItem item &&
item.Content?.ToString() == _settings.NotificationSound)
{
NotificationSoundComboBox.SelectedIndex = i;
break;
}
}
if (NotificationSoundComboBox.SelectedIndex < 0)
NotificationSoundComboBox.SelectedIndex = 0;
// Notification filters
NotifyHealthCb.IsChecked = _settings.NotifyHealth;
NotifyUrgentCb.IsChecked = _settings.NotifyUrgent;
NotifyReminderCb.IsChecked = _settings.NotifyReminder;
NotifyEmailCb.IsChecked = _settings.NotifyEmail;
NotifyCalendarCb.IsChecked = _settings.NotifyCalendar;
NotifyBuildCb.IsChecked = _settings.NotifyBuild;
NotifyStockCb.IsChecked = _settings.NotifyStock;
NotifyInfoCb.IsChecked = _settings.NotifyInfo;
}
private void SaveSettings()
{
_settings.GatewayUrl = GatewayUrlTextBox.Text.Trim();
_settings.Token = TokenTextBox.Text.Trim();
_settings.AutoStart = AutoStartToggle.IsOn;
_settings.GlobalHotkeyEnabled = GlobalHotkeyToggle.IsOn;
_settings.ShowNotifications = NotificationsToggle.IsOn;
if (NotificationSoundComboBox.SelectedItem is Microsoft.UI.Xaml.Controls.ComboBoxItem item)
{
_settings.NotificationSound = item.Content?.ToString() ?? "Default";
}
_settings.NotifyHealth = NotifyHealthCb.IsChecked ?? true;
_settings.NotifyUrgent = NotifyUrgentCb.IsChecked ?? true;
_settings.NotifyReminder = NotifyReminderCb.IsChecked ?? true;
_settings.NotifyEmail = NotifyEmailCb.IsChecked ?? true;
_settings.NotifyCalendar = NotifyCalendarCb.IsChecked ?? true;
_settings.NotifyBuild = NotifyBuildCb.IsChecked ?? true;
_settings.NotifyStock = NotifyStockCb.IsChecked ?? true;
_settings.NotifyInfo = NotifyInfoCb.IsChecked ?? true;
_settings.Save();
AutoStartManager.SetAutoStart(_settings.AutoStart);
}
private async void OnTestConnection(object sender, RoutedEventArgs e)
{
StatusLabel.Text = "Testing...";
TestConnectionButton.IsEnabled = false;
try
{
var client = new MoltbotGatewayClient(
GatewayUrlTextBox.Text.Trim(),
TokenTextBox.Text.Trim(),
new TestLogger());
var connected = false;
var tcs = new TaskCompletionSource<bool>();
client.StatusChanged += (s, status) =>
{
if (status == ConnectionStatus.Connected)
{
connected = true;
tcs.TrySetResult(true);
}
else if (status == ConnectionStatus.Error)
{
tcs.TrySetResult(false);
}
};
_ = client.ConnectAsync();
// Wait up to 5 seconds for connection
var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(5000));
if (completedTask != tcs.Task)
{
connected = false;
}
StatusLabel.Text = connected ? "✅ Connected!" : "❌ Connection failed";
client.Dispose();
}
catch (Exception ex)
{
StatusLabel.Text = $"❌ {ex.Message}";
}
finally
{
TestConnectionButton.IsEnabled = true;
}
}
private void OnTestNotification(object sender, RoutedEventArgs e)
{
try
{
new ToastContentBuilder()
.AddText("Test Notification")
.AddText("This is a test notification from Moltbot Tray.")
.Show();
}
catch (Exception ex)
{
StatusLabel.Text = $"❌ {ex.Message}";
}
}
private void OnSave(object sender, RoutedEventArgs e)
{
SaveSettings();
SettingsSaved?.Invoke(this, EventArgs.Empty);
Close();
}
private void OnCancel(object sender, RoutedEventArgs e)
{
Close();
}
private class TestLogger : IMoltbotLogger
{
public void Info(string message) { }
public void Warn(string message) { }
public void Error(string message, Exception? ex = null) { }
}
}

View File

@ -0,0 +1,113 @@
<?xml version="1.0" encoding="utf-8"?>
<winex:WindowEx
x:Class="MoltbotTray.Windows.StatusDetailWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:winex="using:WinUIEx"
Title="Status — Moltbot Tray">
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="24">
<StackPanel Spacing="16" MaxWidth="400">
<!-- Status Header -->
<StackPanel Orientation="Horizontal" Spacing="12">
<FontIcon x:Name="StatusIcon" Glyph="&#xE8FB;" FontSize="32"
Foreground="#E74C3C"/>
<StackPanel>
<TextBlock x:Name="StatusText" Text="Connected"
Style="{StaticResource SubtitleTextBlockStyle}"/>
<TextBlock x:Name="LastCheckText" Text="Last check: --"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
</StackPanel>
</StackPanel>
<!-- Usage Section -->
<StackPanel x:Name="UsageSection" Spacing="8">
<TextBlock Text="USAGE" Style="{StaticResource CaptionTextBlockStyle}"
Foreground="#E74C3C" FontWeight="Bold"/>
<Grid ColumnSpacing="16" RowSpacing="4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="Today's Cost:"/>
<TextBlock x:Name="TodayCostText" Grid.Row="0" Grid.Column="1"
Text="$0.00" FontWeight="SemiBold"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="Requests:"/>
<TextBlock x:Name="TodayRequestsText" Grid.Row="1" Grid.Column="1"
Text="0" FontWeight="SemiBold"/>
</Grid>
</StackPanel>
<!-- Sessions Section -->
<StackPanel x:Name="SessionsSection" Spacing="8">
<TextBlock Text="ACTIVE SESSIONS" Style="{StaticResource CaptionTextBlockStyle}"
Foreground="#E74C3C" FontWeight="Bold"/>
<ItemsControl x:Name="SessionsList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Padding="8" Margin="0,2" CornerRadius="4"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="💬" Grid.Column="0" Margin="0,0,8,0"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding Channel}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding LastMessage}"
Style="{StaticResource CaptionTextBlockStyle}"
TextTrimming="CharacterEllipsis"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"/>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock x:Name="NoSessionsText" Text="No active sessions"
Style="{StaticResource CaptionTextBlockStyle}"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Visibility="Collapsed"/>
</StackPanel>
<!-- Channels Section -->
<StackPanel x:Name="ChannelsSection" Spacing="8">
<TextBlock Text="CHANNELS" Style="{StaticResource CaptionTextBlockStyle}"
Foreground="#E74C3C" FontWeight="Bold"/>
<ItemsControl x:Name="ChannelsList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Padding="8" Margin="0,2" CornerRadius="4"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding StatusIcon}" Grid.Column="0" Margin="0,0,8,0"/>
<TextBlock Text="{Binding Name}" Grid.Column="1"/>
<TextBlock Text="{Binding StatusText}" Grid.Column="2"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- Refresh Button -->
<Button Content="Refresh" Click="OnRefresh" HorizontalAlignment="Center"/>
</StackPanel>
</ScrollViewer>
</winex:WindowEx>

View File

@ -0,0 +1,114 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI;
using Moltbot.Shared;
using MoltbotTray.Helpers;
using System;
using System.Collections.Generic;
using System.Linq;
using WinUIEx;
using Windows.UI;
namespace MoltbotTray.Windows;
public sealed partial class StatusDetailWindow : WindowEx
{
public bool IsClosed { get; private set; }
public event EventHandler? RefreshRequested;
public StatusDetailWindow(
ConnectionStatus status,
ChannelHealth[] channels,
SessionInfo[] sessions,
GatewayUsageInfo? usage,
DateTime lastCheck)
{
InitializeComponent();
// Window configuration
this.SetWindowSize(420, 550);
this.CenterOnScreen();
this.SetIcon(IconHelper.GetStatusIconPath(status));
Closed += (s, e) => IsClosed = true;
UpdateStatus(status, channels, sessions, usage, lastCheck);
}
public void UpdateStatus(
ConnectionStatus status,
ChannelHealth[] channels,
SessionInfo[] sessions,
GatewayUsageInfo? usage,
DateTime lastCheck)
{
// Status
StatusText.Text = status.ToString();
LastCheckText.Text = $"Last check: {lastCheck:HH:mm:ss}";
var (glyph, color) = status switch
{
ConnectionStatus.Connected => ("\uE8FB", Color.FromArgb(255, 76, 175, 80)), // Checkmark, Green
ConnectionStatus.Connecting => ("\uE895", Color.FromArgb(255, 255, 193, 7)), // Sync, Amber
ConnectionStatus.Error => ("\uE783", Color.FromArgb(255, 244, 67, 54)), // Error, Red
_ => ("\uE8FB", Color.FromArgb(255, 158, 158, 158)) // Gray
};
StatusIcon.Glyph = glyph;
StatusIcon.Foreground = new SolidColorBrush(color);
// Usage
if (usage != null)
{
UsageSection.Visibility = Visibility.Visible;
TodayCostText.Text = $"${usage.CostUsd:F2}";
TodayRequestsText.Text = usage.RequestCount.ToString();
}
else
{
UsageSection.Visibility = Visibility.Collapsed;
}
// Sessions
if (sessions.Length > 0)
{
SessionsList.ItemsSource = sessions.Select(s => new
{
Channel = s.Channel ?? "Unknown",
LastMessage = s.DisplayText
}).ToList();
SessionsList.Visibility = Visibility.Visible;
NoSessionsText.Visibility = Visibility.Collapsed;
}
else
{
SessionsList.Visibility = Visibility.Collapsed;
NoSessionsText.Visibility = Visibility.Visible;
}
// Channels
var isHealthy = (string? s) => s?.ToLowerInvariant() is "ok" or "connected" or "running";
ChannelsList.ItemsSource = channels.Select(c => new ChannelViewModel
{
Name = c.Name,
StatusIcon = isHealthy(c.Status) ? "🟢" : "🔴",
StatusText = c.Status ?? "Unknown",
StatusBrush = new SolidColorBrush(isHealthy(c.Status)
? Color.FromArgb(255, 76, 175, 80)
: Color.FromArgb(255, 244, 67, 54))
}).ToList();
}
private void OnRefresh(object sender, RoutedEventArgs e)
{
RefreshRequested?.Invoke(this, EventArgs.Empty);
}
private class ChannelViewModel
{
public string Name { get; set; } = "";
public string StatusIcon { get; set; } = "";
public string StatusText { get; set; } = "";
public SolidColorBrush StatusBrush { get; set; } = new(Colors.Gray);
}
}

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<winex:WindowEx
x:Class="MoltbotTray.Windows.TrayMenuWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:winex="using:WinUIEx"
Title="Moltbot Menu"
Width="280"
Height="400">
<Border Background="{ThemeResource AcrylicBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="MenuPanel" Padding="4,6">
<!-- Menu items will be added programmatically -->
</StackPanel>
</ScrollViewer>
</Border>
</winex:WindowEx>

View File

@ -0,0 +1,252 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using System;
using System.Runtime.InteropServices;
using WinUIEx;
namespace MoltbotTray.Windows;
/// <summary>
/// A popup window that displays the tray menu at the cursor position.
/// </summary>
public sealed partial class TrayMenuWindow : WindowEx
{
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetCursorPos(out POINT lpPoint);
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
[DllImport("user32.dll")]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
private const uint MONITOR_DEFAULTTONEAREST = 2;
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int X;
public int Y;
}
[StructLayout(LayoutKind.Sequential)]
private struct RECT
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[StructLayout(LayoutKind.Sequential)]
private struct MONITORINFO
{
public int cbSize;
public RECT rcMonitor;
public RECT rcWork;
public uint dwFlags;
}
public event EventHandler<string>? MenuItemClicked;
private int _menuHeight = 400;
private int _itemCount = 0;
private int _separatorCount = 0;
private int _headerCount = 0;
public TrayMenuWindow()
{
InitializeComponent();
// Configure as popup-style window
this.IsMaximizable = false;
this.IsMinimizable = false;
this.IsResizable = false;
this.IsTitleBarVisible = false;
this.IsAlwaysOnTop = true;
// Lose focus = close
Activated += OnActivated;
}
private void OnActivated(object sender, WindowActivatedEventArgs args)
{
if (args.WindowActivationState == WindowActivationState.Deactivated)
{
Close();
}
}
[DllImport("user32.dll")]
private static extern uint GetDpiForWindow(IntPtr hwnd);
public void ShowAtCursor()
{
if (GetCursorPos(out POINT pt))
{
// Get DPI scale factor
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
uint dpi = GetDpiForWindow(hwnd);
if (dpi == 0) dpi = 96;
double scale = dpi / 96.0;
// Get the monitor where the cursor is
var hMonitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST);
var monitorInfo = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
GetMonitorInfo(hMonitor, ref monitorInfo);
var workArea = monitorInfo.rcWork;
// Scale the menu dimensions to physical pixels
int menuWidthPhysical = (int)(280 * scale);
int menuHeightPhysical = (int)(_menuHeight * scale);
// Calculate X position - keep menu on screen
int x = pt.X;
if (x + menuWidthPhysical > workArea.Right)
x = workArea.Right - menuWidthPhysical;
if (x < workArea.Left)
x = workArea.Left;
// Calculate Y position - open ABOVE cursor (tray is at bottom)
int y = pt.Y - menuHeightPhysical - 10;
// If not enough room above, open below
if (y < workArea.Top)
y = pt.Y + 10;
this.Move(x, y);
}
Activate();
// Ensure window gets focus so clicking away will close it
var hwndFocus = WinRT.Interop.WindowNative.GetWindowHandle(this);
SetForegroundWindow(hwndFocus);
}
public void AddMenuItem(string text, string? icon, string action, bool isEnabled = true, bool indent = false)
{
var content = new TextBlock
{
Text = string.IsNullOrEmpty(icon) ? text : $"{icon} {text}",
TextTrimming = TextTrimming.CharacterEllipsis,
IsTextSelectionEnabled = false
};
var leftPadding = indent ? 28 : 12;
var button = new Button
{
Content = content,
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Left,
Padding = new Thickness(leftPadding, 8, 12, 8),
Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Transparent),
BorderThickness = new Thickness(0),
IsEnabled = isEnabled,
Tag = action,
CornerRadius = new CornerRadius(4)
};
if (!isEnabled)
content.Opacity = 0.5;
button.Click += (s, e) =>
{
MenuItemClicked?.Invoke(this, action);
Close();
};
// Hover effect
button.PointerEntered += (s, e) =>
{
if (button.IsEnabled)
button.Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["SubtleFillColorSecondaryBrush"];
};
button.PointerExited += (s, e) =>
{
button.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Transparent);
};
MenuPanel.Children.Add(button);
_itemCount++;
}
public void AddSeparator()
{
MenuPanel.Children.Add(new Border
{
Height = 1,
Margin = new Thickness(8, 6, 8, 6),
Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["DividerStrokeColorDefaultBrush"]
});
_separatorCount++;
}
public void AddBrandHeader(string emoji, string text)
{
var panel = new StackPanel
{
Orientation = Orientation.Horizontal,
Padding = new Thickness(12, 12, 12, 8),
Spacing = 8
};
panel.Children.Add(new TextBlock
{
Text = emoji,
FontSize = 28
});
panel.Children.Add(new TextBlock
{
Text = text,
FontSize = 18,
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
VerticalAlignment = VerticalAlignment.Center
});
MenuPanel.Children.Add(panel);
_headerCount += 2; // Counts as larger
}
public void AddHeader(string text)
{
MenuPanel.Children.Add(new TextBlock
{
Text = text,
FontWeight = Microsoft.UI.Text.FontWeights.SemiBold,
Padding = new Thickness(12, 10, 12, 4),
Opacity = 0.7
});
_headerCount++;
}
public void ClearItems()
{
MenuPanel.Children.Clear();
_itemCount = 0;
_separatorCount = 0;
_headerCount = 0;
}
/// <summary>
/// Adjusts the window height to fit content and stores it for positioning
/// </summary>
public void SizeToContent()
{
// Calculate height based on item counts
// Menu items: ~36px each (button with padding)
// Separators: ~13px each
// Headers: ~30px each
// Plus padding: ~16px
_menuHeight = (_itemCount * 36) + (_separatorCount * 13) + (_headerCount * 30) + 16;
_menuHeight = Math.Max(_menuHeight, 100); // minimum
this.SetWindowSize(280, _menuHeight);
}
}

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<winex:WindowEx
x:Class="MoltbotTray.Windows.WebChatWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:winex="using:WinUIEx"
Title="Moltbot Chat">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Toolbar -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="4" Padding="8"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}">
<Button x:Name="HomeButton" ToolTipService.ToolTip="Home" Click="OnHome"
Width="36" Height="36">
<FontIcon Glyph="&#xE80F;" FontSize="14"/>
</Button>
<Button x:Name="RefreshButton" ToolTipService.ToolTip="Refresh" Click="OnRefresh"
Width="36" Height="36">
<FontIcon Glyph="&#xE72C;" FontSize="14"/>
</Button>
<Button x:Name="PopoutButton" ToolTipService.ToolTip="Open in Browser" Click="OnPopout"
Width="36" Height="36">
<FontIcon Glyph="&#xE8A7;" FontSize="14"/>
</Button>
<Button x:Name="DevToolsButton" ToolTipService.ToolTip="Developer Tools" Click="OnDevTools"
Width="36" Height="36">
<FontIcon Glyph="&#xE90F;" FontSize="14"/>
</Button>
</StackPanel>
<!-- WebView2 -->
<WebView2 x:Name="WebView" Grid.Row="1"/>
<!-- Loading indicator -->
<ProgressRing x:Name="LoadingRing" Grid.Row="1" IsActive="True"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</winex:WindowEx>

View File

@ -0,0 +1,148 @@
using Microsoft.UI.Xaml;
using Microsoft.Web.WebView2.Core;
using Moltbot.Shared;
using MoltbotTray.Helpers;
using MoltbotTray.Services;
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using WinUIEx;
using Windows.Foundation;
namespace MoltbotTray.Windows;
public sealed partial class WebChatWindow : WindowEx
{
private readonly string _gatewayUrl;
private readonly string _token;
private bool _initialized;
// Store event handlers for cleanup
private TypedEventHandler<CoreWebView2, CoreWebView2NavigationCompletedEventArgs>? _navigationCompletedHandler;
private TypedEventHandler<CoreWebView2, CoreWebView2NavigationStartingEventArgs>? _navigationStartingHandler;
public bool IsClosed { get; private set; }
public WebChatWindow(string gatewayUrl, string token)
{
_gatewayUrl = gatewayUrl;
_token = token;
InitializeComponent();
// Window configuration
this.SetWindowSize(520, 750);
this.MinWidth = 380;
this.MinHeight = 450;
this.CenterOnScreen();
this.SetIcon(IconHelper.GetStatusIconPath(ConnectionStatus.Connected));
Closed += OnWindowClosed;
_ = InitializeWebViewAsync();
}
private void OnWindowClosed(object sender, WindowEventArgs e)
{
IsClosed = true;
// Cleanup WebView2 event handlers
if (WebView.CoreWebView2 != null)
{
if (_navigationCompletedHandler != null)
WebView.CoreWebView2.NavigationCompleted -= _navigationCompletedHandler;
if (_navigationStartingHandler != null)
WebView.CoreWebView2.NavigationStarting -= _navigationStartingHandler;
}
}
private async Task InitializeWebViewAsync()
{
try
{
// Set up user data folder via environment variable (WinUI 3 workaround)
var userDataFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MoltbotTray", "WebView2");
Directory.CreateDirectory(userDataFolder);
Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", userDataFolder);
await WebView.EnsureCoreWebView2Async();
// Configure WebView2
WebView.CoreWebView2.Settings.IsStatusBarEnabled = false;
WebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true;
WebView.CoreWebView2.Settings.IsZoomControlEnabled = true;
// Handle navigation events (store for cleanup)
_navigationCompletedHandler = (s, e) =>
{
LoadingRing.IsActive = false;
LoadingRing.Visibility = Visibility.Collapsed;
};
WebView.CoreWebView2.NavigationCompleted += _navigationCompletedHandler;
_navigationStartingHandler = (s, e) =>
{
LoadingRing.IsActive = true;
LoadingRing.Visibility = Visibility.Visible;
};
WebView.CoreWebView2.NavigationStarting += _navigationStartingHandler;
// Navigate to chat
NavigateToChat();
_initialized = true;
}
catch (Exception ex)
{
Logger.Error($"WebView2 initialization failed: {ex.Message}");
LoadingRing.IsActive = false;
}
}
private void NavigateToChat()
{
if (WebView.CoreWebView2 == null) return;
var baseUrl = _gatewayUrl
.Replace("ws://", "http://")
.Replace("wss://", "https://");
var url = $"{baseUrl}?token={Uri.EscapeDataString(_token)}";
WebView.CoreWebView2.Navigate(url);
}
private void OnHome(object sender, RoutedEventArgs e)
{
NavigateToChat();
}
private void OnRefresh(object sender, RoutedEventArgs e)
{
WebView.CoreWebView2?.Reload();
}
private void OnPopout(object sender, RoutedEventArgs e)
{
var baseUrl = _gatewayUrl
.Replace("ws://", "http://")
.Replace("wss://", "https://");
var url = $"{baseUrl}?token={Uri.EscapeDataString(_token)}";
try
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
catch (Exception ex)
{
Logger.Error($"Failed to open in browser: {ex.Message}");
}
}
private void OnDevTools(object sender, RoutedEventArgs e)
{
WebView.CoreWebView2?.OpenDevToolsWindow();
}
}

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Moltbot.Tray.WinUI"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- Per-monitor DPI awareness (best quality) -->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10/11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>