Merge remote-tracking branch 'origin/winui-test'
This commit is contained in:
commit
8e4be37f45
78
XAML_COMPILER_BUG.md
Normal file
78
XAML_COMPILER_BUG.md
Normal 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
1
input.json.bak
Normal file
File diff suppressed because one or more lines are too long
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
25
src/Moltbot.Tray.WinUI/App.xaml
Normal file
25
src/Moltbot.Tray.WinUI/App.xaml
Normal 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>
|
||||
1089
src/Moltbot.Tray.WinUI/App.xaml.cs
Normal file
1089
src/Moltbot.Tray.WinUI/App.xaml.cs
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src/Moltbot.Tray.WinUI/Assets/moltbot.ico
Normal file
BIN
src/Moltbot.Tray.WinUI/Assets/moltbot.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
33
src/Moltbot.Tray.WinUI/Dialogs/DownloadProgressDialog.cs
Normal file
33
src/Moltbot.Tray.WinUI/Dialogs/DownloadProgressDialog.cs
Normal 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();
|
||||
}
|
||||
139
src/Moltbot.Tray.WinUI/Dialogs/QuickSendDialog.cs
Normal file
139
src/Moltbot.Tray.WinUI/Dialogs/QuickSendDialog.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
103
src/Moltbot.Tray.WinUI/Dialogs/UpdateDialog.cs
Normal file
103
src/Moltbot.Tray.WinUI/Dialogs/UpdateDialog.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
128
src/Moltbot.Tray.WinUI/Dialogs/WelcomeDialog.cs
Normal file
128
src/Moltbot.Tray.WinUI/Dialogs/WelcomeDialog.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
145
src/Moltbot.Tray.WinUI/Helpers/IconHelper.cs
Normal file
145
src/Moltbot.Tray.WinUI/Helpers/IconHelper.cs
Normal 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);
|
||||
}
|
||||
57
src/Moltbot.Tray.WinUI/Helpers/ThemeHelper.cs
Normal file
57
src/Moltbot.Tray.WinUI/Helpers/ThemeHelper.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
33
src/Moltbot.Tray.WinUI/Moltbot.Tray.WinUI.csproj
Normal file
33
src/Moltbot.Tray.WinUI/Moltbot.Tray.WinUI.csproj
Normal 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>
|
||||
|
||||
51
src/Moltbot.Tray.WinUI/Services/AutoStartManager.cs
Normal file
51
src/Moltbot.Tray.WinUI/Services/AutoStartManager.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
121
src/Moltbot.Tray.WinUI/Services/DeepLinkHandler.cs
Normal file
121
src/Moltbot.Tray.WinUI/Services/DeepLinkHandler.cs
Normal 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; }
|
||||
}
|
||||
227
src/Moltbot.Tray.WinUI/Services/GlobalHotkeyService.cs
Normal file
227
src/Moltbot.Tray.WinUI/Services/GlobalHotkeyService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
63
src/Moltbot.Tray.WinUI/Services/Logger.cs
Normal file
63
src/Moltbot.Tray.WinUI/Services/Logger.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
131
src/Moltbot.Tray.WinUI/Services/SettingsManager.cs
Normal file
131
src/Moltbot.Tray.WinUI/Services/SettingsManager.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
106
src/Moltbot.Tray.WinUI/Windows/NotificationHistoryWindow.xaml.cs
Normal file
106
src/Moltbot.Tray.WinUI/Windows/NotificationHistoryWindow.xaml.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
81
src/Moltbot.Tray.WinUI/Windows/SettingsWindow.xaml
Normal file
81
src/Moltbot.Tray.WinUI/Windows/SettingsWindow.xaml
Normal 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>
|
||||
|
||||
175
src/Moltbot.Tray.WinUI/Windows/SettingsWindow.xaml.cs
Normal file
175
src/Moltbot.Tray.WinUI/Windows/SettingsWindow.xaml.cs
Normal 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) { }
|
||||
}
|
||||
}
|
||||
113
src/Moltbot.Tray.WinUI/Windows/StatusDetailWindow.xaml
Normal file
113
src/Moltbot.Tray.WinUI/Windows/StatusDetailWindow.xaml
Normal 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="" 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>
|
||||
|
||||
114
src/Moltbot.Tray.WinUI/Windows/StatusDetailWindow.xaml.cs
Normal file
114
src/Moltbot.Tray.WinUI/Windows/StatusDetailWindow.xaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
21
src/Moltbot.Tray.WinUI/Windows/TrayMenuWindow.xaml
Normal file
21
src/Moltbot.Tray.WinUI/Windows/TrayMenuWindow.xaml
Normal 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>
|
||||
252
src/Moltbot.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs
Normal file
252
src/Moltbot.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
49
src/Moltbot.Tray.WinUI/Windows/WebChatWindow.xaml
Normal file
49
src/Moltbot.Tray.WinUI/Windows/WebChatWindow.xaml
Normal 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="" FontSize="14"/>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="RefreshButton" ToolTipService.ToolTip="Refresh" Click="OnRefresh"
|
||||
Width="36" Height="36">
|
||||
<FontIcon Glyph="" FontSize="14"/>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="PopoutButton" ToolTipService.ToolTip="Open in Browser" Click="OnPopout"
|
||||
Width="36" Height="36">
|
||||
<FontIcon Glyph="" FontSize="14"/>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="DevToolsButton" ToolTipService.ToolTip="Developer Tools" Click="OnDevTools"
|
||||
Width="36" Height="36">
|
||||
<FontIcon Glyph="" 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>
|
||||
|
||||
148
src/Moltbot.Tray.WinUI/Windows/WebChatWindow.xaml.cs
Normal file
148
src/Moltbot.Tray.WinUI/Windows/WebChatWindow.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
19
src/Moltbot.Tray.WinUI/app.manifest
Normal file
19
src/Moltbot.Tray.WinUI/app.manifest
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user