Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 30 additions & 18 deletions src/OpenClaw.Tray.WinUI/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ public IntPtr GetHubWindowHandle()
private GatewayService? _gatewayService;
private CancellationTokenSource? _deepLinkCts;
private bool _isExiting;

/// <summary>
/// Cached connection status — sole writer is OnManagerStateChanged.
/// Reads are safe from any thread; derives from the connection manager's state machine.
Expand Down Expand Up @@ -176,9 +176,10 @@ public IntPtr GetHubWindowHandle()
private DiagnosticsClipboardService? _diagnosticsClipboard;
private ToastService? _toastService;
private AppNotificationService? _appNotificationService;

// Node service (optional, enabled in settings)
private NodeService? _nodeService;

// Keep-alive window to anchor WinUI runtime (prevents GC/threading issues)
private Window? _keepAliveWindow;
private SetupWindow? _setupWindow;
Expand Down Expand Up @@ -232,10 +233,10 @@ public App()
GatewayHostAccessLocalization.Format = (key, args) => LocalizationHelper.Format(key, args);

InitializeComponent();

s_runMarker.Check();
s_runMarker.MarkStarted();

// Hook up crash handlers
this.UnhandledException += OnUnhandledException;
AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandledException;
Expand Down Expand Up @@ -293,15 +294,26 @@ private void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExce

private void OnDomainUnhandledException(object sender, System.UnhandledExceptionEventArgs e)
{
_crashLogger.Log("DomainUnhandledException", e.ExceptionObject as Exception);
var ex = e.ExceptionObject as Exception;
_crashLogger.Log("DomainUnhandledException", ex);

// Log SherpaOnnx finalizer errors clearly so they are visible in diagnostics.
// The actual crash prevention is handled by the exe.config
// legacyUnhandledExceptionPolicy setting and the GC.SuppressFinalize
// call in PiperTextToSpeechClient.Dispose().
if (ex is DllNotFoundException dllEx &&
dllEx.Message.Contains("sherpa-onnx", StringComparison.OrdinalIgnoreCase))
{
Logger.Warn($"SherpaOnnx native DLL unavailable: {dllEx.Message}");
}
}

private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
_crashLogger.Log("UnobservedTaskException", e.Exception);
e.SetObserved(); // Prevent crash
}

private void OnProcessExit(object? sender, EventArgs e)
{
s_runMarker.MarkEnded();
Expand Down Expand Up @@ -679,7 +691,7 @@ private void InitializeKeepAliveWindow()
_keepAliveWindow = new Window();
_keepAliveWindow.Content = new Microsoft.UI.Xaml.Controls.Grid();
_keepAliveWindow.AppWindow.IsShownInSwitchers = false;

// Move off-screen and set minimal size
_keepAliveWindow.AppWindow.MoveAndResize(new global::Windows.Graphics.RectInt32(-32000, -32000, 1, 1));
}
Expand All @@ -688,10 +700,10 @@ private void InitializeTrayIcon()
{
// Initialize keep-alive window first to anchor WinUI runtime
InitializeKeepAliveWindow();

// Pre-create tray menu window at startup to avoid creation crashes later
InitializeTrayMenuWindow();

var iconPath = IconHelper.GetStatusIconPath(ConnectionStatus.Disconnected);
_trayIcon = new TrayIcon(1, iconPath, BuildTrayTooltip());
_trayIcon.IsVisible = true;
Expand Down Expand Up @@ -1002,15 +1014,15 @@ private void OnTrayMenuItemClicked(object? sender, string action)
break;
}
}

private void CopyDeviceIdToClipboard()
{
if (_nodeService?.FullDeviceId == null) return;

try
{
CopyTextToClipboard(_nodeService.FullDeviceId);

// Show toast confirming copy
_toastService!.ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_DeviceIdCopied"))
Expand Down Expand Up @@ -2031,15 +2043,15 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status)
{
Logger.Info($"Node status: {status}");
AddRecentActivity($"Node mode {status}", category: "node", dashboardPath: "nodes");

// In node-only mode, surface node connection in main status indicator
if (_settings?.EnableNodeMode == true)
{
// Status field is maintained by OnManagerStateChanged — no write needed here.
UpdateTrayIcon();
OnUiThread(UpdateStatusDetailWindow);
}

// Don't show "connected" toast if waiting for pairing - we'll show pairing status instead
var nodeService = _nodeService;
if (status == ConnectionStatus.Connected && nodeService?.IsPaired == true)
Expand Down Expand Up @@ -2070,7 +2082,7 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status)
private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatusEventArgs args)
{
Logger.Info($"Pairing status: {args.Status}");

try
{
if (args.Status == OpenClaw.Shared.PairingStatus.Pending)
Expand Down Expand Up @@ -2166,7 +2178,7 @@ public void ShowPairingPendingNotification(string deviceId, string? approvalComm
"node-pairing-pending",
deviceId);
}

private void OnNodeNotificationRequested(object? sender, OpenClaw.Shared.Capabilities.SystemNotifyArgs args)
{
AddRecentActivity(args.Title, category: "node", dashboardPath: "nodes", details: args.Body);
Expand Down Expand Up @@ -3277,7 +3289,7 @@ private async Task ToggleChannelAsync(string channelName)
await client.StartChannelAsync(channelName);
AddRecentActivity($"Started channel: {channelName}", category: "channel", dashboardPath: "settings");
}

// Refresh health
await RunHealthCheckAsync();
}
Expand Down Expand Up @@ -3389,7 +3401,7 @@ private void StartDeepLinkServer()
{
_deepLinkCts = new CancellationTokenSource();
var token = _deepLinkCts.Token;

Task.Run(async () =>
{
while (!token.IsCancellationRequested)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ public sealed class PiperTextToSpeechClient : IDisposable
{
private readonly IOpenClawLogger _logger;
private readonly string _voiceId;
private readonly OfflineTts _tts;
private OfflineTts? _tts;
private readonly SemaphoreSlim _gate = new(1, 1);
private bool _disposed;
private bool _ttsAvailable;

public string VoiceId => _voiceId;
public int SampleRate => _tts.SampleRate;
public int SampleRate => _tts?.SampleRate ?? 22050;

public PiperTextToSpeechClient(IOpenClawLogger logger, PiperVoiceManager voices, string voiceId)
{
Expand All @@ -53,8 +54,18 @@ public PiperTextToSpeechClient(IOpenClawLogger logger, PiperVoiceManager voices,
config.Model.Debug = 0;
config.MaxNumSentences = 2;

_tts = new OfflineTts(config);
_logger.Info($"Piper voice '{_voiceId}' loaded (sample rate {_tts.SampleRate} Hz, {config.Model.NumThreads} threads)");
try
{
_tts = new OfflineTts(config);
_ttsAvailable = true;
_logger.Info($"Piper voice '{_voiceId}' loaded (sample rate {_tts.SampleRate} Hz, {config.Model.NumThreads} threads)");
}
catch (DllNotFoundException ex)
{
_logger.Warn($"Piper voice '{_voiceId}' unavailable: sherpa-onnx native library could not be loaded. TTS will be disabled. ({ex.Message})");
_tts = null;
_ttsAvailable = false;
}
}

/// <summary>
Expand All @@ -65,6 +76,8 @@ public async Task<byte[]> GenerateWavAsync(string text, float speed = 1.0f, Canc
{
if (_disposed) throw new ObjectDisposedException(nameof(PiperTextToSpeechClient));
if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("text must be non-empty", nameof(text));
if (!_ttsAvailable || _tts == null)
throw new InvalidOperationException("Piper TTS is not available: sherpa-onnx native library could not be loaded.");

await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
Expand Down Expand Up @@ -131,7 +144,18 @@ public void Dispose()
if (_disposed) return;
_disposed = true;
// slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved.
try { _tts.Dispose(); } catch { /* swallow */ }
try
{
if (_tts != null)
{
_tts.Dispose();
// CRITICAL: Suppress the finalizer to prevent SherpaOnnxDestroyOfflineTts
// from being called during GC, which crashes the app when the native DLL
// is unavailable (DllNotFoundException in finalizer = instant crash).
GC.SuppressFinalize(_tts);
}
}
catch { /* swallow */ }
// slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved.
try { _gate.Dispose(); } catch { /* swallow */ }
}
Expand Down
Loading