From 7ae86773fdfd82f3a5e99ce8f5175dd44ac64a29 Mon Sep 17 00:00:00 2001 From: Regis Brid Date: Thu, 30 Apr 2026 15:27:58 -0700 Subject: [PATCH 01/10] Add Windows node text-to-speech Implements phase 1 support for the tts.speak command tracked by #252. Adds the shared TTS capability, Windows and ElevenLabs playback paths, Settings UI/persistence, gateway/MCP advertisement, Command Center classification, docs, and tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 33 +-- docs/MCP_MODE.md | 2 +- docs/WINDOWS_NODE_TESTING.md | 2 + docs/gateway-node-integration.md | 2 + .../Capabilities/TtsCapability.cs | 108 +++++++++ src/OpenClaw.Shared/Mcp/McpToolBridge.cs | 4 + src/OpenClaw.Shared/Models.cs | 7 +- src/OpenClaw.Shared/SettingsData.cs | 5 + .../OpenClaw.Tray.WinUI.csproj | 1 + .../Services/NodeService.cs | 21 ++ .../Services/SettingsManager.cs | 73 ++++++ .../ElevenLabsTextToSpeechClient.cs | 116 ++++++++++ .../TextToSpeech/TextToSpeechService.cs | 208 ++++++++++++++++++ .../Windows/SettingsWindow.xaml | 26 +++ .../Windows/SettingsWindow.xaml.cs | 53 +++++ .../OpenClaw.Shared.Tests/CapabilityTests.cs | 172 +++++++++++++++ .../McpToolBridgeTests.cs | 2 + tests/OpenClaw.Shared.Tests/ModelsTests.cs | 2 + .../ElevenLabsTextToSpeechClientTests.cs | 137 ++++++++++++ .../OpenClaw.Tray.Tests.csproj | 3 + .../SettingsRoundTripTests.cs | 38 ++++ .../TrayMenuWindowMarkupTests.cs | 4 + 22 files changed, 1002 insertions(+), 17 deletions(-) create mode 100644 src/OpenClaw.Shared/Capabilities/TtsCapability.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/TextToSpeech/ElevenLabsTextToSpeechClient.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/TextToSpeech/TextToSpeechService.cs create mode 100644 tests/OpenClaw.Tray.Tests/ElevenLabsTextToSpeechClientTests.cs diff --git a/README.md b/README.md index 26815f37..2ca54d10 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ When Node Mode is enabled in Settings, your Windows PC becomes a **node** that t | **Camera** | `camera.list`, `camera.snap`, `camera.clip` | Enumerate cameras and capture still photos or short video clips | | **Location** | `location.get` | Return Windows geolocation when permission is available | | **Device** | `device.info`, `device.status` | Return Windows host/app metadata and lightweight status | +| **Text-to-speech** | `tts.speak` | Speak text aloud through Windows speech synthesis, or ElevenLabs when configured | #### Node Setup @@ -205,23 +206,24 @@ When Node Mode is enabled in Settings, your Windows PC becomes a **node** that t "canvas.hide", "canvas.navigate", "canvas.eval", - "canvas.snapshot", - "canvas.a2ui.push", - "canvas.a2ui.pushJSONL", - "canvas.a2ui.reset", - "screen.snapshot", - "camera.list", - "camera.snap", - "camera.clip", - "location.get", - "device.info", - "device.status" + "canvas.snapshot", + "canvas.a2ui.push", + "canvas.a2ui.pushJSONL", + "canvas.a2ui.reset", + "screen.snapshot", + "camera.list", + "camera.snap", + "camera.clip", + "location.get", + "device.info", + "device.status", + "tts.speak" ] - } - } + } + } } ``` - > ⚠️ **Important**: The gateway has a server-side allowlist. Commands must be listed explicitly - wildcards like `canvas.*` don't work! Privacy-sensitive commands such as `screen.record` should only be added to `allowCommands` when you explicitly want to allow them. + > ⚠️ **Important**: The gateway has a server-side allowlist. Commands must be listed explicitly - wildcards like `canvas.*` don't work! Privacy-sensitive commands such as `screen.record` and agent-driven audio playback via `tts.speak` should only be added to `allowCommands` when you explicitly want to allow them. 5. **Test it** from your Mac/gateway: ```bash @@ -249,6 +251,9 @@ When Node Mode is enabled in Settings, your Windows PC becomes a **node** that t # Take a photo (NV12/MediaCapture fallback) openclaw nodes invoke --node --command camera.snap --params '{"deviceId":"","format":"jpeg","quality":80}' + # Speak text aloud on the Windows node (requires TTS enabled in Settings and tts.speak allowed on the gateway) + openclaw nodes invoke --node --command tts.speak --params '{"text":"Hello from OpenClaw","provider":"windows"}' + # Execute a command on the Windows node openclaw nodes invoke --node --command system.run --params '{"command":"Get-Process | Select -First 5","shell":"powershell","timeoutMs":10000}' diff --git a/docs/MCP_MODE.md b/docs/MCP_MODE.md index 52bfedff..3491a0cb 100644 --- a/docs/MCP_MODE.md +++ b/docs/MCP_MODE.md @@ -4,7 +4,7 @@ ## Summary -The Windows tray app now ships a **local Model Context Protocol (MCP) server** alongside its existing OpenClaw gateway client. The same node capabilities the agent reaches over the OpenClaw gateway WebSocket — `system.run`, `screen.snapshot`, `canvas.*`, `camera.list`, `camera.snap`, `camera.clip`, `location.get`, `system.notify`, `system.execApprovals.*` — are advertised, on the same machine, as MCP tools over `http://127.0.0.1:8765/`. +The Windows tray app now ships a **local Model Context Protocol (MCP) server** alongside its existing OpenClaw gateway client. The same node capabilities the agent reaches over the OpenClaw gateway WebSocket — `system.run`, `screen.snapshot`, `canvas.*`, `camera.list`, `camera.snap`, `camera.clip`, `location.get`, `tts.speak`, `system.notify`, `system.execApprovals.*` — are advertised, on the same machine, as MCP tools over `http://127.0.0.1:8765/`. This means any local MCP client (Claude Desktop, Claude Code, Cursor, an MCP-aware CLI, a custom dev script) can reach into the running tray and drive Windows-native capabilities directly, without an OpenClaw gateway in the loop. The tray app can run in **MCP-only mode** with no gateway connection at all. diff --git a/docs/WINDOWS_NODE_TESTING.md b/docs/WINDOWS_NODE_TESTING.md index 2f60127a..2c20c295 100644 --- a/docs/WINDOWS_NODE_TESTING.md +++ b/docs/WINDOWS_NODE_TESTING.md @@ -61,6 +61,7 @@ These features need the gateway to send `node.invoke` commands: | `location.get` | Get Windows location | Uses Windows location permission/settings | | `device.info` / `device.status` | Device metadata/status | Returns host/app/locale plus battery/storage/network/uptime payloads | | `browser.proxy` | Proxy browser-control host requests | Requires Browser proxy bridge enabled, a compatible browser-control host listening on gateway port + 2, and matching browser-control auth | +| `tts.speak` | Speak text aloud | Requires Text-to-speech playback enabled in Settings; gateway mode also requires `tts.speak` in `gateway.nodes.allowCommands` | ## Capabilities Advertised @@ -72,6 +73,7 @@ When the node connects, it advertises these capabilities: - `location` - Windows.Devices.Geolocation - `device` - Host/app metadata and lightweight status - `browser` - Local `browser.proxy` bridge to a browser-control host on gateway port + 2, when enabled in Settings +- `tts` - Windows speech synthesis or ElevenLabs playback, when enabled in Settings ## Security Features diff --git a/docs/gateway-node-integration.md b/docs/gateway-node-integration.md index ee62390a..17fe3a9f 100644 --- a/docs/gateway-node-integration.md +++ b/docs/gateway-node-integration.md @@ -79,6 +79,8 @@ Add ALL needed commands to `gateway.nodes.allowCommands` in `~/.openclaw/opencla // Device metadata/status "device.info", "device.status", + // Text-to-speech playback (enable only when agent-driven audio is desired) + "tts.speak", // System (already in Windows defaults, but listed for completeness) // "system.run", // "system.run.prepare", diff --git a/src/OpenClaw.Shared/Capabilities/TtsCapability.cs b/src/OpenClaw.Shared/Capabilities/TtsCapability.cs new file mode 100644 index 00000000..c6407828 --- /dev/null +++ b/src/OpenClaw.Shared/Capabilities/TtsCapability.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClaw.Shared.Capabilities; + +public sealed class TtsCapability : NodeCapabilityBase +{ + public const string SpeakCommand = "tts.speak"; + public const string WindowsProvider = "windows"; + public const string ElevenLabsProvider = "elevenlabs"; + public const int MaxTextLength = 5000; + + private static readonly string[] _commands = [SpeakCommand]; + + public override string Category => "tts"; + public override IReadOnlyList Commands => _commands; + + public event Func>? SpeakRequested; + + public TtsCapability(IOpenClawLogger logger) : base(logger) + { + } + + public static string ResolveProvider(string? requestedProvider, string? configuredProvider) + { + var provider = string.IsNullOrWhiteSpace(requestedProvider) + ? configuredProvider + : requestedProvider; + + return string.IsNullOrWhiteSpace(provider) + ? WindowsProvider + : provider.Trim().ToLowerInvariant(); + } + + public override Task ExecuteAsync(NodeInvokeRequest request) + => ExecuteAsync(request, CancellationToken.None); + + public override async Task ExecuteAsync( + NodeInvokeRequest request, + CancellationToken cancellationToken) + { + if (!string.Equals(request.Command, SpeakCommand, StringComparison.Ordinal)) + return Error($"Unknown command: {request.Command}"); + + var text = GetStringArg(request.Args, "text")?.Trim(); + if (string.IsNullOrWhiteSpace(text)) + return Error("Missing required text"); + if (text.Length > MaxTextLength) + return Error($"TTS text exceeds {MaxTextLength} characters."); + + if (SpeakRequested == null) + return Error("TTS speak not available"); + + var args = new TtsSpeakArgs + { + Text = text, + Provider = NormalizeOptional(GetStringArg(request.Args, "provider")), + VoiceId = NormalizeOptional(GetStringArg(request.Args, "voiceId")), + Model = NormalizeOptional(GetStringArg(request.Args, "model")), + Interrupt = GetBoolArg(request.Args, "interrupt") + }; + + Logger.Info($"tts.speak: provider={args.Provider ?? "(default)"}, chars={args.Text.Length}, interrupt={args.Interrupt}"); + + try + { + var result = await SpeakRequested(args, cancellationToken).ConfigureAwait(false); + return Success(new + { + spoken = result.Spoken, + provider = result.Provider, + contentType = result.ContentType, + durationMs = result.DurationMs + }); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return Error("Speak canceled"); + } + catch (Exception ex) + { + Logger.Error("TTS speak failed", ex); + return Error($"Speak failed: {ex.Message}"); + } + } + + private static string? NormalizeOptional(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} + +public sealed class TtsSpeakArgs +{ + public string Text { get; set; } = ""; + public string? Provider { get; set; } + public string? VoiceId { get; set; } + public string? Model { get; set; } + public bool Interrupt { get; set; } +} + +public sealed class TtsSpeakResult +{ + public bool Spoken { get; set; } = true; + public string Provider { get; set; } = TtsCapability.WindowsProvider; + public string? ContentType { get; set; } + public int? DurationMs { get; set; } +} diff --git a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs index e3d19dce..11c22fbc 100644 --- a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs +++ b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs @@ -229,6 +229,10 @@ private object HandleToolsList() "Capture a still photo from a camera. Args: deviceId (string, optional — defaults to system default camera), format ('jpeg'|'png', default 'jpeg'), maxWidth (int, default 1280), quality (int 1-100, default 80). Returns { format, width, height, base64 }.", ["camera.clip"] = "Record a short clip from a camera. Args: deviceId (string, optional), durationMs (int, required, max 60000), format ('mp4'|'webm', default 'mp4'), maxWidth (int, default 1280). Returns { format, durationMs, base64 }.", + + // tts.* + ["tts.speak"] = + "Speak text aloud on the Windows node. Args: text (string, required), provider ('windows'|'elevenlabs', optional), voiceId (string, optional), model (string, optional), interrupt (bool, default false). Returns { spoken, provider, contentType, durationMs }.", }; private async Task HandleToolsCallAsync(JsonElement parameters, CancellationToken cancellationToken) diff --git a/src/OpenClaw.Shared/Models.cs b/src/OpenClaw.Shared/Models.cs index 0d7888a8..c71fac0d 100644 --- a/src/OpenClaw.Shared/Models.cs +++ b/src/OpenClaw.Shared/Models.cs @@ -1023,7 +1023,8 @@ public static class CommandCenterCommandGroups [ "camera.snap", "camera.clip", - "screen.record" + "screen.record", + "tts.speak" ]; public static readonly FrozenSet DangerousCommandSet = @@ -1046,7 +1047,9 @@ public static class CommandCenterCommandGroups public static readonly string[] MacNodeParityCommands = [ .. SafeCompanionCommands, - .. DangerousCommands, + "camera.snap", + "camera.clip", + "screen.record", "system.notify", "system.run", "system.which", diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs index 6f3fe495..a98aaa20 100644 --- a/src/OpenClaw.Shared/SettingsData.cs +++ b/src/OpenClaw.Shared/SettingsData.cs @@ -34,6 +34,11 @@ public class SettingsData public bool NodeCameraEnabled { get; set; } = true; public bool NodeLocationEnabled { get; set; } = true; public bool NodeBrowserProxyEnabled { get; set; } = true; + public bool NodeTtsEnabled { get; set; } = false; + public string TtsProvider { get; set; } = "windows"; + public string? TtsElevenLabsApiKey { get; set; } + public string? TtsElevenLabsModel { get; set; } + public string? TtsElevenLabsVoiceId { get; set; } /// Run the local MCP HTTP server. Independent of EnableNodeMode. public bool EnableMcpServer { get; set; } = false; /// diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index 3285f99a..3d4f72f4 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -56,6 +56,7 @@ + diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index 2d9a6ac9..f6abf77a 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -69,6 +69,8 @@ public sealed class NodeService : IDisposable private LocationCapability? _locationCapability; private DeviceCapability? _deviceCapability; private BrowserProxyCapability? _browserProxyCapability; + private TtsCapability? _ttsCapability; + private TextToSpeechService? _textToSpeechService; private readonly string _dataPath; private string? _token; @@ -282,6 +284,14 @@ private void RegisterCapabilities() Register(_locationCapability); } + if (_settings?.NodeTtsEnabled == true) + { + _textToSpeechService ??= new TextToSpeechService(_logger, _settings); + _ttsCapability = new TtsCapability(_logger); + _ttsCapability.SpeakRequested += OnTtsSpeakAsync; + Register(_ttsCapability); + } + // Device metadata/status capability _deviceCapability = new DeviceCapability(_logger); Register(_deviceCapability); @@ -447,6 +457,8 @@ private List BuildDisabledCommands() disabled.AddRange(CommandCenterCommandGroups.SafeCompanionCommands.Where(command => command.StartsWith("location.", StringComparison.OrdinalIgnoreCase))); if (_settings?.NodeBrowserProxyEnabled == false) disabled.Add("browser.proxy"); + if (_settings?.NodeTtsEnabled != true) + disabled.AddRange(CommandCenterCommandGroups.DangerousCommands.Where(command => command.StartsWith("tts.", StringComparison.OrdinalIgnoreCase))); return disabled; } @@ -1265,6 +1277,14 @@ private async Task GetLocationAsync(LocationGetArgs args) TimestampMs = position.Coordinate.Timestamp.ToUnixTimeMilliseconds() }; } + + private Task OnTtsSpeakAsync(TtsSpeakArgs args, CancellationToken cancellationToken) + { + if (_textToSpeechService == null) + throw new InvalidOperationException("Text-to-speech service not available"); + + return _textToSpeechService.SpeakAsync(args, cancellationToken); + } #endregion @@ -1278,6 +1298,7 @@ public void Dispose() try { _cameraCaptureService?.Dispose(); } catch { /* ignore */ } try { _screenRecordingService?.Dispose(); } catch { /* ignore */ } + try { _textToSpeechService?.Dispose(); } catch { /* ignore */ } // MediaResolver owns SocketsHttpHandler + HttpClient (disposeHandler:true); // without disposal the connection pool survives node teardown/recreate. try { _mediaResolver?.Dispose(); } catch { /* ignore */ } diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs index 0afbf9b6..b9f5cae0 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs @@ -1,5 +1,7 @@ using System; using System.IO; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using OpenClaw.Shared; @@ -20,6 +22,8 @@ public class SettingsManager "OpenClawTray"); private static readonly string SettingsFilePath = Path.Combine(SettingsDirectory, "settings.json"); + private const string ProtectedSecretPrefix = "dpapi:"; + private static readonly byte[] ProtectedSecretEntropy = Encoding.UTF8.GetBytes("OpenClawTray.Settings.v1"); public static string SettingsDirectoryPath => SettingsDirectory; public static string SettingsPath => SettingsFilePath; @@ -64,6 +68,11 @@ public class SettingsManager public bool NodeCameraEnabled { get; set; } = true; public bool NodeLocationEnabled { get; set; } = true; public bool NodeBrowserProxyEnabled { get; set; } = true; + public bool NodeTtsEnabled { get; set; } = false; + public string TtsProvider { get; set; } = "windows"; + public string TtsElevenLabsApiKey { get; set; } = ""; + public string TtsElevenLabsModel { get; set; } = ""; + public string TtsElevenLabsVoiceId { get; set; } = ""; // Local MCP HTTP server (independent of EnableNodeMode) public bool EnableMcpServer { get; set; } = false; /// @@ -117,6 +126,11 @@ public void Load() NodeCameraEnabled = loaded.NodeCameraEnabled; NodeLocationEnabled = loaded.NodeLocationEnabled; NodeBrowserProxyEnabled = loaded.NodeBrowserProxyEnabled; + NodeTtsEnabled = loaded.NodeTtsEnabled; + TtsProvider = string.IsNullOrWhiteSpace(loaded.TtsProvider) ? TtsProvider : loaded.TtsProvider; + TtsElevenLabsApiKey = UnprotectSettingSecret(loaded.TtsElevenLabsApiKey) ?? TtsElevenLabsApiKey; + TtsElevenLabsModel = loaded.TtsElevenLabsModel ?? TtsElevenLabsModel; + TtsElevenLabsVoiceId = loaded.TtsElevenLabsVoiceId ?? TtsElevenLabsVoiceId; EnableMcpServer = loaded.EnableMcpServer; A2UIImageHosts = loaded.A2UIImageHosts ?? new List(); // Legacy McpOnlyMode migration: @@ -185,6 +199,11 @@ public void Save() NodeCameraEnabled = NodeCameraEnabled, NodeLocationEnabled = NodeLocationEnabled, NodeBrowserProxyEnabled = NodeBrowserProxyEnabled, + NodeTtsEnabled = NodeTtsEnabled, + TtsProvider = TtsProvider, + TtsElevenLabsApiKey = ProtectSettingSecret(TtsElevenLabsApiKey), + TtsElevenLabsModel = string.IsNullOrWhiteSpace(TtsElevenLabsModel) ? null : TtsElevenLabsModel, + TtsElevenLabsVoiceId = string.IsNullOrWhiteSpace(TtsElevenLabsVoiceId) ? null : TtsElevenLabsVoiceId, EnableMcpServer = EnableMcpServer, A2UIImageHosts = A2UIImageHosts.Count == 0 ? null : new List(A2UIImageHosts), // McpOnlyMode is legacy — never written; remains null in serialized output. @@ -206,6 +225,60 @@ public void Save() } } + internal static string? ProtectSettingSecret(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("Windows Data Protection API is required for protected settings secrets."); + + var bytes = Encoding.UTF8.GetBytes(value); + var protectedBytes = ProtectedData.Protect(bytes, ProtectedSecretEntropy, DataProtectionScope.CurrentUser); + return ProtectedSecretPrefix + Convert.ToBase64String(protectedBytes); + } + + internal static string? UnprotectSettingSecret(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return value; + if (!value.StartsWith(ProtectedSecretPrefix, StringComparison.Ordinal)) + return value; + + if (!OperatingSystem.IsWindows()) + { + Logger.Warn("Failed to decrypt protected settings secret: Windows Data Protection API is unavailable."); + return null; + } + + try + { + var protectedBytes = Convert.FromBase64String(value[ProtectedSecretPrefix.Length..]); + var bytes = ProtectedData.Unprotect(protectedBytes, ProtectedSecretEntropy, DataProtectionScope.CurrentUser); + return Encoding.UTF8.GetString(bytes); + } + catch (FormatException ex) + { + Logger.Warn($"Failed to decode protected settings secret: {ex.Message}"); + return null; + } + catch (CryptographicException ex) + { + Logger.Warn($"Failed to decrypt protected settings secret: {ex.Message}"); + return null; + } + catch (NotSupportedException ex) + { + Logger.Warn($"Failed to decrypt protected settings secret: {ex.Message}"); + return null; + } + catch (ArgumentException ex) + { + Logger.Warn($"Failed to decrypt protected settings secret: {ex.Message}"); + return null; + } + } + public string GetEffectiveGatewayUrl() { if (!UseSshTunnel) diff --git a/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/ElevenLabsTextToSpeechClient.cs b/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/ElevenLabsTextToSpeechClient.cs new file mode 100644 index 00000000..864a6cc5 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/ElevenLabsTextToSpeechClient.cs @@ -0,0 +1,116 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenClaw.Shared.Capabilities; + +namespace OpenClawTray.Services; + +public sealed class ElevenLabsSynthesisRequest +{ + public string ApiKey { get; set; } = ""; + public string VoiceId { get; set; } = ""; + public string Text { get; set; } = ""; + public string? ModelId { get; set; } +} + +public sealed class ElevenLabsSynthesisResult +{ + public byte[] AudioBytes { get; set; } = []; + public string ContentType { get; set; } = "audio/mpeg"; +} + +public sealed class ElevenLabsTextToSpeechClient : IDisposable +{ + private const string DefaultBaseUrl = "https://api.elevenlabs.io"; + public const int MaxTextLength = TtsCapability.MaxTextLength; + internal static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30); + private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + private readonly Uri _baseUri; + + internal TimeSpan Timeout => _httpClient.Timeout; + + public ElevenLabsTextToSpeechClient() + : this(new HttpClient(), ownsHttpClient: true, baseUrl: DefaultBaseUrl) + { + } + + public ElevenLabsTextToSpeechClient(HttpMessageHandler handler, string baseUrl = DefaultBaseUrl) + : this(new HttpClient(handler), ownsHttpClient: true, baseUrl) + { + } + + private ElevenLabsTextToSpeechClient(HttpClient httpClient, bool ownsHttpClient, string baseUrl) + { + _httpClient = httpClient; + _httpClient.Timeout = DefaultTimeout; + _ownsHttpClient = ownsHttpClient; + _baseUri = new Uri(baseUrl.TrimEnd('/') + "/", UriKind.Absolute); + } + + public async Task SynthesizeAsync( + ElevenLabsSynthesisRequest request, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(request.ApiKey)) + throw new InvalidOperationException("ElevenLabs API key is required."); + if (string.IsNullOrWhiteSpace(request.VoiceId)) + throw new InvalidOperationException("ElevenLabs voice ID is required."); + if (string.IsNullOrWhiteSpace(request.Text)) + throw new InvalidOperationException("Text is required."); + if (request.Text.Length > MaxTextLength) + throw new InvalidOperationException($"ElevenLabs TTS text exceeds {MaxTextLength} characters."); + + var path = $"v1/text-to-speech/{Uri.EscapeDataString(request.VoiceId.Trim())}"; + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, new Uri(_baseUri, path)); + httpRequest.Headers.Add("xi-api-key", request.ApiKey.Trim()); + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("audio/mpeg")); + + var body = JsonSerializer.Serialize(new + { + text = request.Text, + model_id = string.IsNullOrWhiteSpace(request.ModelId) ? null : request.ModelId.Trim() + }); + httpRequest.Content = new StringContent(body, Encoding.UTF8, "application/json"); + + using var response = await _httpClient.SendAsync( + httpRequest, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + throw new InvalidOperationException(BuildFailureMessage(response.StatusCode, bytes)); + if (bytes.Length == 0) + throw new InvalidOperationException("ElevenLabs returned an empty audio response."); + + return new ElevenLabsSynthesisResult + { + AudioBytes = bytes, + ContentType = response.Content.Headers.ContentType?.MediaType ?? "audio/mpeg" + }; + } + + internal static string BuildFailureMessage(HttpStatusCode statusCode, byte[] bodyBytes) + { + var body = Encoding.UTF8.GetString(bodyBytes); + if (body.Length > 300) + body = body[..300]; + body = body.Trim(); + + return string.IsNullOrEmpty(body) + ? $"ElevenLabs TTS failed with HTTP {(int)statusCode} ({statusCode})." + : $"ElevenLabs TTS failed with HTTP {(int)statusCode} ({statusCode}): {body}"; + } + + public void Dispose() + { + if (_ownsHttpClient) + _httpClient.Dispose(); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/TextToSpeechService.cs b/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/TextToSpeechService.cs new file mode 100644 index 00000000..13be3bfc --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/TextToSpeech/TextToSpeechService.cs @@ -0,0 +1,208 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using OpenClaw.Shared; +using OpenClaw.Shared.Capabilities; +using Windows.Media.Core; +using Windows.Media.Playback; +using Windows.Media.SpeechSynthesis; +using Windows.Storage.Streams; + +namespace OpenClawTray.Services; + +public sealed class TextToSpeechService : IDisposable +{ + private readonly IOpenClawLogger _logger; + private readonly SettingsManager _settings; + private readonly ElevenLabsTextToSpeechClient _elevenLabsClient; + private readonly SemaphoreSlim _playbackGate = new(1, 1); + private readonly object _activeLock = new(); + private MediaPlayer? _activePlayer; + private TaskCompletionSource? _activeCompletion; + + public TextToSpeechService(IOpenClawLogger logger, SettingsManager settings) + : this(logger, settings, new ElevenLabsTextToSpeechClient()) + { + } + + internal TextToSpeechService( + IOpenClawLogger logger, + SettingsManager settings, + ElevenLabsTextToSpeechClient elevenLabsClient) + { + _logger = logger; + _settings = settings; + _elevenLabsClient = elevenLabsClient; + } + + public async Task SpeakAsync(TtsSpeakArgs args, CancellationToken cancellationToken = default) + { + var provider = TtsCapability.ResolveProvider(args.Provider, _settings.TtsProvider); + var stopwatch = Stopwatch.StartNew(); + + if (string.Equals(provider, TtsCapability.WindowsProvider, StringComparison.OrdinalIgnoreCase)) + { + await SpeakWithWindowsAsync(args, cancellationToken).ConfigureAwait(false); + } + else if (string.Equals(provider, TtsCapability.ElevenLabsProvider, StringComparison.OrdinalIgnoreCase)) + { + await SpeakWithElevenLabsAsync(args, cancellationToken).ConfigureAwait(false); + } + else + { + throw new InvalidOperationException($"Unsupported TTS provider '{provider}'."); + } + + stopwatch.Stop(); + return new TtsSpeakResult + { + Provider = provider, + ContentType = string.Equals(provider, TtsCapability.ElevenLabsProvider, StringComparison.OrdinalIgnoreCase) + ? "audio/mpeg" + : "audio/wav", + DurationMs = (int)Math.Min(stopwatch.ElapsedMilliseconds, int.MaxValue) + }; + } + + private async Task SpeakWithWindowsAsync(TtsSpeakArgs args, CancellationToken cancellationToken) + { + using var synthesizer = new SpeechSynthesizer(); + if (!string.IsNullOrWhiteSpace(args.VoiceId)) + { + var requestedVoice = args.VoiceId.Trim(); + var voice = SpeechSynthesizer.AllVoices.FirstOrDefault(v => + string.Equals(v.Id, requestedVoice, StringComparison.OrdinalIgnoreCase) || + string.Equals(v.DisplayName, requestedVoice, StringComparison.OrdinalIgnoreCase)); + if (voice == null) + throw new InvalidOperationException($"Windows TTS voice '{requestedVoice}' was not found."); + + synthesizer.Voice = voice; + } + + using var stream = await synthesizer + .SynthesizeTextToStreamAsync(args.Text) + .AsTask(cancellationToken) + .ConfigureAwait(false); + await PlayStreamAsync(stream, stream.ContentType, args.Interrupt, cancellationToken).ConfigureAwait(false); + } + + private async Task SpeakWithElevenLabsAsync(TtsSpeakArgs args, CancellationToken cancellationToken) + { + var apiKey = _settings.TtsElevenLabsApiKey; + if (string.IsNullOrWhiteSpace(apiKey)) + throw new InvalidOperationException("ElevenLabs API key is required in Settings."); + + var voiceId = string.IsNullOrWhiteSpace(args.VoiceId) + ? _settings.TtsElevenLabsVoiceId + : args.VoiceId; + if (string.IsNullOrWhiteSpace(voiceId)) + throw new InvalidOperationException("ElevenLabs voice ID is required in Settings or the tts.speak voiceId argument."); + + var model = string.IsNullOrWhiteSpace(args.Model) + ? _settings.TtsElevenLabsModel + : args.Model; + + var audio = await _elevenLabsClient.SynthesizeAsync(new ElevenLabsSynthesisRequest + { + ApiKey = apiKey, + VoiceId = voiceId, + Text = args.Text, + ModelId = model + }, cancellationToken).ConfigureAwait(false); + + using var stream = await CreateStreamAsync(audio.AudioBytes, cancellationToken).ConfigureAwait(false); + await PlayStreamAsync(stream, audio.ContentType, args.Interrupt, cancellationToken).ConfigureAwait(false); + } + + private static async Task CreateStreamAsync(byte[] bytes, CancellationToken cancellationToken) + { + var stream = new InMemoryRandomAccessStream(); + using var writer = new DataWriter(stream); + writer.WriteBytes(bytes); + await writer.StoreAsync().AsTask(cancellationToken).ConfigureAwait(false); + await writer.FlushAsync().AsTask(cancellationToken).ConfigureAwait(false); + writer.DetachStream(); + stream.Seek(0); + return stream; + } + + private async Task PlayStreamAsync( + IRandomAccessStream stream, + string contentType, + bool interrupt, + CancellationToken cancellationToken) + { + if (interrupt) + InterruptActivePlayback(); + + await _playbackGate.WaitAsync(cancellationToken).ConfigureAwait(false); + + MediaPlayer? player = null; + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + try + { + player = new MediaPlayer(); + player.MediaEnded += (_, _) => completion.TrySetResult(true); + player.MediaFailed += (_, e) => + completion.TrySetException(new InvalidOperationException($"TTS playback failed: {e.ErrorMessage}")); + player.Source = MediaSource.CreateFromStream(stream, contentType); + + lock (_activeLock) + { + _activePlayer = player; + _activeCompletion = completion; + } + + player.Play(); + + using var cancellationRegistration = cancellationToken.Register( + static state => ((TaskCompletionSource)state!).TrySetCanceled(), + completion); + await completion.Task.ConfigureAwait(false); + } + finally + { + lock (_activeLock) + { + if (ReferenceEquals(_activePlayer, player)) + { + _activePlayer = null; + _activeCompletion = null; + } + } + + if (player != null) + { + player.Pause(); + player.Source = null; + player.Dispose(); + } + + _playbackGate.Release(); + } + } + + private void InterruptActivePlayback() + { + TaskCompletionSource? completion; + lock (_activeLock) + { + completion = _activeCompletion; + } + + if (completion != null) + { + _logger.Info("Interrupting active TTS playback"); + completion.TrySetException(new InvalidOperationException("TTS playback was interrupted.")); + } + } + + public void Dispose() + { + InterruptActivePlayback(); + // Playback may still release the gate after an interrupt during shutdown. + _elevenLabsClient.Dispose(); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml index e5dbee62..e716fe07 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml +++ b/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml @@ -200,6 +200,32 @@ + + + + + + + + + + + diff --git a/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml.cs index 89f0b91b..35ded837 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml.cs @@ -94,6 +94,12 @@ private void LoadSettings() NodeCameraToggle.IsOn = _settings.NodeCameraEnabled; NodeLocationToggle.IsOn = _settings.NodeLocationEnabled; NodeBrowserProxyToggle.IsOn = _settings.NodeBrowserProxyEnabled; + NodeTtsToggle.IsOn = _settings.NodeTtsEnabled; + SelectTtsProvider(_settings.TtsProvider); + TtsElevenLabsApiKeyPasswordBox.Password = _settings.TtsElevenLabsApiKey; + TtsElevenLabsVoiceIdTextBox.Text = _settings.TtsElevenLabsVoiceId; + TtsElevenLabsModelTextBox.Text = _settings.TtsElevenLabsModel; + UpdateTtsProviderUiState(); UpdateSshTunnelPreviewText(); McpServerToggle.IsOn = _settings.EnableMcpServer; McpUrlTextBox.Text = NodeService.McpServerUrl; @@ -386,6 +392,11 @@ private void SaveSettings() _settings.NodeCameraEnabled = NodeCameraToggle.IsOn; _settings.NodeLocationEnabled = NodeLocationToggle.IsOn; _settings.NodeBrowserProxyEnabled = NodeBrowserProxyToggle.IsOn; + _settings.NodeTtsEnabled = NodeTtsToggle.IsOn; + _settings.TtsProvider = GetSelectedTtsProvider(); + _settings.TtsElevenLabsApiKey = TtsElevenLabsApiKeyPasswordBox.Password.Trim(); + _settings.TtsElevenLabsVoiceId = TtsElevenLabsVoiceIdTextBox.Text.Trim(); + _settings.TtsElevenLabsModel = TtsElevenLabsModelTextBox.Text.Trim(); _settings.EnableMcpServer = McpServerToggle.IsOn; _settings.Save(); @@ -630,6 +641,48 @@ private void OnNodeBrowserProxyToggled(object sender, RoutedEventArgs e) UpdateSshTunnelPreviewText(); } + private void OnTtsProviderSelectionChanged(object sender, Microsoft.UI.Xaml.Controls.SelectionChangedEventArgs e) + { + UpdateTtsProviderUiState(); + } + + private void SelectTtsProvider(string provider) + { + for (int i = 0; i < TtsProviderComboBox.Items.Count; i++) + { + if (TtsProviderComboBox.Items[i] is Microsoft.UI.Xaml.Controls.ComboBoxItem item && + string.Equals(item.Tag?.ToString(), provider, StringComparison.OrdinalIgnoreCase)) + { + TtsProviderComboBox.SelectedIndex = i; + return; + } + } + + TtsProviderComboBox.SelectedIndex = 0; + } + + private string GetSelectedTtsProvider() + { + if (TtsProviderComboBox.SelectedItem is Microsoft.UI.Xaml.Controls.ComboBoxItem item && + item.Tag is not null) + { + return item.Tag.ToString() ?? "windows"; + } + + return "windows"; + } + + private void UpdateTtsProviderUiState() + { + if (TtsElevenLabsSettingsPanel == null) + return; + + TtsElevenLabsSettingsPanel.Visibility = + string.Equals(GetSelectedTtsProvider(), "elevenlabs", StringComparison.OrdinalIgnoreCase) + ? Visibility.Visible + : Visibility.Collapsed; + } + private void OnUseLocalGateway(object sender, RoutedEventArgs e) { UseSshTunnelToggle.IsOn = false; diff --git a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs index 57dacb42..bfb55043 100644 --- a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs @@ -2088,6 +2088,178 @@ public async Task Clip_ClampsZeroAndNegativeDuration_ToMinimum() } } +public class TtsCapabilityTests +{ + private static JsonElement Parse(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + [Fact] + public void CanHandle_TtsSpeak() + { + var cap = new TtsCapability(NullLogger.Instance); + + Assert.True(cap.CanHandle("tts.speak")); + Assert.False(cap.CanHandle("tts.stop")); + Assert.Equal("tts", cap.Category); + } + + [Theory] + [InlineData("elevenlabs", "windows", "elevenlabs")] + [InlineData(" ELEVENLABS ", "windows", "elevenlabs")] + [InlineData(null, "elevenlabs", "elevenlabs")] + [InlineData(" ", "elevenlabs", "elevenlabs")] + [InlineData(null, "", "windows")] + [InlineData(null, " ", "windows")] + public void ResolveProvider_NormalizesRequestedAndConfiguredValues( + string? requestedProvider, + string? configuredProvider, + string expected) + { + Assert.Equal(expected, TtsCapability.ResolveProvider(requestedProvider, configuredProvider)); + } + + [Fact] + public async Task Speak_ReturnsError_WhenTextMissing() + { + var cap = new TtsCapability(NullLogger.Instance); + var handlerCalled = false; + cap.SpeakRequested += (_, _) => + { + handlerCalled = true; + return Task.FromResult(new TtsSpeakResult()); + }; + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "tts-missing", + Command = "tts.speak", + Args = Parse("""{"text":" "}""") + }); + + Assert.False(res.Ok); + Assert.False(handlerCalled); + Assert.Contains("text", res.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Speak_ReturnsError_WhenNoHandler() + { + var cap = new TtsCapability(NullLogger.Instance); + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "tts-unavailable", + Command = "tts.speak", + Args = Parse("""{"text":"hello"}""") + }); + + Assert.False(res.Ok); + Assert.Contains("not available", res.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Speak_ReturnsError_WhenTextTooLong() + { + var cap = new TtsCapability(NullLogger.Instance); + var handlerCalled = false; + cap.SpeakRequested += (_, _) => + { + handlerCalled = true; + return Task.FromResult(new TtsSpeakResult()); + }; + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "tts-too-long", + Command = "tts.speak", + Args = Parse(JsonSerializer.Serialize(new + { + text = new string('x', TtsCapability.MaxTextLength + 1) + })) + }); + + Assert.False(res.Ok); + Assert.False(handlerCalled); + Assert.Contains(TtsCapability.MaxTextLength.ToString(), res.Error); + } + + [Fact] + public async Task Speak_RaisesEvent_WithArgs() + { + var cap = new TtsCapability(NullLogger.Instance); + TtsSpeakArgs? received = null; + cap.SpeakRequested += (args, _) => + { + received = args; + return Task.FromResult(new TtsSpeakResult + { + Provider = TtsCapability.ElevenLabsProvider, + ContentType = "audio/mpeg", + DurationMs = 123 + }); + }; + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "tts-args", + Command = "tts.speak", + Args = Parse("""{"text":" hello world ","provider":"elevenlabs","voiceId":"voice-1","model":"model-1","interrupt":true}""") + }); + + Assert.True(res.Ok); + Assert.NotNull(received); + Assert.Equal("hello world", received!.Text); + Assert.Equal("elevenlabs", received.Provider); + Assert.Equal("voice-1", received.VoiceId); + Assert.Equal("model-1", received.Model); + Assert.True(received.Interrupt); + + var json = JsonSerializer.Serialize(res.Payload); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + Assert.True(root.GetProperty("spoken").GetBoolean()); + Assert.Equal("elevenlabs", root.GetProperty("provider").GetString()); + Assert.Equal("audio/mpeg", root.GetProperty("contentType").GetString()); + Assert.Equal(123, root.GetProperty("durationMs").GetInt32()); + } + + [Fact] + public async Task Speak_ReturnsError_WhenHandlerThrows() + { + var cap = new TtsCapability(NullLogger.Instance); + cap.SpeakRequested += (_, _) => throw new InvalidOperationException("Audio device unavailable"); + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "tts-fail", + Command = "tts.speak", + Args = Parse("""{"text":"hello"}""") + }); + + Assert.False(res.Ok); + Assert.Contains("Audio device unavailable", res.Error); + } + + [Fact] + public async Task UnknownCommand_ReturnsError() + { + var cap = new TtsCapability(NullLogger.Instance); + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "tts-unknown", + Command = "tts.stop", + Args = Parse("""{}""") + }); + + Assert.False(res.Ok); + Assert.Contains("Unknown command", res.Error); + } +} + public class LocationCapabilityTests { private static JsonElement Parse(string json) diff --git a/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs b/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs index 198141c6..402ea2b6 100644 --- a/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs +++ b/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs @@ -78,6 +78,7 @@ public async Task ToolsList_KnownCommands_GetCuratedDescriptions() new FakeCapability("canvas", "canvas.a2ui.push"), new FakeCapability("screen", "screen.snapshot"), new FakeCapability("camera", "camera.snap"), + new FakeCapability("tts", "tts.speak"), new FakeCapability("custom", "custom.unknown"), }; var bridge = CreateBridge(caps); @@ -95,6 +96,7 @@ public async Task ToolsList_KnownCommands_GetCuratedDescriptions() Assert.Contains("A2UI v0.8", byName["canvas.a2ui.push"]); Assert.Contains("screenshot", byName["screen.snapshot"]); Assert.Contains("camera", byName["camera.snap"], System.StringComparison.OrdinalIgnoreCase); + Assert.Contains("Speak text", byName["tts.speak"]); // Unknown commands keep the generic fallback so newly-added capabilities still render. Assert.Equal("custom capability: custom.unknown", byName["custom.unknown"]); diff --git a/tests/OpenClaw.Shared.Tests/ModelsTests.cs b/tests/OpenClaw.Shared.Tests/ModelsTests.cs index bef5095f..f95853f3 100644 --- a/tests/OpenClaw.Shared.Tests/ModelsTests.cs +++ b/tests/OpenClaw.Shared.Tests/ModelsTests.cs @@ -918,6 +918,8 @@ public void CommandGroups_IncludeCurrentSafeParityCommands() Assert.Contains("device.info", CommandCenterCommandGroups.SafeCompanionCommands); Assert.Contains("device.status", CommandCenterCommandGroups.SafeCompanionCommands); Assert.Contains("screen.record", CommandCenterCommandGroups.DangerousCommands); + Assert.Contains("tts.speak", CommandCenterCommandGroups.DangerousCommands); + Assert.DoesNotContain("tts.speak", CommandCenterCommandGroups.MacNodeParityCommands); Assert.Contains("browser.proxy", CommandCenterCommandGroups.BrowserCommands); Assert.Contains("browser.proxy", CommandCenterCommandGroups.MacNodeParityCommands); } diff --git a/tests/OpenClaw.Tray.Tests/ElevenLabsTextToSpeechClientTests.cs b/tests/OpenClaw.Tray.Tests/ElevenLabsTextToSpeechClientTests.cs new file mode 100644 index 00000000..bf0aed75 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/ElevenLabsTextToSpeechClientTests.cs @@ -0,0 +1,137 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +public class ElevenLabsTextToSpeechClientTests +{ + [Fact] + public async Task SynthesizeAsync_PostsExpectedRequest() + { + var handler = new CapturingHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([1, 2, 3]) + { + Headers = { ContentType = new("audio/mpeg") } + } + }); + var client = new ElevenLabsTextToSpeechClient(handler, "https://example.test"); + + var result = await client.SynthesizeAsync(new ElevenLabsSynthesisRequest + { + ApiKey = "key-123", + VoiceId = "voice/with slash", + Text = "Hello", + ModelId = "model-1" + }); + + Assert.Equal([1, 2, 3], result.AudioBytes); + Assert.Equal("audio/mpeg", result.ContentType); + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); + Assert.Equal("https://example.test/v1/text-to-speech/voice%2Fwith%20slash", handler.LastRequest.RequestUri!.AbsoluteUri); + Assert.True(handler.LastRequest.Headers.TryGetValues("xi-api-key", out var keyValues)); + Assert.Contains("key-123", keyValues); + + using var doc = JsonDocument.Parse(handler.LastBody!); + Assert.Equal("Hello", doc.RootElement.GetProperty("text").GetString()); + Assert.Equal("model-1", doc.RootElement.GetProperty("model_id").GetString()); + } + + [Fact] + public async Task SynthesizeAsync_ReturnsErrorMessageForProviderFailure() + { + var handler = new CapturingHandler(new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent("""{"detail":"bad key"}""") + }); + var client = new ElevenLabsTextToSpeechClient(handler, "https://example.test"); + + var ex = await Assert.ThrowsAsync(() => client.SynthesizeAsync(new ElevenLabsSynthesisRequest + { + ApiKey = "bad", + VoiceId = "voice-1", + Text = "Hello" + })); + + Assert.Contains("401", ex.Message); + Assert.Contains("bad key", ex.Message); + } + + [Fact] + public async Task SynthesizeAsync_ValidatesRequiredFieldsBeforeNetwork() + { + var handler = new CapturingHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([1]) + }); + var client = new ElevenLabsTextToSpeechClient(handler, "https://example.test"); + + await Assert.ThrowsAsync(() => client.SynthesizeAsync(new ElevenLabsSynthesisRequest + { + ApiKey = "", + VoiceId = "voice-1", + Text = "Hello" + })); + Assert.Null(handler.LastRequest); + } + + [Fact] + public async Task SynthesizeAsync_RejectsOversizedTextBeforeNetwork() + { + var handler = new CapturingHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([1]) + }); + var client = new ElevenLabsTextToSpeechClient(handler, "https://example.test"); + + var ex = await Assert.ThrowsAsync(() => client.SynthesizeAsync(new ElevenLabsSynthesisRequest + { + ApiKey = "key-123", + VoiceId = "voice-1", + Text = new string('x', ElevenLabsTextToSpeechClient.MaxTextLength + 1) + })); + + Assert.Contains(ElevenLabsTextToSpeechClient.MaxTextLength.ToString(), ex.Message); + Assert.Null(handler.LastRequest); + } + + [Fact] + public void Constructor_SetsRequestTimeout() + { + var handler = new CapturingHandler(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([1]) + }); + + using var client = new ElevenLabsTextToSpeechClient(handler, "https://example.test"); + + Assert.Equal(ElevenLabsTextToSpeechClient.DefaultTimeout, client.Timeout); + } + + private sealed class CapturingHandler : HttpMessageHandler + { + private readonly HttpResponseMessage _response; + + public HttpRequestMessage? LastRequest { get; private set; } + public string? LastBody { get; private set; } + + public CapturingHandler(HttpResponseMessage response) + { + _response = response; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + LastRequest = request; + LastBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + return _response; + } + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 7159ef27..ffa4c31e 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -16,12 +16,15 @@ + + + diff --git a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs index 231fba46..6181bb34 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using OpenClaw.Shared; +using OpenClawTray.Services; namespace OpenClaw.Tray.Tests; @@ -36,6 +37,11 @@ public void RoundTrip_AllFields_Preserved() NodeCameraEnabled = false, NodeLocationEnabled = true, NodeBrowserProxyEnabled = false, + NodeTtsEnabled = true, + TtsProvider = "elevenlabs", + TtsElevenLabsApiKey = "elevenlabs-key", + TtsElevenLabsModel = "eleven_multilingual_v2", + TtsElevenLabsVoiceId = "voice-123", HasSeenActivityStreamTip = true, SkippedUpdateTag = "v1.2.3", NotifyChatResponses = false, @@ -76,6 +82,11 @@ public void RoundTrip_AllFields_Preserved() Assert.Equal(original.NodeCameraEnabled, restored.NodeCameraEnabled); Assert.Equal(original.NodeLocationEnabled, restored.NodeLocationEnabled); Assert.Equal(original.NodeBrowserProxyEnabled, restored.NodeBrowserProxyEnabled); + Assert.Equal(original.NodeTtsEnabled, restored.NodeTtsEnabled); + Assert.Equal(original.TtsProvider, restored.TtsProvider); + Assert.Equal(original.TtsElevenLabsApiKey, restored.TtsElevenLabsApiKey); + Assert.Equal(original.TtsElevenLabsModel, restored.TtsElevenLabsModel); + Assert.Equal(original.TtsElevenLabsVoiceId, restored.TtsElevenLabsVoiceId); Assert.Equal(original.HasSeenActivityStreamTip, restored.HasSeenActivityStreamTip); Assert.Equal(original.SkippedUpdateTag, restored.SkippedUpdateTag); Assert.Equal(original.NotifyChatResponses, restored.NotifyChatResponses); @@ -133,6 +144,11 @@ public void MissingFields_UseDefaults() Assert.True(settings.NodeCameraEnabled); Assert.True(settings.NodeLocationEnabled); Assert.True(settings.NodeBrowserProxyEnabled); + Assert.False(settings.NodeTtsEnabled); + Assert.Equal("windows", settings.TtsProvider); + Assert.Null(settings.TtsElevenLabsApiKey); + Assert.Null(settings.TtsElevenLabsModel); + Assert.Null(settings.TtsElevenLabsVoiceId); Assert.False(settings.HasSeenActivityStreamTip); Assert.Null(settings.SkippedUpdateTag); Assert.True(settings.NotifyChatResponses); @@ -182,6 +198,11 @@ public void BackwardCompatibility_OldSettingsWithoutNewFields() Assert.True(settings.NodeCameraEnabled); Assert.True(settings.NodeLocationEnabled); Assert.True(settings.NodeBrowserProxyEnabled); + Assert.False(settings.NodeTtsEnabled); + Assert.Equal("windows", settings.TtsProvider); + Assert.Null(settings.TtsElevenLabsApiKey); + Assert.Null(settings.TtsElevenLabsModel); + Assert.Null(settings.TtsElevenLabsVoiceId); Assert.False(settings.HasSeenActivityStreamTip); Assert.Null(settings.SkippedUpdateTag); Assert.True(settings.GlobalHotkeyEnabled); @@ -194,6 +215,23 @@ public void InvalidJson_ReturnsNull() Assert.Null(SettingsData.FromJson("not json at all")); } + [Fact] + public void SettingsManager_ProtectsElevenLabsApiKeyForStorage() + { + var protectedValue = SettingsManager.ProtectSettingSecret("elevenlabs-key"); + + Assert.NotNull(protectedValue); + Assert.StartsWith("dpapi:", protectedValue); + Assert.DoesNotContain("elevenlabs-key", protectedValue); + Assert.Equal("elevenlabs-key", SettingsManager.UnprotectSettingSecret(protectedValue)); + } + + [Fact] + public void SettingsManager_ReturnsNullForCorruptedProtectedSecret() + { + Assert.Null(SettingsManager.UnprotectSettingSecret("dpapi:not-base64")); + } + [Theory] [InlineData(null)] [InlineData("")] diff --git a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs index 498eeb3b..2a6f473d 100644 --- a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs +++ b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs @@ -346,6 +346,10 @@ public void SettingsWindow_HasNodeCapabilityToggles() Assert.Contains(@"AutomationProperties.AutomationId=""NodeCameraToggle""", xaml); Assert.Contains(@"AutomationProperties.AutomationId=""NodeLocationToggle""", xaml); Assert.Contains(@"AutomationProperties.AutomationId=""NodeBrowserProxyToggle""", xaml); + Assert.Contains(@"AutomationProperties.AutomationId=""NodeTtsToggle""", xaml); + Assert.Contains(@"AutomationProperties.AutomationId=""TtsProviderComboBox""", xaml); + Assert.Contains(@"AutomationProperties.AutomationId=""TtsElevenLabsSettingsPanel""", xaml); + Assert.Contains(@"AutomationProperties.AutomationId=""TtsElevenLabsApiKeyPasswordBox""", xaml); } [Fact] From 1433349d10a4abfe2e4cbd617abcfba9221bff61 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Fri, 1 May 2026 09:12:35 -0700 Subject: [PATCH 02/10] feat(tray): add onboarding wizard updates (#241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Snap Reactor framework as OpenClawTray.Infrastructure - Copy microsoft/microsoft-ui-reactor src/Reactor/ (249 C# files, 12 modules) - Rename namespace Microsoft.UI.Reactor -> OpenClawTray.Infrastructure - Create OpenClawTray.Infrastructure.csproj (net10.0, WinAppSDK 1.8) - Add ProjectReference from OpenClaw.Tray.WinUI - Add project to moltbot-windows-hub.slnx - Fix C# 14 field keyword conflict in ValidationContext.cs - Exclude ReactorApplication.xaml (library mode, host app owns Application) - Update global.json rollForward to latestMajor - Full solution builds clean (0 errors) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Implement OnboardingWindow host with Reactor pages - OnboardingWindow.cs: WindowEx host with ReactorHostControl, Mica backdrop, 720x752 - OnboardingApp.cs: Root Reactor component with UseNavigation, step indicator, back/next - OnboardingState.cs: Shared state with mode-dependent page order (matches macOS flow) - WelcomePage.cs: Page 0 - welcome title + security notice card - ConnectionPage.cs: Page 1 - local/remote/later gateway selection - ReadyPage.cs: Page 9 - feature summary with emoji rows - Placeholder stubs for Wizard/Permissions/Chat pages (Phase 3) - Full solution builds clean via build.ps1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Wire first-run detection and tray menu to OnboardingWindow - First-run: ShowOnboardingAsync() replaces ShowSetupWizardAsync() in OnLaunched - Tray menu: 'setup' action now opens OnboardingWindow instead of SetupWizardWindow - OnboardingCompleted event mirrors existing SetupCompleted reconnection logic - Old ShowSetupWizardAsync() preserved for backward compatibility - Full build passes clean Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove SetupWizardWindow, redirect all call sites to OnboardingWindow - Remove ShowSetupWizardAsync() and _setupWizard field - Redirect deep link OpenSetup handler to ShowOnboardingAsync() - SetupWizardWindow.cs retained but no longer wired from App.xaml.cs - All build.ps1 targets pass clean Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Sprint 1: Enhanced pages + shared widgets (4 parallel tasks) Welcome Page (op-dlw): - Lobster icon, security warning card with ⚠️, trust model bullet points - Two-card layout (orange warning + gray trust explanation) Connection Page (op-24b): - Local/Remote/Later radio choices with ●/○ indicators and emoji icons - Conditional gateway URL + token fields for Local/Remote modes - Local pre-fills ws://localhost:18789, Test Connection button - Two-way binding to OnboardingState and SettingsManager Ready Page (op-qrh): - 🎉 celebration icon, mode-specific info card - Feature action rows with icon + title + subtitle - Launch at Login toggle - Configure Later / Remote info cards Shared Widgets (op-5xl): - OnboardingCard: Rounded card with white background - FeatureRow: Icon + title + subtitle row component - StepIndicator: Dot-based navigation indicator - GlowingIcon: 🦞 lobster icon (animation-ready) All 4 tasks implemented in parallel. Full build passes clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * OnboardingApp nav + localization (27 keys × 5 locales) OnboardingApp (op-fix): - Integrated GlowingIcon header and StepIndicator widget - Layout matches macOS: icon → page content → nav bar - Phase 3 placeholder pages with clear labels Localization (op-4jl): - 27 onboarding keys added to all 5 locale .resw files - en-us, fr-fr, nl-nl, zh-cn, zh-tw - Covers: title, nav buttons, welcome, connection, ready pages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Sprint 2+3: All pages + polish (6 parallel tasks) Wizard Page (op-y0w): - Native offline fallback: gateway URL, token, node mode toggle - Test Connection button with status feedback - TODO comments for future WebSocket RPC integration Permissions Page (op-9mr): - 5 Windows permissions: Notifications, Camera, Mic, Screen Capture, Location - Status indicators (✅/⚪) with Open Settings buttons - Status message area for feedback Chat Page (op-e38): - 'Meet your Agent' MVP chat UI - Agent welcome bubble (blue) + user message bubbles (gray) - Text input + Send button, footer note about full WebView2 integration Mica + Theming (op-dl8): - Non-resizable window via OverlappedPresenter - Mica backdrop confirmed, window size matches spec Page Transitions (op-xh9): - Spring slide transition on NavigationHost (dampingRatio: 0.86) - Matches macOS interactiveSpring(response: 0.5, dampingFraction: 0.86) Accessibility (op-61d): - To be enhanced in Sprint 4 integration pass All pages wired into OnboardingApp. Full build passes clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * WizardStepView renderer + integration validation WizardStepView (op-2oj): - Dynamic renderer for all 7 gateway RPC step types - Note, Text (with Sensitive/password), Confirm, Select, MultiSelect, Progress, Action - WizardStepProps record + WizardStepType enum - Switch expression renders type-appropriate UI with OnSubmit callback Integration (op-28l): - Solution file already includes OpenClawTray.Infrastructure (done in Sprint 0) - build.ps1 builds WinUI with ProjectReference chain — no changes needed - All 774 tests pass (652 Shared + 122 Tray, 0 failures) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add onboarding unit tests (13 new tests, 135 total Tray tests) OnboardingStateTests: - GetPageOrder: Local includes Wizard, Remote excludes it, Later is minimal - GetPageOrder: NoChat mode excludes Chat for all modes - GetPageOrder: Always starts with Welcome, ends with Ready - Defaults: Mode=Local, ShowChat=true - Complete: fires Finished event, calls Settings.Save() All 774+ tests pass (652 Shared + 135 Tray, 0 failures). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add WizardStepProps and WizardStepType unit tests Tests WizardStepType enum (7 values) and WizardStepProps record defaults. All 145 Tray tests pass (0 failures). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add inner-loop dev scripts for testing onboarding UX - dev-loop.ps1: Build + kill + launch cycle with -Clean (first-run) and -Tail (logs) - test-sandbox.wsb: Windows Sandbox config with mapped build output for clean-state testing - setup-sandbox-network.ps1: Port proxy setup for sandbox-to-WSL gateway connectivity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix NullRef on first render, duplicate lobster, Border(null!) crash - OnboardingWindow: use ctx.UseState(state) in mount function for props persistence - WelcomePage: remove duplicate lobster icon (OnboardingApp header has the persistent one) - StepIndicator: Border(TextBlock('')) instead of Border(null!) to avoid runtime NullRef Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix nav bar positioning + visual test framework + bug fixes Nav bar fix: - Fixed NavigationHost height to 520px so nav bar stays at consistent position - All pages render within the same content area, nav bar never jumps - Replaced Spring transition with 200ms Slide (prevents overlap on fast navigation) - Compacted WelcomePage: merged security+trust cards, reduced font sizes - Reduced GlowingIcon from 64px to 48px, tightened margins Bug fixes: - Fixed NullRef on first render (ctx.UseState for mount props persistence) - Fixed duplicate lobster icon (removed from WelcomePage, kept in OnboardingApp header) - Fixed Border(null!) crash in StepIndicator Visual test framework: - visual-test.ps1: P/Invoke window finding + UIAutomation button clicking - Screenshot capture via PrintWindow/CopyFromScreen (note: GDI capture fails on Dev Box/Cloud PC) - Baseline + after screenshots in visual-test-output/ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * SlideInOnly transition + RenderTargetBitmap visual capture SlideInOnlyTransition (NavigationTransition.cs + TransitionEngine.cs): - New transition type: instantly hides old page (opacity=0), slides+fades new in - Direction auto-reverses on back nav (Push=right, Pop=left) - 200ms duration with cubic-bezier easing - Zero flicker — old page is invisible before new one starts animating RenderTargetBitmap visual capture (OnboardingWindow.cs): - In-app capture via WinUI RenderTargetBitmap API - Works on Dev Box/Cloud PC (no physical display needed) - Triggered by OPENCLAW_VISUAL_TEST=1 env var - Auto-captures on initial load and every page navigation (PageChanged event) - Saves PNGs to OPENCLAW_VISUAL_TEST_DIR - All 6 pages validated via LLM visual analysis OnboardingState.cs: - Added PageChanged event for capture integration All 145 tests pass. Full build clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Permissions page button alignment: use Grid layout for right-aligned buttons Changed PermissionRow from HStack to Grid with ['1*', 'Auto'] columns so 'Open Settings' buttons are consistently right-aligned and stacked vertically, matching the pattern used in ConnectionPage.cs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Permissions page: left-align status emojis in own column Move status emojis (✅, ❌, ⚠️) from inline with permission name into a dedicated Grid column 0 with Auto width. Changes the row Grid from 2 columns [1*, Auto] to 3 columns [Auto, 1*, Auto]: - Column 0: Status emoji, fixed width, left-aligned - Column 1: Permission icon + name + description, fills remaining - Column 2: Open Settings button, right-aligned This ensures all status emojis form a clean vertical line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * wip: latest onboarding fixes pre-upstream-merge Checkpoint of in-progress work before merging origin/master to pick up GatewayTopologyClassifier, SshTunnelCommandLine, SshTunnelService, and updated SettingsWindow connection logic. Includes: - Permissions page alignment fixes - ConnectionPage gateway auth + pairing flow - New onboarding services (GatewayHealthCheck, InputValidator, LocalGatewayApprover, PermissionChecker, SetupCodeDecoder, WizardStepParser) - Tests for those services - Localization keys across 5 locales - Inner-loop dev scripts and e2e helpers - Onboarding + auth-fix proposal docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(onboarding): redesign Connection page to match new UX mockup Implements the redesigned Connection page from connection-page-mockup.html: - Five gateway modes (was three): Local / WSL / Remote / SSH Tunnel / Configure Later. WSL and SSH are added to ConnectionMode and reuse the Local page-order in OnboardingState.GetPageOrder(). - Setup Code row gains explicit Paste and QR-import buttons in addition to the existing focus-paste behavior. QR decoding is extracted from SetupWizardWindow into a reusable Helpers/QrSetupCodeReader so it can be invoked from Reactor pages without depending on the wizard window. - Animated SSH panel renders inline when SSH mode is selected: 2x2 grid of SSH User / Host / Remote Port / Local Port plus a live preview line generated via SshTunnelCommandLine.BuildArguments(...). Settings are written through to SettingsManager.SshTunnel*. App gains a EnsureSshTunnelStarted() shim so TestConnection can spin up the managed tunnel before health-checking ws://127.0.0.1:. - Topology detection line renders the GatewayTopologyClassifier output (DisplayName/Transport/Detail) live as the user changes modes / SSH fields, matching the mockup's '● Detected: ...' line. - Page content is wrapped in a ScrollView and the onboarding window is resized to 720x900 to fit the additional rows in the SSH layout. - App exposes GetOnboardingWindowHandle() so the QR FileOpenPicker can initialize against the onboarding HWND. - Two new optional environment variables aid visual testing without requiring UI automation: * OPENCLAW_ONBOARDING_START_ROUTE = * OPENCLAW_ONBOARDING_START_MODE = Adds new locale keys for the SSH/WSL/QR/Topology surface in all five locales (en-us authoritative; fr-fr, nl-nl, zh-cn, zh-tw machine- translated and flagged for human review in the PR description). Adds tests/OpenClaw.Tray.Tests/ConnectionPageTopologyTests.cs covering: - 5-mode page-order parity (Wsl/Ssh behave like Local). - GatewayTopologyClassifier outputs for the canonical mode→URL mapping. - SshTunnelCommandLine preview includes both forwards (gateway + browser-proxy +2) and validates user/host. Validation (per AGENTS.md): - ./build.ps1: all projects succeed. - dotnet test Shared: 967 passed / 20 skipped / 0 failed. - dotnet test Tray: 350 passed / 0 failed (8 new). - Visual capture in OPENCLAW_VISUAL_TEST mode for both Local and SSH modes; matches mockup layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * security: remove hardcoded WSL gateway dev token from e2e test The fallback token was a dev-gateway secret that got flagged by GitHub secret scanning. Token now must come from WSL openclaw.json (preferred) or OPENCLAW_GATEWAY_TOKEN env var; the script fails fast if neither is available. Note: the leaked token should be rotated by regenerating the dev gateway config in WSL. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(infra): prune unused Reactor modules (Charting/Data/Yoga/FlexPanel/DataGrid/PropertyGrid) Per PR feedback: the tray only uses Core/Hosting/Navigation/Elements/Hooks/ Animation/Markdown/Accessibility/Input from the Reactor snap. Removed: - Charting/ (D3 charts not used by onboarding) - Data/ (datasource/grid binding not used) - Yoga/ (FlexPanel not used; tray uses StackElement-based HStack/VStack) - Controls/DataGrid (cascading: depends on Data+Charting) - Controls/PropertyGrid (cascading: depends on Data) - Pruned Yoga/FlexPanel hooks from Core/Element.cs, ElementPool.cs, Reconciler.Mount.cs, Reconciler.Update.cs, Elements/Dsl.cs, ElementExtensions.cs - Pruned Charting hooks from Core/AccessibilityScanner.cs and Hosting/ReactorHost.cs - Removed UseDataSource from Core/Component.cs - Removed FieldDescriptor overload from Controls/Validation/FormField.cs - Removed ResizeGripRegistration call sites (lived in DataGrid) Build clean. Tray tests 350/350 pass. Shared tests 967/967 pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(onboarding): replace Reactor snap with FunctionalUI helper Replace the vendored Reactor-derived infrastructure project with a tiny OpenClaw-owned FunctionalUI helper layer used by onboarding. Remove unused charting, data, markdown, devtools, validation, localization, input, animation, and broad control infrastructure from the PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: remove local workflow files from onboarding PR Remove Beads and Gastown hook files so the tray onboarding PR only contains product UI changes and required app support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: remove extraneous artifacts from onboarding PR Remove local visual outputs, sandbox/provisioning scripts, e2e scratch automation, and upstream planning docs from the tray onboarding PR. Keep the remaining changes focused on the product onboarding flow and supporting app code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix tray onboarding runtime issues Remove inconsistent gray onboarding panels, stabilize connection mode selection, fix FunctionalUI reparenting during conditional renders, and add runtime hooks needed for tray window capture and WebChat error rendering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address onboarding pairing feedback Remove the local gateway auto-approval shortcut and use the existing pairing command copy/notification flow instead. Also scope bootstrap operator handshakes to the gateway handoff profile, skip Chat for Configure Later, and dispose onboarding state safely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Constrain bootstrap auth to onboarding setup codes Keep the default gateway client auth payload and chat URL construction aligned with the existing tray app, while allowing onboarding setup-code handoff to opt into bootstrap auth scopes explicitly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Tighten gateway security follow-ups Preserve MCP-only onboarding completion routing, remove the unused public connect auth token getter, and add regression coverage for default operator scopes and paired bootstrap handoff auth. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Mike Harsh Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 + DEVELOPMENT.md | 49 +- README.md | 18 +- docs/LOCALIZATION.md | 44 +- docs/ONBOARDING_WIZARD.md | 99 ++ docs/SETUP.md | 53 +- docs/TEST_COVERAGE.md | 51 +- openclaw-windows-node.slnx | 1 + src/OpenClaw.Shared/OpenClawGatewayClient.cs | 122 ++- src/OpenClaw.Tray.WinUI/App.xaml.cs | 138 ++- .../Helpers/GatewayChatHelper.cs | 67 ++ .../Helpers/GatewayChatUrlBuilder.cs | 64 ++ .../Helpers/QrSetupCodeReader.cs | 62 ++ .../Helpers/VisualTestCapture.cs | 178 ++++ .../Onboarding/OnboardingApp.cs | 117 +++ .../Onboarding/OnboardingWindow.cs | 646 ++++++++++++ .../Onboarding/Pages/ChatPage.cs | 39 + .../Onboarding/Pages/ConnectionPage.cs | 828 +++++++++++++++ .../Onboarding/Pages/PermissionsPage.cs | 167 +++ .../Onboarding/Pages/ReadyPage.cs | 163 +++ .../Onboarding/Pages/WelcomePage.cs | 78 ++ .../Onboarding/Pages/WizardPage.cs | 642 ++++++++++++ .../Services/ConnectionPageModeSelector.cs | 93 ++ .../Onboarding/Services/GatewayHealthCheck.cs | 96 ++ .../Onboarding/Services/InputValidator.cs | 63 ++ .../Services/LocalGatewayApprover.cs | 27 + .../Onboarding/Services/OnboardingState.cs | 139 +++ .../Onboarding/Services/PermissionChecker.cs | 261 +++++ .../Onboarding/Services/SetupCodeDecoder.cs | 74 ++ .../Onboarding/Services/WizardStepParser.cs | 140 +++ .../Onboarding/Widgets/FeatureRow.cs | 32 + .../Onboarding/Widgets/GlowingIcon.cs | 20 + .../Onboarding/Widgets/OnboardingCard.cs | 22 + .../Onboarding/Widgets/StepIndicator.cs | 32 + .../Onboarding/Widgets/WizardStepModels.cs | 26 + .../Onboarding/Widgets/WizardStepView.cs | 166 +++ .../OpenClaw.Tray.WinUI.csproj | 1 + src/OpenClaw.Tray.WinUI/Package.appxmanifest | 1 + .../Services/DeepLinkHandler.cs | 7 + .../Strings/en-us/Resources.resw | 337 ++++++ .../Strings/fr-fr/Resources.resw | 337 ++++++ .../Strings/nl-nl/Resources.resw | 337 ++++++ .../Strings/zh-cn/Resources.resw | 337 ++++++ .../Strings/zh-tw/Resources.resw | 337 ++++++ .../Windows/ActivityStreamWindow.xaml | 2 +- .../Windows/ActivityStreamWindow.xaml.cs | 1 + .../Windows/NotificationHistoryWindow.xaml | 2 +- .../Windows/NotificationHistoryWindow.xaml.cs | 1 + .../Windows/SettingsWindow.xaml | 2 +- .../Windows/SettingsWindow.xaml.cs | 1 + .../Windows/StatusDetailWindow.xaml | 2 +- .../Windows/StatusDetailWindow.xaml.cs | 1 + .../Windows/TrayMenuWindow.xaml | 4 +- .../Windows/TrayMenuWindow.xaml.cs | 18 + .../Windows/WebChatWindow.xaml | 9 +- .../Windows/WebChatWindow.xaml.cs | 104 +- src/OpenClawTray.FunctionalUI/FunctionalUI.cs | 971 ++++++++++++++++++ .../OpenClawTray.FunctionalUI.csproj | 18 + .../OpenClawGatewayClientTests.cs | 120 ++- .../ConnectionPageTopologyTests.cs | 86 ++ .../GatewayChatHelperTests.cs | 136 +++ .../GatewayHealthCheckTests.cs | 73 ++ .../LocalGatewayApproverTests.cs | 92 ++ .../LocalizationValidationTests.cs | 81 ++ .../OnboardingStateTests.cs | 238 +++++ .../OpenClaw.Tray.Tests.csproj | 9 + .../SecurityValidationTests.cs | 182 ++++ .../SetupCodeDecoderTests.cs | 184 ++++ .../WizardStepParsingTests.cs | 175 ++++ .../WizardStepPropsTests.cs | 78 ++ 70 files changed, 8998 insertions(+), 106 deletions(-) create mode 100644 docs/ONBOARDING_WIZARD.md create mode 100644 src/OpenClaw.Tray.WinUI/Helpers/GatewayChatHelper.cs create mode 100644 src/OpenClaw.Tray.WinUI/Helpers/GatewayChatUrlBuilder.cs create mode 100644 src/OpenClaw.Tray.WinUI/Helpers/QrSetupCodeReader.cs create mode 100644 src/OpenClaw.Tray.WinUI/Helpers/VisualTestCapture.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Pages/ChatPage.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Pages/ConnectionPage.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Pages/PermissionsPage.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Pages/ReadyPage.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Pages/WelcomePage.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Pages/WizardPage.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/ConnectionPageModeSelector.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/GatewayHealthCheck.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/InputValidator.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalGatewayApprover.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/PermissionChecker.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/SetupCodeDecoder.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Services/WizardStepParser.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Widgets/FeatureRow.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Widgets/GlowingIcon.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Widgets/OnboardingCard.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Widgets/StepIndicator.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Widgets/WizardStepModels.cs create mode 100644 src/OpenClaw.Tray.WinUI/Onboarding/Widgets/WizardStepView.cs create mode 100644 src/OpenClawTray.FunctionalUI/FunctionalUI.cs create mode 100644 src/OpenClawTray.FunctionalUI/OpenClawTray.FunctionalUI.csproj create mode 100644 tests/OpenClaw.Tray.Tests/ConnectionPageTopologyTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/GatewayChatHelperTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/GatewayHealthCheckTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/LocalGatewayApproverTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/SecurityValidationTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/SetupCodeDecoderTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/WizardStepParsingTests.cs create mode 100644 tests/OpenClaw.Tray.Tests/WizardStepPropsTests.cs diff --git a/.gitignore b/.gitignore index 0c395504..7a60a7fa 100644 --- a/.gitignore +++ b/.gitignore @@ -346,3 +346,6 @@ FodyWeavers.xsd Output/ *.lscache test_ws.py + +# Local visual test output +visual-test-output/ diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 044b8e52..fbcc4ebf 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -425,8 +425,8 @@ dotnet test --filter "FullyQualifiedName~AgentActivityTests" ``` **Test Coverage:** -- ✅ **478 tests** in `OpenClaw.Shared.Tests` — models, gateway client, exec approvals, capabilities, URL helpers, notification categorization, shell quoting -- ✅ **93 tests** in `OpenClaw.Tray.Tests` — menu display, menu positioning, settings round-trip, deep link parsing +- ✅ **652 tests** in `OpenClaw.Shared.Tests` — models, gateway client, exec approvals, capabilities, URL helpers, notification categorization, shell quoting +- ✅ **262 tests** in `OpenClaw.Tray.Tests` — menu display, menu positioning, settings round-trip, deep link parsing, onboarding state, setup code decoder, security validation, wizard step parsing, localization validation - ✅ All tests are pure unit tests (no network, no file system, no external dependencies) See [tests/OpenClaw.Shared.Tests/README.md](tests/OpenClaw.Shared.Tests/README.md) for detailed test documentation. @@ -747,6 +747,51 @@ gh run download --repo shanselman/openclaw-windows-hub - **Discussions**: [GitHub Discussions](https://github.com/shanselman/openclaw-windows-hub/discussions) - **Documentation**: [OpenClaw Docs](https://docs.molt.bot) +## Developing & Testing the Onboarding Wizard + +The onboarding wizard is a 6-screen flow built with OpenClaw's minimal FunctionalUI helper layer for declarative C# WinUI. The chat page uses a WebView2 overlay for visual consistency with the post-setup chat experience. + +### Building + +The WinUI project requires platform-specific build targets. Use the build script: + +```bash +./build.ps1 -Project WinUI # Builds with correct -r win-x64 targets +``` + +Direct `dotnet build` without the script will fail with "WindowsAppSDKSelfContained requires a supported Windows architecture". + +### Environment Variables + +| Variable | Purpose | +|----------|---------| +| `OPENCLAW_FORCE_ONBOARDING=1` | Show onboarding wizard even if a token already exists | +| `OPENCLAW_SKIP_UPDATE_CHECK=1` | Skip the update dialog (useful during testing) | +| `OPENCLAW_LANGUAGE=fr-fr` | Override UI language (validated: en-us, fr-fr, nl-nl, zh-cn, zh-tw) | +| `OPENCLAW_GATEWAY_PORT=19001` | Override default gateway port for local dev | +| `OPENCLAW_VISUAL_TEST=1` | Enable automatic screenshot capture on page transitions | +| `OPENCLAW_VISUAL_TEST_DIR=path` | Output directory for visual test screenshots | + +### Testing the Wizard Locally + +1. Start a local gateway (e.g., in WSL): `cd ~/openclaw && npx openclaw gateway` +2. Set env vars: + ```powershell + $env:OPENCLAW_FORCE_ONBOARDING = "1" + $env:OPENCLAW_SKIP_UPDATE_CHECK = "1" + ``` +3. Build and run: `./build.ps1 -Project WinUI` then launch the exe +4. Navigate through all 6 screens to verify + +### Architecture + +- **FunctionalUI**: `src/OpenClawTray.FunctionalUI/` — Minimal declarative WinUI helper layer used by onboarding +- **Pages**: `src/OpenClaw.Tray.WinUI/Onboarding/Pages/` — Functional UI components for each wizard screen +- **Services**: `src/OpenClaw.Tray.WinUI/Onboarding/Services/` — State management, setup code decoder, permission checker, health check, input validation +- **Widgets**: `src/OpenClaw.Tray.WinUI/Onboarding/Widgets/` — Shared UI components (cards, step indicators, feature rows) +- **Window**: `src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs` — Host window with WebView2 overlay for chat +- **Helpers**: `src/OpenClaw.Tray.WinUI/Helpers/GatewayChatHelper.cs` — Shared WebView2 chat URL builder + --- *Made with 🦞 love by Scott Hanselman and the OpenClaw community* diff --git a/README.md b/README.md index 26815f37..0a455ca2 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Modern Windows 11-style system tray companion that connects to your local OpenCl - ⏱ **Cron Jobs** - Quick access to scheduled tasks - 🚀 **Auto-start** - Launch with Windows - ⚙️ **Settings** - Full configuration dialog -- 🎯 **First-run experience** - Welcome dialog guides new users +- 🎯 **First-run onboarding** — 6-screen setup wizard (connection, permissions, chat, configuration) #### Quick Send scope requirement @@ -164,7 +164,7 @@ These features are available in Windows but not in the Mac app: | Channel control | Start/stop Telegram & WhatsApp | | Modern flyout menu | Windows 11-style with dark/light mode | | Deep links | `openclaw://` URL scheme with IPC | -| First-run welcome | Guided onboarding for new users | +| First-run onboarding | 6-screen guided setup wizard (Welcome → Connection → Wizard → Permissions → Chat → Ready) | | PowerToys integration | Command Palette extension | ### 🔌 Node Mode (Agent Control) @@ -402,10 +402,16 @@ Default gateway: `ws://localhost:18789` ### First Run -On first run without a token, Molty displays a welcome dialog that: -1. Explains what's needed to get started -2. Links to [documentation](https://docs.molt.bot/web/dashboard) for token setup -3. Opens Settings to configure the connection +On first run, Molty launches a guided onboarding wizard that walks you through setup: + +1. **Welcome** — introduces OpenClaw and starts the setup flow +2. **Connection** — choose Local gateway, Remote gateway, or configure later. Paste a setup code or enter gateway URL and token manually. Tests the connection with Ed25519 device authentication. +3. **Wizard** — gateway-driven configuration steps (AI provider selection, personality setup, communication channels). Steps are defined by your gateway. +4. **Permissions** — reviews Windows system permissions (notifications, camera, microphone, screen capture, location) and links to system settings to grant them. +5. **Chat** — meet your agent in a live chat powered by the gateway's web UI. +6. **Ready** — summary of available features, option to launch at startup, and a Finish button. + +For detailed setup instructions, see [docs/SETUP.md](docs/SETUP.md). For the full onboarding architecture, see [docs/ONBOARDING_WIZARD.md](docs/ONBOARDING_WIZARD.md). ## License diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md index 9e077bd8..c53148c7 100644 --- a/docs/LOCALIZATION.md +++ b/docs/LOCALIZATION.md @@ -7,7 +7,10 @@ OpenClaw Tray uses WinUI `.resw` resource files for localization. Windows automa | Language | Locale | Resource File | |----------|--------|---------------| | English (US) | `en-us` | `Strings/en-us/Resources.resw` | +| French (France) | `fr-fr` | `Strings/fr-fr/Resources.resw` | +| Dutch (Netherlands) | `nl-nl` | `Strings/nl-nl/Resources.resw` | | Chinese (Simplified) | `zh-cn` | `Strings/zh-cn/Resources.resw` | +| Chinese (Traditional) | `zh-tw` | `Strings/zh-tw/Resources.resw` | ## Adding a New Language @@ -65,16 +68,16 @@ Windows picks the language automatically based on the user's OS display language ## Testing a Language Locally -To test a specific locale without changing your Windows language: +Set the `OPENCLAW_LANGUAGE` environment variable before launching the app: -1. Open `src/OpenClaw.Tray.WinUI/App.xaml.cs` -2. Add this line at the top of the `App()` constructor, **before** `InitializeComponent()`: - ```csharp - LocalizationHelper.SetLanguageOverride("zh-CN"); - ``` -3. Build and run (`dotnet build src/OpenClaw.Tray.WinUI -r win-x64`). Remove the line when done testing. +```powershell +$env:OPENCLAW_LANGUAGE = "fr-fr" # or nl-nl, zh-cn, zh-tw +.\src\OpenClaw.Tray.WinUI\bin\Debug\net10.0-windows10.0.19041.0\win-x64\OpenClaw.Tray.WinUI.exe +``` -> **Note:** This overrides `LocalizationHelper.GetString()` calls (menus, toasts, dialogs, window titles). XAML `x:Uid` bindings follow the OS display language. For full XAML localization testing, change your Windows display language in Settings → Time & Language. +This overrides `LocalizationHelper.GetString()` calls for menus, toasts, dialogs, and the onboarding wizard. The language is validated against the supported locale list. + +> **Note:** XAML `x:Uid` bindings follow the OS display language. For full localization testing including XAML elements, change your Windows display language in Settings → Time & Language. ## Resource Key Naming Conventions @@ -87,12 +90,29 @@ To test a specific locale without changing your Windows language: | `Status_Name` | Status display text | `Status_Connected` | | `TimeAgo_Format` | Relative time strings | `TimeAgo_MinutesFormat` | +### Onboarding Key Namespace + +All onboarding wizard strings use the `Onboarding_` prefix: + +| Pattern | Used For | Example | +|---------|----------|---------| +| `Onboarding_PageName_Label` | Page titles, descriptions | `Onboarding_Welcome_Title` | +| `Onboarding_Connection_*` | Connection page labels/status | `Onboarding_Connection_TestConnection` | +| `Onboarding_Perm_*` | Permission names | `Onboarding_Perm_Camera` | +| `Onboarding_Ready_*` | Ready page elements | `Onboarding_Ready_Feature_Voice_Subtitle` | +| `Onboarding_Wizard_*` | Wizard page elements | `Onboarding_Wizard_Continue` | + ## Validation -Both resource files must have the **same set of keys**. You can verify with: +All 5 resource files must have the **same set of keys**. You can verify with: ```powershell -$en = (Select-String -Path "src\OpenClaw.Tray.WinUI\Strings\en-us\Resources.resw" -Pattern '\Resources.resw" -Pattern ': $new keys | Match: $($en -eq $new)" +$locales = @("en-us", "fr-fr", "nl-nl", "zh-cn", "zh-tw") +$base = "src\OpenClaw.Tray.WinUI\Strings" +foreach ($loc in $locales) { + $count = (Select-String -Path "$base\$loc\Resources.resw" -Pattern '` flow; the Windows tray does not edit gateway pairing state directly. + +### Wizard +Renders server-defined setup steps via RPC (`wizard.start` / `wizard.next`). The gateway controls the flow — steps can be: +- **Note** — informational messages +- **Confirm** — yes/no decisions +- **Text** — free-form input (with PasswordBox for sensitive fields like API keys) +- **Select** — radio button choices (e.g., AI provider selection) +- **Progress** — loading indicator for background operations + +If the gateway doesn't support the wizard protocol or is unreachable, this screen shows an "offline" message and can be skipped. + +### Permissions +Checks 5 Windows permissions using native APIs and registry: +- Notifications (Toast capability) +- Camera (Windows.Devices.Enumeration) +- Microphone (Windows.Devices.Enumeration) +- Screen Capture (Graphics.Capture) +- Location (optional, registry-based) + +Each permission shows its current status (Enabled/Disabled/Allowed/Denied) with an "Open Settings" button linking to the relevant `ms-settings:` URI. + +### Chat +Embeds the gateway's web chat UI via WebView2, matching the post-setup `WebChatWindow` for visual consistency. Uses the shared `GatewayChatHelper` for URL building and WebView2 initialization. + +On first load, a bootstrap message is auto-injected to kick off the gateway's first-run ritual (BOOTSTRAP.md). The message is safely encoded using `JsonSerializer.Serialize` to prevent XSS. + +### Ready +Displays 5 feature cards (Tray Menu, Channels, Voice, Canvas, Skills) with localized subtitles. Includes a "Launch at Login" toggle and a "Finish" button that saves settings and closes the wizard. + +## Security + +The onboarding wizard follows these security practices: + +- **XSS prevention**: Bootstrap messages encoded via `JsonSerializer.Serialize` for safe JS injection +- **Input validation**: Setup codes limited to 2KB, decoded JSON validated, gateway URLs checked via `GatewayUrlHelper` +- **URI scheme whitelists**: Only `ms-settings:` for permissions, `http/https` for chat +- **Navigation restriction**: WebView2 `NavigationStarting` handler blocks navigation to external origins +- **Token protection**: Query params stripped from all log output; WebView2 accelerator keys disabled +- **Gateway-owned pairing**: Device approval uses the gateway CLI/API path so scope checks, token issuance, audit, and broadcasts stay centralized +- **Error sanitization**: Exception details logged but not shown to users + +## Localization + +All user-visible strings use `LocalizationHelper.GetString()` with the `Onboarding_*` key namespace. Supported languages: English, French, Dutch, Chinese Simplified, Chinese Traditional. + +Translations are AI-generated following the repo convention. Technical terms (Gateway, Token, Node Mode) are kept in English across all locales. + +## Developer Guide + +See [DEVELOPMENT.md](../DEVELOPMENT.md#developing--testing-the-onboarding-wizard) for build instructions, environment variables, and testing workflow. + +### Key Files + +| Path | Purpose | +|------|---------| +| `Onboarding/OnboardingWindow.cs` | Host window with WebView2 overlay | +| `Onboarding/OnboardingApp.cs` | Functional UI root component, page navigation | +| `Onboarding/Services/OnboardingState.cs` | Shared state across all pages | +| `Onboarding/Pages/*.cs` | Individual wizard screens | +| `Onboarding/Services/SetupCodeDecoder.cs` | Base64url setup code parsing | +| `Onboarding/Services/InputValidator.cs` | Security input validation | +| `Onboarding/Services/WizardStepParser.cs` | Wizard JSON step parsing | +| `Onboarding/Services/LocalGatewayApprover.cs` | Local gateway URL classification | +| `Onboarding/Services/PermissionChecker.cs` | Windows permission checks | +| `Helpers/GatewayChatHelper.cs` | Shared WebView2 chat URL builder | diff --git a/docs/SETUP.md b/docs/SETUP.md index e58802de..b0db5776 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -43,16 +43,35 @@ After the installer finishes, OpenClaw Tray starts automatically. Look for the If you don't see it, check the **hidden icons** area (the `^` arrow next to the tray). -### 5. Configure the Connection +### 5. Onboarding Wizard -On first launch, a **Welcome** dialog appears. Click **Open Settings** to configure: +On first launch, Molty opens a **6-screen onboarding wizard** that walks you through setup: -| Setting | What to enter | -|---------|--------------| -| **Gateway URL** | `ws://localhost:18789` (if running OpenClaw locally) or your remote gateway address | -| **Token** | Your OpenClaw API token from [openclaw.ai](https://openclaw.ai) | +1. **Welcome** — A friendly greeting introducing OpenClaw and Molty. Click **Get Started** to begin. -Click **Save**. Molty will connect to the gateway and the tray icon will turn green when connected. +2. **Connection** — Choose how to connect to your gateway: + - **Local** — Select this if the gateway runs on the same machine or in WSL. The URL is pre-filled to `ws://localhost:18789`. + - **Remote** — Enter your gateway URL and bootstrap token manually, **or** paste a base64url-encoded **setup code** (a single string containing both URL and token). + - **Later** — Skip connection setup for now. You can configure it later from the tray menu → Settings. + + After entering your details, click **Test Connection**. The wizard performs a real WebSocket handshake with Ed25519 device authentication and shows real-time status feedback (connecting → connected → pairing). + +3. **Wizard** — If your gateway supports it, this screen walks you through gateway-driven configuration steps (AI provider selection, personality setup, communication channels). The steps are defined by your gateway via RPC. If the gateway doesn't support wizard mode, this screen is skipped automatically. + +4. **Permissions** — Reviews Windows system permissions needed for full functionality: + - **Notifications** — for toast alerts + - **Camera** — for camera capture + - **Microphone** — for voice input + - **Screen Capture** — for screenshots + - **Location** — optional, for location-aware features + + Each permission shows its current status. Click **Open Settings** next to any permission to jump directly to the relevant Windows Settings page. + +5. **Chat** — Meet your agent! This screen opens a live chat powered by the gateway's web UI. A bootstrap message is sent automatically to kick off your first conversation. + +6. **Ready** — A summary of available features (tray menu, channels, voice, canvas, skills). Toggle **Launch at Login** to start Molty with Windows, then click **Finish** to complete setup. + +After the wizard, the tray icon turns green when connected. You can re-run the wizard or change settings anytime from the tray menu. ## Tray Icon Status @@ -131,6 +150,26 @@ openclaw devices approve See [issue #81](https://github.com/openclaw/openclaw-windows-node/issues/81) for context on this flow. +### Setup code doesn't work + +- Make sure you paste the **entire** setup code — it's a single base64url-encoded string. +- Check for accidental leading/trailing whitespace. +- The code must be from a compatible gateway version. Try entering the gateway URL and token manually instead. + +### Connection test fails + +- Verify the gateway URL is correct (e.g., `ws://localhost:18789` for local, or the full URL for remote). +- Check that your token is valid and hasn't expired. +- If the gateway is on another machine, ensure Windows Firewall allows traffic on the gateway port. +- See the log at `%LOCALAPPDATA%\OpenClawTray\openclaw-tray.log` for detailed error messages. + +### Wizard shows "offline" + +The Wizard screen relies on the gateway's wizard protocol. If it shows offline: +- The gateway may not support wizard mode yet — this is fine, configuration can be done later. +- Check that the gateway is running and reachable. +- You can skip the Wizard screen and configure your gateway manually from the tray menu → Settings. + ### Settings are not saved Settings are stored at `%APPDATA%\OpenClawTray\settings.json`. If this file is corrupt, delete it and reconfigure from scratch. diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index 9247b3c9..243fd642 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -1,17 +1,17 @@ # Test Coverage Summary -**571 tests total** (478 shared + 93 tray) — all passing ✅ +**914 tests total** (652 shared + 262 tray) — all passing ✅ | Metric | Value | |--------|-------| -| Total Tests | 571 | -| Passing | 571 (100%) | +| Total Tests | 914 | +| Passing | 914 (100%) | | Failing | 0 | | Framework | xUnit 2.9.3 / .NET 10.0 | ## Test Projects -### OpenClaw.Shared.Tests — 478 tests +### OpenClaw.Shared.Tests — 652 tests #### ModelsTests - **AgentActivityTests** (~15) — glyph mapping for all ActivityKind values, display text formatting @@ -71,29 +71,26 @@ --- -### OpenClaw.Tray.Tests — 93 tests +### OpenClaw.Tray.Tests — 262 tests -#### MenuDisplayHelperTests (~40) -- `GetStatusIcon` — emoji mapping for Connected/Disconnected/Connecting/Error states -- `GetChannelStatusIcon` — status icons for running/idle/pending/error/disconnected + case-insensitive variants -- `GetNextToggleValue` — ON↔OFF toggling, case handling -- Unknown/empty status fallback +#### Core Tray Tests -#### MenuPositionerTests (~15) -- Screen edge clamping (top-left, bottom-right) -- Taskbar-at-right scenario -- Menu positioning relative to cursor +- **MenuDisplayHelperTests** (~40) — `GetStatusIcon` emoji mapping for Connected/Disconnected/Connecting/Error states, `GetChannelStatusIcon` status icons for running/idle/pending/error/disconnected + case-insensitive variants, `GetNextToggleValue` ON↔OFF toggling, unknown/empty status fallback +- **MenuPositionerTests** (~15) — Screen edge clamping (top-left, bottom-right), taskbar-at-right scenario, menu positioning relative to cursor +- **SettingsRoundTripTests** (~15) — Serialization/deserialization round trips, default values on missing keys, backward compatibility with older settings formats +- **DeepLinkParserTests** (~23) — `ParseDeepLink` protocol validation, null/empty handling, subpath parsing, trailing slash stripping, query parameter extraction, URL-encoded message handling -#### SettingsRoundTripTests (~15) -- Serialization/deserialization round trips -- Default values on missing keys -- Backward compatibility with older settings formats +#### Onboarding Tests -#### DeepLinkParserTests (~23) -- `ParseDeepLink` — protocol validation, null/empty handling, subpath parsing, trailing slash stripping -- Query parameter extraction (`GetQueryParam`) -- URL-encoded message handling -- Multiple query parameters, missing keys +- **OnboardingStateTests** (19) — Page order, mode logic, route changes, wizard state persistence, completion, disposal +- **WizardStepPropsTests** (4) — Enum values, record defaults, callback verification +- **GatewayChatHelperTests** (11) — URL scheme conversion, token encoding, localhost checks, session keys +- **LocalGatewayApproverTests** (13) — IsLocalGateway for localhost/remote/edge cases +- **SetupCodeDecoderTests** (14) — Base64url decode, size limits, JSON validation, URL/token extraction +- **GatewayHealthCheckTests** (6) — Health URI building, scheme conversion, port preservation +- **SecurityValidationTests** (16) — Locale whitelist, port range, path traversal, URI scheme validation +- **WizardStepParsingTests** (12) — JSON step parsing, options, completion, sensitive fields +- **LocalizationValidationTests** (6) — 5-locale key parity, onboarding key presence, no duplicates --- @@ -110,6 +107,9 @@ dotnet test tests/OpenClaw.Tray.Tests # Specific test class dotnet test --filter "FullyQualifiedName~MenuDisplayHelperTests" +# Onboarding tests only +dotnet test --filter "FullyQualifiedName~Onboarding" + # Verbose output dotnet test --logger "console;verbosity=detailed" ``` @@ -120,9 +120,10 @@ dotnet test --logger "console;verbosity=detailed" - Real gateway message parsing - Concurrent event handling - File I/O and thread synchronization +- End-to-end onboarding wizard flow (WebView2 requires runtime) --- -**Last Updated**: 2026-03-18 +**Last Updated**: 2026-04-26 **Framework**: xUnit 2.9.3 / .NET 10.0 -**Status**: ✅ 571 tests passing +**Status**: ✅ 914 tests passing diff --git a/openclaw-windows-node.slnx b/openclaw-windows-node.slnx index edba045c..2a339643 100644 --- a/openclaw-windows-node.slnx +++ b/openclaw-windows-node.slnx @@ -14,6 +14,7 @@ + diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index e662ae84..ae4f807f 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -25,6 +25,13 @@ public class OpenClawGatewayClient : WebSocketClientBase "operator.approvals", "operator.pairing" ]; + private static readonly string[] s_operatorBootstrapScopes = + [ + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write" + ]; private enum SignatureTokenMode { @@ -59,8 +66,17 @@ private enum SignatureTokenMode private bool _operatorReadScopeUnavailable; private bool _pairingRequiredAwaitingApproval; private bool _authFailed; + private readonly bool _useBootstrapHandoffAuth; + + /// True when the gateway reported "pairing required" for this device. + public bool IsPairingRequired => _pairingRequiredAwaitingApproval; + + /// True when the device signature was rejected in all supported modes. + public bool IsAuthFailed => _authFailed; + private IReadOnlyList? _userRules; private bool _preferStructuredCategories = true; + private readonly System.Collections.Concurrent.ConcurrentDictionary> _pendingWizardResponses = new(); /// /// Controls whether structured notification metadata (Intent, Channel) takes priority @@ -134,9 +150,14 @@ protected override void OnDisposing() public IReadOnlyList GrantedOperatorScopes => _grantedOperatorScopes; public bool IsConnectedToGateway => IsConnected; - public OpenClawGatewayClient(string gatewayUrl, string token, IOpenClawLogger? logger = null) + public OpenClawGatewayClient( + string gatewayUrl, + string token, + IOpenClawLogger? logger = null, + bool useBootstrapHandoffAuth = false) : base(gatewayUrl, token, logger) { + _useBootstrapHandoffAuth = useBootstrapHandoffAuth; var dataPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenClawTray"); @@ -232,6 +253,41 @@ public async Task SendChatMessageAsync(string message, string? sessionKey = null _logger.Info($"Sent chat message ({message.Length} chars)"); } + /// + /// Sends a wizard RPC request and waits for the response payload. + /// Used for wizard.start, wizard.next, wizard.cancel, wizard.status. + /// + public async Task SendWizardRequestAsync(string method, object? parameters = null, int timeoutMs = 30000) + { + if (!IsConnected) + throw new InvalidOperationException("Gateway connection is not open"); + + var requestId = Guid.NewGuid().ToString(); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingWizardResponses[requestId] = completion; + TrackPendingRequest(requestId, method); + + try + { + await SendRawAsync(SerializeRequest(requestId, method, parameters)); + } + catch + { + _pendingWizardResponses.TryRemove(requestId, out _); + RemovePendingRequest(requestId); + throw; + } + + var completedTask = await Task.WhenAny(completion.Task, Task.Delay(timeoutMs, CancellationToken)); + if (completedTask != completion.Task) + { + _pendingWizardResponses.TryRemove(requestId, out _); + throw new TimeoutException($"Timed out waiting for {method} response"); + } + + return await completion.Task; + } + /// Request session list from gateway. public async Task RequestSessionsAsync() { @@ -384,6 +440,7 @@ private async Task SendConnectMessageAsync(string? nonce = null) { var requestId = Guid.NewGuid().ToString(); TrackPendingRequest(requestId, "connect"); + var requestedScopes = GetRequestedOperatorScopes(); var signedAt = _challengeTimestampMs ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var connectNonce = nonce ?? string.Empty; @@ -398,7 +455,7 @@ private async Task SendConnectMessageAsync(string? nonce = null) OperatorClientId, OperatorClientMode, OperatorRole, - s_operatorScopes, + requestedScopes, signatureToken) : _deviceIdentity.SignConnectPayloadV3( connectNonce, @@ -406,7 +463,7 @@ private async Task SendConnectMessageAsync(string? nonce = null) OperatorClientId, OperatorClientMode, OperatorRole, - s_operatorScopes, + requestedScopes, signatureToken, OperatorPlatform, OperatorDeviceFamily); @@ -430,11 +487,11 @@ private async Task SendConnectMessageAsync(string? nonce = null) displayName = OperatorClientDisplayName }, role = OperatorRole, - scopes = s_operatorScopes, + scopes = requestedScopes, caps = Array.Empty(), commands = Array.Empty(), permissions = new { }, - auth = new { token = _connectAuthToken }, + auth = BuildAuthPayload(), locale = "en-US", userAgent = "openclaw-windows-tray/1.0.0", device = new @@ -459,6 +516,40 @@ private async Task SendConnectMessageAsync(string? nonce = null) } } + private string[] GetRequestedOperatorScopes() => + _useBootstrapHandoffAuth && string.IsNullOrEmpty(_deviceIdentity.DeviceToken) + ? s_operatorBootstrapScopes + : s_operatorScopes; + + /// + /// Builds the auth payload for the connect handshake, matching the gateway's + /// HandshakeConnectAuth type: { token?, bootstrapToken?, deviceToken?, password? }. + /// Fresh devices send bootstrapToken for initial QR/setup-code pairing. + /// Paired devices send an explicit deviceToken. + /// + private Dictionary BuildAuthPayload() + { + var auth = new Dictionary { ["token"] = _connectAuthToken }; + + if (!_useBootstrapHandoffAuth) + { + return auth; + } + + if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken)) + { + // Paired device: send explicit device token for cleaner auth path + auth["deviceToken"] = _deviceIdentity.DeviceToken; + } + else + { + // Fresh device: send bootstrap token for initial pairing + auth["bootstrapToken"] = _token; + } + + return auth; + } + private async Task SendTrackedRequestAsync(string method, object? parameters = null) { if (!IsConnected) return; @@ -649,6 +740,27 @@ private void HandleResponse(JsonElement root) return; } + // Check for pending wizard response + if (requestId != null && _pendingWizardResponses.TryRemove(requestId, out var wizardCompletion)) + { + if (root.TryGetProperty("ok", out var okWiz) && okWiz.ValueKind == JsonValueKind.False) + { + var message = TryGetErrorMessage(root) ?? "wizard request failed"; + wizardCompletion.TrySetException(new InvalidOperationException(message)); + } + else if (root.TryGetProperty("payload", out var wizPayload)) + { + // Log the payload kind for debugging + _logger.Info($"Wizard response payload kind={wizPayload.ValueKind}, raw={wizPayload.ToString()?.Substring(0, Math.Min(200, wizPayload.ToString()?.Length ?? 0))}"); + wizardCompletion.TrySetResult(wizPayload.Clone()); + } + else + { + wizardCompletion.TrySetResult(root.Clone()); + } + return; + } + if (root.TryGetProperty("ok", out var okProp) && okProp.ValueKind == JsonValueKind.False) { diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 8f93a14c..f2b92ca8 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -7,6 +7,7 @@ using OpenClawTray.Helpers; using OpenClawTray.Services; using OpenClawTray.Windows; +using OpenClawTray.Onboarding; using System; using System.Collections.Frozen; using System.Collections.Generic; @@ -35,6 +36,31 @@ public partial class App : Application private TrayIcon? _trayIcon; private OpenClawGatewayClient? _gatewayClient; + + /// The persistent gateway client. Used by the onboarding wizard for RPC calls. + public OpenClawGatewayClient? GatewayClient => _gatewayClient; + + /// + /// Ensures the managed SSH tunnel is started using the current settings. + /// Used by the onboarding ConnectionPage when the user picks the SSH topology. + /// + public void EnsureSshTunnelStarted() => _sshTunnelService?.EnsureStarted(_settings); + + /// + /// Returns the HWND of the active onboarding window, or IntPtr.Zero if none. + /// Used by onboarding pages that need to host file pickers / dialogs. + /// + public IntPtr GetOnboardingWindowHandle() + => _onboardingWindow != null + ? WinRT.Interop.WindowNative.GetWindowHandle(_onboardingWindow) + : IntPtr.Zero; + + /// + /// Reinitializes the gateway client with current settings. + /// Called by the onboarding wizard after saving URL + Token. + /// + public void ReinitializeGatewayClient(bool useBootstrapHandoffAuth = false) => + InitializeGatewayClient(useBootstrapHandoffAuth); private SettingsManager? _settings; private SshTunnelService? _sshTunnelService; private GlobalHotkeyService? _globalHotkey; @@ -114,6 +140,18 @@ public partial class App : Application public App() { + // Language override for localization testing (e.g., OPENCLAW_LANGUAGE=zh-CN) + var langOverride = Environment.GetEnvironmentVariable("OPENCLAW_LANGUAGE"); + if (!string.IsNullOrEmpty(langOverride)) + { + // SECURITY: Whitelist known locale codes to prevent locale injection + string[] allowedLocales = ["en-us", "fr-fr", "nl-nl", "zh-cn", "zh-tw"]; + if (allowedLocales.Contains(langOverride.ToLowerInvariant())) + LocalizationHelper.SetLanguageOverride(langOverride); + else + Logger.Warn($"[App] Ignoring invalid OPENCLAW_LANGUAGE value: {langOverride}"); + } + InitializeComponent(); CheckPreviousRun(); @@ -289,7 +327,8 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) // Check for updates before launching. Skip in test instances — no UI dialogs, // no network calls, no startup delay. - if (DataDirOverride is null) + if (DataDirOverride is null && + Environment.GetEnvironmentVariable("OPENCLAW_SKIP_UPDATE_CHECK") != "1") { var shouldLaunch = await CheckForUpdatesAsync(); if (!shouldLaunch) @@ -305,10 +344,11 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) _sshTunnelService = new SshTunnelService(new AppLogger()); _sshTunnelService.TunnelExited += OnSshTunnelExited; - // First-run check - if (RequiresSetup(_settings)) + // First-run check (also supports forced onboarding for testing) + if (RequiresSetup(_settings) || + Environment.GetEnvironmentVariable("OPENCLAW_FORCE_ONBOARDING") == "1") { - await ShowSetupWizardAsync(); + await ShowOnboardingAsync(); } // Initialize tray icon (window-less pattern from WinUIEx) @@ -576,7 +616,7 @@ private void OnTrayMenuItemClicked(object? sender, string action) case "healthcheck": _ = RunHealthCheckAsync(userInitiated: true); break; case "checkupdates": _ = CheckForUpdatesUserInitiatedAsync(); break; case "settings": ShowSettings(); break; - case "setup": _ = ShowSetupWizardAsync(); break; + case "setup": _ = ShowOnboardingAsync(); break; case "autostart": ToggleAutoStart(); break; case "log": OpenLogFile(); break; case "logfolder": OpenLogFolder(); break; @@ -1176,16 +1216,28 @@ private void BuildTrayMenu(MenuFlyout flyout) #region Gateway Client - private void InitializeGatewayClient() + private void InitializeGatewayClient(bool useBootstrapHandoffAuth = false) { if (_settings == null) return; if (!EnsureSshTunnelConfigured()) return; + // Guard against empty gateway URL (e.g., fresh install before onboarding) + var gatewayUrl = _settings.GetEffectiveGatewayUrl(); + if (string.IsNullOrWhiteSpace(gatewayUrl)) + { + Logger.Info("Gateway URL not configured — skipping client initialization"); + return; + } + // Unsubscribe from old client if exists UnsubscribeGatewayEvents(); _lastGatewaySelf = null; - _gatewayClient = new OpenClawGatewayClient(_settings.GetEffectiveGatewayUrl(), _settings.Token, new AppLogger()); + _gatewayClient = new OpenClawGatewayClient( + gatewayUrl, + _settings.Token, + new AppLogger(), + useBootstrapHandoffAuth); _gatewayClient.SetUserRules(_settings.UserRules.Count > 0 ? _settings.UserRules : null); _gatewayClient.SetPreferStructuredCategories(_settings.PreferStructuredCategories); _gatewayClient.StatusChanged += OnConnectionStatusChanged; @@ -1327,16 +1379,7 @@ private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatu { if (args.Status == OpenClaw.Shared.PairingStatus.Pending) { - AddRecentActivity("Node pairing pending", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId); - var approvalCommand = $"openclaw devices approve {args.DeviceId}"; - // Show toast with approval instructions - ShowToast(new ToastContentBuilder() - .AddText(LocalizationHelper.GetString("Toast_PairingPending")) - .AddText(string.Format(LocalizationHelper.GetString("Toast_PairingPendingDetail"), args.DeviceId.Substring(0, 16))) - .AddButton(new ToastButton() - .SetContent(LocalizationHelper.GetString("Toast_CopyPairingCommand")) - .AddArgument("action", "copy_pairing_command") - .AddArgument("command", approvalCommand))); + ShowPairingPendingNotification(args.DeviceId); } else if (args.Status == OpenClaw.Shared.PairingStatus.Paired) { @@ -1355,6 +1398,24 @@ private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatu } catch { /* ignore */ } } + + public static string BuildPairingApprovalCommand(string deviceId) => + $"openclaw devices approve {deviceId}"; + + public void ShowPairingPendingNotification(string deviceId, string? approvalCommand = null) + { + var command = approvalCommand ?? BuildPairingApprovalCommand(deviceId); + var shortDeviceId = deviceId.Length > 16 ? deviceId[..16] : deviceId; + + AddRecentActivity("Node pairing pending", category: "node", dashboardPath: "nodes", nodeId: deviceId); + ShowToast(new ToastContentBuilder() + .AddText(LocalizationHelper.GetString("Toast_PairingPending")) + .AddText(string.Format(LocalizationHelper.GetString("Toast_PairingPendingDetail"), shortDeviceId)) + .AddButton(new ToastButton() + .SetContent(LocalizationHelper.GetString("Toast_CopyPairingCommand")) + .AddArgument("action", "copy_pairing_command") + .AddArgument("command", command))); + } private void OnNodeNotificationRequested(object? sender, OpenClaw.Shared.Capabilities.SystemNotifyArgs args) { @@ -1715,6 +1776,15 @@ private bool ShouldShowNotification(OpenClawNotification notification) if (notification.IsChat && !_settings.NotifyChatResponses) return false; + // Suppress chat notifications when a chat window is already showing them + if (notification.IsChat) + { + if (_webChatWindow != null && !_webChatWindow.IsClosed) + return false; + if (_onboardingWindow != null) + return false; // Onboarding window has chat overlay + } + var type = notification.Type; if (type == null) return true; return s_notifTypeMap.TryGetValue(type, out var selector) ? selector(_settings) : true; @@ -2542,24 +2612,31 @@ private void ShowActivityStream(string? filter = null) _activityStreamWindow.Activate(); } - private SetupWizardWindow? _setupWizard; + private OnboardingWindow? _onboardingWindow; - private async Task ShowSetupWizardAsync() + private async Task ShowOnboardingAsync() { if (_settings == null) return; - if (_setupWizard != null) + if (_onboardingWindow != null) { - try { _setupWizard.Activate(); return; } catch { _setupWizard = null; } + try { _onboardingWindow.Activate(); return; } catch { _onboardingWindow = null; } } - _setupWizard = new SetupWizardWindow(_settings); - _setupWizard.SetupCompleted += (s, e) => + _onboardingWindow = new OnboardingWindow(_settings); + _onboardingWindow.OnboardingCompleted += (s, e) => { - Logger.Info("Setup wizard completed, reinitializing connections"); - _setupWizard = null; + Logger.Info("Onboarding completed"); + _onboardingWindow = null; + + // If the persistent client was already initialized during onboarding, keep it + if (_gatewayClient?.IsConnectedToGateway == true) + { + Logger.Info("Gateway client already connected from onboarding — keeping"); + return; + } - // Mirror OnSettingsSaved — clean up both, then start only one + // Otherwise reinitialize with saved settings UnsubscribeGatewayEvents(); _gatewayClient?.Dispose(); _gatewayClient = null; @@ -2575,8 +2652,8 @@ private async Task ShowSetupWizardAsync() else InitializeGatewayClient(); }; - _setupWizard.Closed += (s, e) => _setupWizard = null; - _setupWizard.Activate(); + _onboardingWindow.Closed += (s, e) => _onboardingWindow = null; + _onboardingWindow.Activate(); } private void ShowSurfaceImprovementsTipIfNeeded() @@ -3081,7 +3158,7 @@ private void HandleDeepLink(string uri) DeepLinkHandler.Handle(uri, new DeepLinkActions { OpenSettings = ShowSettings, - OpenSetup = () => _ = ShowSetupWizardAsync(), + OpenSetup = () => _ = ShowOnboardingAsync(), RunHealthCheck = () => RunHealthCheckAsync(userInitiated: true), CheckForUpdates = CheckForUpdatesUserInitiatedAsync, OpenLogFile = OpenLogFile, @@ -3100,6 +3177,7 @@ private void HandleDeepLink(string uri) RestartSshTunnel = RestartSshTunnel, OpenChat = ShowWebChat, OpenCommandCenter = ShowStatusDetail, + OpenTrayMenu = ShowTrayMenuPopup, OpenActivityStream = ShowActivityStream, OpenNotificationHistory = ShowNotificationHistory, OpenDashboard = OpenDashboard, @@ -3171,7 +3249,7 @@ private void OnToastActivated(ToastNotificationActivatedEventArgsCompat args) } } - private static void CopyTextToClipboard(string text) + public static void CopyTextToClipboard(string text) { var dataPackage = new global::Windows.ApplicationModel.DataTransfer.DataPackage(); dataPackage.SetText(text); diff --git a/src/OpenClaw.Tray.WinUI/Helpers/GatewayChatHelper.cs b/src/OpenClaw.Tray.WinUI/Helpers/GatewayChatHelper.cs new file mode 100644 index 00000000..63adbdf3 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/GatewayChatHelper.cs @@ -0,0 +1,67 @@ +using Microsoft.UI.Xaml.Controls; +using Microsoft.Web.WebView2.Core; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Helpers; + +/// +/// Shared helper for building gateway chat URLs and initializing WebView2. +/// Used by both the onboarding ChatPage and the standalone WebChatWindow +/// to ensure visual and behavioral consistency. +/// +public static class GatewayChatHelper +{ + private static readonly string s_userDataFolder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenClawTray", "WebView2"); + + /// + /// Build the HTTP(S) chat URL from a WebSocket gateway URL. + /// Converts ws:// → http://, wss:// → https://, appends token and optional session key. + /// + /// + /// SECURITY NOTE: Token is passed as a URL query parameter (?token=...). This follows the + /// existing WebChatWindow pattern in the repo. Tokens in URLs can leak to server access logs, + /// WebView2 navigation logs, and Referrer headers. The NavigationStarting handler in + /// OnboardingWindow.cs strips query params before logging to mitigate log exposure. + /// Future improvement: inject token via Authorization header using CoreWebView2.AddWebResourceRequestedFilter. + /// + public static bool TryBuildChatUrl( + string gatewayUrl, + string token, + out string url, + out string errorMessage, + string? sessionKey = null) + { + return GatewayChatUrlBuilder.TryBuildChatUrl(gatewayUrl, token, out url, out errorMessage, sessionKey); + } + + /// + /// Initialize a WebView2 control with standard settings for gateway chat. + /// Sets up user data folder, configures settings, and returns when ready. + /// + public static async Task InitializeWebView2Async(WebView2 webView) + { + Directory.CreateDirectory(s_userDataFolder); + // Note: WEBVIEW2_USER_DATA_FOLDER is process-scoped (Environment.SetEnvironmentVariable + // without EnvironmentVariableTarget defaults to Process). This follows the existing + // WebChatWindow pattern. The env var only affects WebView2 instances in this process. + Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", s_userDataFolder); + + await webView.EnsureCoreWebView2Async(); + + webView.CoreWebView2.Settings.IsStatusBarEnabled = false; + webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = true; + webView.CoreWebView2.Settings.IsZoomControlEnabled = true; + // SECURITY: Disable features not needed for chat that could aid attacks + webView.CoreWebView2.Settings.AreBrowserAcceleratorKeysEnabled = false; // Prevents Ctrl+U view-source, etc. + webView.CoreWebView2.Settings.IsPasswordAutosaveEnabled = false; + webView.CoreWebView2.Settings.IsGeneralAutofillEnabled = false; + } + + private static bool IsLocalHost(Uri uri) + { + return GatewayChatUrlBuilder.IsLocalHost(uri); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Helpers/GatewayChatUrlBuilder.cs b/src/OpenClaw.Tray.WinUI/Helpers/GatewayChatUrlBuilder.cs new file mode 100644 index 00000000..dd37da45 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/GatewayChatUrlBuilder.cs @@ -0,0 +1,64 @@ +using OpenClaw.Shared; + +namespace OpenClawTray.Helpers; + +/// +/// Pure helper for building gateway chat URLs. +/// Extracted from GatewayChatHelper so the URL logic is testable without WinUI dependencies. +/// +public static class GatewayChatUrlBuilder +{ + /// + /// Build the HTTP(S) chat URL from a WebSocket gateway URL. + /// Converts ws:// → http://, wss:// → https://, appends token and optional session key. + /// + public static bool TryBuildChatUrl( + string gatewayUrl, + string token, + out string url, + out string errorMessage, + string? sessionKey = null) + { + url = string.Empty; + errorMessage = string.Empty; + + if (!GatewayUrlHelper.TryNormalizeWebSocketUrl(gatewayUrl, out var normalizedGatewayUrl) || + !Uri.TryCreate(normalizedGatewayUrl, UriKind.Absolute, out var gatewayUri)) + { + errorMessage = $"Invalid gateway URL: {gatewayUrl}"; + return false; + } + + var webScheme = gatewayUri.Scheme.Equals("wss", StringComparison.OrdinalIgnoreCase) + ? "https" + : "http"; + + if (webScheme == "http" && !IsLocalHost(gatewayUri)) + { + errorMessage = "Non-local gateways require a secure (wss://) connection for web chat."; + return false; + } + + var builder = new UriBuilder(gatewayUri) + { + Scheme = webScheme, + Port = gatewayUri.Port + }; + + var baseUrl = builder.Uri.GetLeftPart(UriPartial.Authority); + url = $"{baseUrl}?token={Uri.EscapeDataString(token)}"; + + if (!string.IsNullOrEmpty(sessionKey)) + url += $"&session={Uri.EscapeDataString(sessionKey)}"; + + return true; + } + + /// + /// Checks whether the given URI points to a loopback address. + /// + public static bool IsLocalHost(Uri uri) + { + return uri.IsLoopback || string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Helpers/QrSetupCodeReader.cs b/src/OpenClaw.Tray.WinUI/Helpers/QrSetupCodeReader.cs new file mode 100644 index 00000000..7883b62a --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/QrSetupCodeReader.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using ZXing; +using ZXing.Common; +using DrawingBitmap = System.Drawing.Bitmap; +using DrawingGraphics = System.Drawing.Graphics; +using DrawingImageLockMode = System.Drawing.Imaging.ImageLockMode; +using DrawingPixelFormat = System.Drawing.Imaging.PixelFormat; + +namespace OpenClawTray.Helpers; + +/// +/// Decodes an OpenClaw setup code from a QR-encoded image. Shared by the + /// legacy SetupWizardWindow and the onboarding ConnectionPage. +/// +public static class QrSetupCodeReader +{ + /// + /// Decodes the first QR code found in (PNG/JPEG/BMP/GIF) + /// and returns the encoded text. Returns null when no QR code is found. + /// + public static string? Decode(Stream stream) + { + using var source = new DrawingBitmap(stream); + using var bitmap = new DrawingBitmap(source.Width, source.Height, DrawingPixelFormat.Format32bppArgb); + using (var graphics = DrawingGraphics.FromImage(bitmap)) + { + graphics.DrawImage(source, 0, 0, source.Width, source.Height); + } + + var bounds = new System.Drawing.Rectangle(0, 0, bitmap.Width, bitmap.Height); + var data = bitmap.LockBits(bounds, DrawingImageLockMode.ReadOnly, DrawingPixelFormat.Format32bppArgb); + try + { + var rowBytes = bitmap.Width * 4; + var pixels = new byte[rowBytes * bitmap.Height]; + for (var y = 0; y < bitmap.Height; y++) + { + Marshal.Copy(System.IntPtr.Add(data.Scan0, y * data.Stride), pixels, y * rowBytes, rowBytes); + } + + var reader = new BarcodeReaderGeneric + { + AutoRotate = true, + Options = new DecodingOptions + { + PossibleFormats = new List { BarcodeFormat.QR_CODE }, + TryHarder = true, + TryInverted = true + } + }; + + var result = reader.Decode(pixels, bitmap.Width, bitmap.Height, RGBLuminanceSource.BitmapFormat.BGRA32); + return string.IsNullOrWhiteSpace(result?.Text) ? null : result.Text; + } + finally + { + bitmap.UnlockBits(data); + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Helpers/VisualTestCapture.cs b/src/OpenClaw.Tray.WinUI/Helpers/VisualTestCapture.cs new file mode 100644 index 00000000..490d46df --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/VisualTestCapture.cs @@ -0,0 +1,178 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using OpenClawTray.Services; +using System.Collections.Concurrent; +using System.Runtime.InteropServices.WindowsRuntime; +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; + +namespace OpenClawTray.Helpers; + +internal static class VisualTestCapture +{ + private static readonly ConcurrentDictionary s_captureIndexes = new(StringComparer.OrdinalIgnoreCase); + + public static void CaptureOnLoaded(FrameworkElement root, string surfaceName) + { + var rootDir = GetVisualTestDirectory(); + if (rootDir is null) + return; + + var surfaceDir = Path.Combine(rootDir, SanitizePathSegment(surfaceName)); + root.Loaded += (_, _) => + { + _ = CaptureAfterDelayAsync(root, surfaceDir, 300); + _ = CaptureAfterDelayAsync(root, surfaceDir, 1500); + _ = CaptureAfterDelayAsync(root, surfaceDir, 3500); + }; + } + + public static async Task CaptureAsync(FrameworkElement root, string surfaceName) + { + var rootDir = GetVisualTestDirectory(); + if (rootDir is null) + return; + + await CaptureToDirectoryAsync(root, Path.Combine(rootDir, SanitizePathSegment(surfaceName))); + } + + private static async Task CaptureAfterDelayAsync(FrameworkElement root, string surfaceDir, int delayMs) + { + await Task.Delay(delayMs); + await CaptureToDirectoryAsync(root, surfaceDir); + } + + private static async Task CaptureToDirectoryAsync(FrameworkElement root, string surfaceDir) + { + try + { + Directory.CreateDirectory(surfaceDir); + if (root.DispatcherQueue.HasThreadAccess) + { + await CaptureOnUiThreadAsync(root, surfaceDir); + return; + } + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var enqueued = root.DispatcherQueue.TryEnqueue(async () => + { + try + { + await CaptureOnUiThreadAsync(root, surfaceDir); + tcs.SetResult(); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }); + + if (!enqueued) + throw new InvalidOperationException("Dispatcher queue rejected capture work."); + + await tcs.Task; + } + catch (Exception ex) + { + Logger.Warn($"[VisualTest] Capture failed for {surfaceDir}: {ex.Message}"); + } + } + + private static async Task CaptureOnUiThreadAsync(FrameworkElement root, string surfaceDir) + { + Action restoreBackground = () => { }; + try + { + var fileName = $"capture-{s_captureIndexes.AddOrUpdate(surfaceDir, 0, (_, current) => current + 1):D2}.png"; + var filePath = Path.Combine(surfaceDir, fileName); + restoreBackground = ApplyCaptureBackground(root); + + var rtb = new RenderTargetBitmap(); + await rtb.RenderAsync(root); + var pixels = await rtb.GetPixelsAsync(); + + using var stream = new InMemoryRandomAccessStream(); + var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, stream); + encoder.SetPixelData( + BitmapPixelFormat.Bgra8, + BitmapAlphaMode.Premultiplied, + (uint)rtb.PixelWidth, + (uint)rtb.PixelHeight, + 96, + 96, + pixels.ToArray()); + await encoder.FlushAsync(); + + stream.Seek(0); + var reader = new DataReader(stream); + await reader.LoadAsync((uint)stream.Size); + var bytes = new byte[stream.Size]; + reader.ReadBytes(bytes); + await File.WriteAllBytesAsync(filePath, bytes); + } + finally + { + restoreBackground(); + } + } + + private static Action ApplyCaptureBackground(FrameworkElement root) + { + var background = new SolidColorBrush(Microsoft.UI.Colors.White); + + switch (root) + { + case Panel panel when IsTransparent(panel.Background): + { + var original = panel.Background; + panel.Background = background; + return () => panel.Background = original; + } + case Border border when IsTransparent(border.Background): + { + var original = border.Background; + border.Background = background; + return () => border.Background = original; + } + case ScrollViewer scrollViewer when IsTransparent(scrollViewer.Background): + { + var original = scrollViewer.Background; + scrollViewer.Background = background; + return () => scrollViewer.Background = original; + } + } + + return () => { }; + } + + private static bool IsTransparent(Brush? brush) => + brush is null || brush is SolidColorBrush { Color.A: 0 }; + + private static string? GetVisualTestDirectory() + { + if (Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST") != "1") + return null; + + var path = Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST_DIR"); + if (string.IsNullOrWhiteSpace(path) || path.Contains('\0')) + return null; + + try + { + return Path.GetFullPath(path); + } + catch + { + return null; + } + } + + private static string SanitizePathSegment(string value) + { + foreach (var invalid in Path.GetInvalidFileNameChars()) + value = value.Replace(invalid, '-'); + return value; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs new file mode 100644 index 00000000..027ced77 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingApp.cs @@ -0,0 +1,117 @@ +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using OpenClawTray.FunctionalUI.Navigation; +using OpenClawTray.Onboarding.Services; +using OpenClawTray.Onboarding.Pages; +using OpenClawTray.Onboarding.Widgets; +using static OpenClawTray.FunctionalUI.Factories; +using Microsoft.UI.Xaml; + +namespace OpenClawTray.Onboarding; + +/// +/// Root functional UI component for the onboarding wizard. +/// Manages navigation between pages with GlowingIcon header, +/// NavigationHost for page content, and a step indicator + back/next nav bar. +/// Matches macOS OnboardingView layout: icon → page content → navigation bar. +/// +public sealed class OnboardingApp : Component +{ + public override Element Render() + { + // Seed navigation + page index from Props.CurrentRoute (used by visual tests via + // OPENCLAW_ONBOARDING_START_ROUTE; defaults to Welcome on normal launches). + var pagesInit = Props.GetPageOrder(); + var initialIdx = Math.Max(0, Array.IndexOf(pagesInit, Props.CurrentRoute)); + var nav = UseNavigation(pagesInit[initialIdx]); + var (pageIndex, setPageIndex) = UseState(initialIdx); + var pages = Props.GetPageOrder(); + + // Clamp pageIndex if page order changed (e.g., node mode toggled) + if (pageIndex >= pages.Length) + { + setPageIndex(pages.Length - 1); + } + + void GoNext() + { + if (pageIndex < pages.Length - 1) + { + setPageIndex(pageIndex + 1); + nav.Navigate(pages[pageIndex + 1]); + Props.NotifyPageChanged(); + Props.NotifyRouteChanged(pages[pageIndex + 1]); + } + } + + void GoBack() + { + if (pageIndex > 0) + { + setPageIndex(pageIndex - 1); + nav.GoBack(); + Props.NotifyPageChanged(); + Props.NotifyRouteChanged(pages[pageIndex - 1]); + } + } + + var isLastPage = pageIndex >= pages.Length - 1; + + // VStack for functional UI content (icon + pages only). + // The nav bar is rendered natively in OnboardingWindow for reliable bottom pinning. + return VStack( + // GlowingIcon header + Component() + .Margin(0, 8, 0, 4), + + // Page content — fixed height prevents nav bar from jumping between pages + (NavigationHost(nav, route => route switch + { + OnboardingRoute.Welcome => Component(), + OnboardingRoute.Connection => Component(Props), + OnboardingRoute.Ready => Component(Props), + OnboardingRoute.Wizard => Component(Props), + OnboardingRoute.Permissions => Component(Props), + OnboardingRoute.Chat => Component(Props), + _ => TextBlock("Unknown page"), + }) with { Transition = NavigationTransition.SlideInOnly( + direction: SlideDirection.FromRight, + duration: TimeSpan.FromMilliseconds(400), + distance: 80) }) + .Height(680), + + // Navigation bar + HStack(16, + Button(Helpers.LocalizationHelper.GetString("Onboarding_Back"), GoBack) + .Disabled(pageIndex <= 0) + .Width(100) + .Set(b => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingBack")), + Component( + new StepIndicatorProps(pages.Length, pageIndex)), + Button( + isLastPage + ? Helpers.LocalizationHelper.GetString("Onboarding_Finish") + : Helpers.LocalizationHelper.GetString("Onboarding_Next"), + isLastPage ? Props.Complete : GoNext) + .Width(100) + .Set(b => + { + Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingNext"); + b.Resources["ButtonBackground"] = new Microsoft.UI.Xaml.Media.SolidColorBrush( + Microsoft.UI.ColorHelper.FromArgb(255, 211, 47, 47)); // #D32F2F + b.Resources["ButtonBackgroundPointerOver"] = new Microsoft.UI.Xaml.Media.SolidColorBrush( + Microsoft.UI.ColorHelper.FromArgb(255, 198, 40, 40)); // #C62828 + b.Resources["ButtonBackgroundPressed"] = new Microsoft.UI.Xaml.Media.SolidColorBrush( + Microsoft.UI.ColorHelper.FromArgb(255, 183, 28, 28)); // #B71C1C + b.Resources["ButtonForeground"] = new Microsoft.UI.Xaml.Media.SolidColorBrush( + Microsoft.UI.Colors.White); + b.Resources["ButtonForegroundPointerOver"] = new Microsoft.UI.Xaml.Media.SolidColorBrush( + Microsoft.UI.Colors.White); + b.Resources["ButtonForegroundPressed"] = new Microsoft.UI.Xaml.Media.SolidColorBrush( + Microsoft.UI.Colors.White); + }) + ).HAlign(HorizontalAlignment.Center) + .Padding(0, 12, 0, 12) + ).Padding(20); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs new file mode 100644 index 00000000..fc55ef59 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs @@ -0,0 +1,646 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Windowing; +using Microsoft.UI.Dispatching; +using Microsoft.Web.WebView2.Core; +using OpenClawTray.Helpers; +using OpenClawTray.Services; +using OpenClawTray.Onboarding.Services; +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Hosting; +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; +using System.Runtime.InteropServices.WindowsRuntime; +using WinUIEx; + +namespace OpenClawTray.Onboarding; + +/// +/// Host window for the functional UI onboarding wizard. +/// Manages a WebView2 overlay for the Chat page to provide a consistent +/// chat experience that matches the post-setup WebChatWindow. +/// Supports visual test capture via OPENCLAW_VISUAL_TEST env var. +/// +public sealed class OnboardingWindow : WindowEx +{ + public event EventHandler? OnboardingCompleted; + public bool Completed { get; private set; } + + private readonly SettingsManager _settings; + private readonly FunctionalHostControl _host; + private readonly string? _visualTestDir; + private readonly DispatcherQueue _dispatcherQueue; + private int _captureIndex; + + // WebView2 overlay for Chat page + private readonly Grid _rootGrid; + private readonly Grid _chatOverlay; + private WebView2? _chatWebView; + private ProgressRing? _chatLoadingRing; + private TextBlock? _chatErrorText; + private Button? _chatRetryButton; + private bool _chatWebViewInitialized; + private readonly OnboardingState _state; + private bool _stateDisposed; + + public OnboardingWindow(SettingsManager settings) + { + _settings = settings; + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + _visualTestDir = Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST") == "1" + ? ValidateTestDir(Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST_DIR") + ?? Path.Combine(Path.GetTempPath(), "openclaw-visual-test")) + : null; + + Title = LocalizationHelper.GetString("Onboarding_Title"); + this.SetWindowSize(720, 900); + this.CenterOnScreen(); + this.SetIcon("Assets\\openclaw.ico"); + SystemBackdrop = new MicaBackdrop(); + + if (AppWindow.Presenter is OverlappedPresenter presenter) + { + presenter.IsResizable = false; + presenter.IsMaximizable = false; + } + + _state = new OnboardingState(settings); + _state.Finished += OnOnboardingFinished; + _state.RouteChanged += OnRouteChanged; + + // Optional override for visual tests / engineering: jump straight to a route. + // Accepts the OnboardingRoute enum name (e.g., "Connection"). + var startRoute = Environment.GetEnvironmentVariable("OPENCLAW_ONBOARDING_START_ROUTE"); + if (!string.IsNullOrWhiteSpace(startRoute) && + Enum.TryParse(startRoute, ignoreCase: true, out var parsed)) + { + _state.CurrentRoute = parsed; + } + // Optional override for visual tests: pre-select a connection mode (Local/Wsl/Remote/Ssh/Later). + var startMode = Environment.GetEnvironmentVariable("OPENCLAW_ONBOARDING_START_MODE"); + if (!string.IsNullOrWhiteSpace(startMode) && + Enum.TryParse(startMode, ignoreCase: true, out var parsedMode)) + { + _state.Mode = parsedMode; + } + + _host = new FunctionalHostControl(); + _host.Mount(ctx => + { + var (s, _) = ctx.UseState(_state); + return Factories.Component(s); + }); + + // Build the chat overlay (hidden by default) + // Leave bottom 60px uncovered so the functional UI nav bar (Back/Next/dots) is visible and clickable + _chatOverlay = BuildChatOverlay(); + _chatOverlay.Visibility = Visibility.Collapsed; + _chatOverlay.VerticalAlignment = VerticalAlignment.Top; + + // Root grid: functional UI host fills everything, overlay sits on top (except nav bar) + _rootGrid = new Grid + { + Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.White) + }; + _rootGrid.Children.Add(_host); + _rootGrid.Children.Add(_chatOverlay); + Content = _rootGrid; + Closed += OnClosed; + + // Size the overlay after layout — leave space for the nav bar + // Nav bar is ~60px + VStack bottom padding 20px = 80px minimum + _rootGrid.SizeChanged += (_, args) => + { + _chatOverlay.Height = Math.Max(0, args.NewSize.Height - 84); + }; + + // Auto-capture in visual test mode + if (_visualTestDir != null) + { + Directory.CreateDirectory(_visualTestDir); + + _host.Loaded += (_, _) => + { + DispatcherQueue.GetForCurrentThread().TryEnqueue( + DispatcherQueuePriority.Low, + () => _ = CaptureCurrentPageAsync()); + }; + + Task.Delay(1500).ContinueWith(_ => + _dispatcherQueue.TryEnqueue(() => _ = CaptureCurrentPageAsync()), + TaskScheduler.Default); + Task.Delay(5000).ContinueWith(_ => + _dispatcherQueue.TryEnqueue(() => _ = CaptureCurrentPageAsync()), + TaskScheduler.Default); + + _state.PageChanged += (_, _) => + { + Task.Delay(500).ContinueWith(_ => + _dispatcherQueue.TryEnqueue(() => _ = CaptureCurrentPageAsync()), + TaskScheduler.Default); + }; + } + } + + private Grid BuildChatOverlay() + { + var grid = new Grid(); + + // Try to use theme-aware brush, fall back to white + try + { + grid.Background = (Microsoft.UI.Xaml.Media.Brush) + Microsoft.UI.Xaml.Application.Current.Resources["SolidBackgroundFillColorBaseBrush"]; + } + catch + { + grid.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush( + Microsoft.UI.Colors.White); + } + + // Match the functional UI layout: 20px padding, header + WebView2 content + grid.Padding = new Thickness(20, 0, 20, 0); + grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(70) }); // Header space + grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); // WebView2 + + // Header: lobster icon + title (matches other pages) + var headerStack = new StackPanel + { + Orientation = Orientation.Vertical, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + var iconBlock = new TextBlock + { + Text = "🦞", + FontSize = 48, + HorizontalAlignment = HorizontalAlignment.Center + }; + headerStack.Children.Add(iconBlock); + Grid.SetRow(headerStack, 0); + grid.Children.Add(headerStack); + + // Chat content area + var chatArea = new Grid { Margin = new Thickness(0, 4, 0, 0) }; + chatArea.CornerRadius = new CornerRadius(8); + + _chatWebView = new WebView2 + { + Visibility = Visibility.Collapsed + }; + chatArea.Children.Add(_chatWebView); + + _chatLoadingRing = new ProgressRing + { + IsActive = true, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + chatArea.Children.Add(_chatLoadingRing); + + _chatErrorText = new TextBlock + { + TextWrapping = TextWrapping.Wrap, + Visibility = Visibility.Collapsed, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Opacity = 0.7, + FontSize = 14, + MaxWidth = 400 + }; + chatArea.Children.Add(_chatErrorText); + + Grid.SetRow(chatArea, 1); + grid.Children.Add(chatArea); + + return grid; + } + + private void OnRouteChanged(object? sender, OnboardingRoute route) + { + _dispatcherQueue.TryEnqueue(() => + { + if (route == OnboardingRoute.Chat) + { + _chatOverlay.Visibility = Visibility.Visible; + if (!_chatWebViewInitialized) + _ = InitializeChatWebViewAsync(); + } + else + { + _chatOverlay.Visibility = Visibility.Collapsed; + } + }); + } + + private async Task InitializeChatWebViewAsync() + { + if (_chatWebView == null) return; + _chatWebViewInitialized = true; + + try + { + Logger.Info("[OnboardingChat] Initializing WebView2 chat overlay"); + + var gatewayUrl = _state.Settings.GetEffectiveGatewayUrl(); + // Use settings token for chat URL — this is the gateway shared secret + // that the chat web UI's JavaScript uses for WebSocket authentication. + // NOTE: Do NOT use the client's connect auth token here. After device pairing, + // that becomes the Ed25519 device token, which the HTTP chat JS doesn't understand. + var token = _state.Settings.Token; + + // Pre-flight: verify gateway is reachable before loading chat + var app = (App)Microsoft.UI.Xaml.Application.Current; + var gatewayClient = app.GatewayClient; + if (gatewayClient == null || !gatewayClient.IsConnectedToGateway) + { + Logger.Warn("[OnboardingChat] Gateway not connected, waiting..."); + for (int i = 0; i < 15; i++) + { + await Task.Delay(1000); + gatewayClient = app.GatewayClient; + if (gatewayClient?.IsConnectedToGateway == true) break; + } + if (gatewayClient == null || !gatewayClient.IsConnectedToGateway) + { + Logger.Warn("[OnboardingChat] Gateway still not connected after 15s"); + ShowChatError("Gateway is not connected.\nComplete the connection setup first, then come back to chat."); + return; + } + Logger.Info("[OnboardingChat] Gateway connected after waiting"); + } + + // Dev port override for testing with non-default gateway port + if (gatewayUrl == "ws://localhost:18789") + { + var devPort = Environment.GetEnvironmentVariable("OPENCLAW_GATEWAY_PORT"); + // SECURITY: Validate port is a valid number in range 1-65535 + if (!string.IsNullOrEmpty(devPort) && int.TryParse(devPort, out var port) && port >= 1 && port <= 65535) + gatewayUrl = $"ws://localhost:{port}"; + else if (!string.IsNullOrEmpty(devPort)) + Logger.Warn($"[OnboardingChat] Invalid OPENCLAW_GATEWAY_PORT value: {devPort}"); + } + + await GatewayChatHelper.InitializeWebView2Async(_chatWebView); + + // Bridge JS console to app logs via WebView2 postMessage + _chatWebView.CoreWebView2.WebMessageReceived += (s, e) => + { + try + { + var msg = e.TryGetWebMessageAsString(); + if (!string.IsNullOrEmpty(msg)) + Logger.Debug($"[OnboardingChat-JS] {msg}"); + } + catch { } + }; + + // SECURITY: Restrict navigation to gateway origin only and strip tokens from logs + // (matches existing WebChatWindow.xaml.cs pattern) + string? _allowedOrigin = null; + + _chatWebView.CoreWebView2.NavigationStarting += (s, e) => + { + // Strip query params to avoid logging tokens + var safeUri = e.Uri?.Split('?')[0] ?? "unknown"; + Logger.Info($"[OnboardingChat] Navigation starting to {safeUri}"); + + // Block navigation to unexpected origins (prevent open redirects) + if (_allowedOrigin != null && e.Uri != null) + { + if (Uri.TryCreate(e.Uri, UriKind.Absolute, out var navUri)) + { + var navOrigin = $"{navUri.Scheme}://{navUri.Authority}"; + if (!string.Equals(navOrigin, _allowedOrigin, StringComparison.OrdinalIgnoreCase) + && !navUri.Scheme.Equals("about", StringComparison.OrdinalIgnoreCase)) + { + Logger.Warn($"[OnboardingChat] Blocked navigation to external origin: {safeUri}"); + e.Cancel = true; + } + } + } + }; + + _chatWebView.CoreWebView2.NavigationCompleted += (s, e) => + { + _dispatcherQueue.TryEnqueue(() => + { + if (_chatLoadingRing != null) + { + _chatLoadingRing.IsActive = false; + _chatLoadingRing.Visibility = Visibility.Collapsed; + } + + if (!e.IsSuccess) + { + Logger.Warn($"[OnboardingChat] Navigation failed: {e.WebErrorStatus}"); + ShowChatError($"Could not load chat ({e.WebErrorStatus}).\nMake sure the gateway is running."); + return; + } + + if (_chatWebView != null) + { + _chatWebView.Visibility = Visibility.Visible; + + // Inject console bridge — forward JS errors/warnings to app log via postMessage + _ = _chatWebView.CoreWebView2.ExecuteScriptAsync(@" + (function() { + const origError = console.error; + const origWarn = console.warn; + const origLog = console.log; + console.error = function() { + origError.apply(console, arguments); + try { window.chrome.webview.postMessage('[ERROR] ' + Array.from(arguments).join(' ')); } catch(e) {} + }; + console.warn = function() { + origWarn.apply(console, arguments); + try { window.chrome.webview.postMessage('[WARN] ' + Array.from(arguments).join(' ')); } catch(e) {} + }; + // Also forward OpenClaw-specific logs + const origLogFn = console.log; + console.log = function() { + origLogFn.apply(console, arguments); + const msg = Array.from(arguments).join(' '); + if (msg.includes('OpenClaw') || msg.includes('openclaw') || msg.includes('websocket') || msg.includes('WebSocket') || msg.includes('session') || msg.includes('error') || msg.includes('Error')) + try { window.chrome.webview.postMessage('[LOG] ' + msg); } catch(e) {} + }; + // Capture unhandled errors + window.addEventListener('error', function(e) { + try { window.chrome.webview.postMessage('[UNCAUGHT] ' + e.message + ' at ' + e.filename + ':' + e.lineno); } catch(ex) {} + }); + window.addEventListener('unhandledrejection', function(e) { + try { window.chrome.webview.postMessage('[REJECTION] ' + (e.reason?.message || e.reason || 'unknown')); } catch(ex) {} + }); + window.chrome.webview.postMessage('[BRIDGE] Console bridge installed'); + })(); + "); + + _ = SendBootstrapMessageAsync(); + } + }); + }; + + if (GatewayChatHelper.TryBuildChatUrl(gatewayUrl, token, out var url, out var error, sessionKey: "onboarding")) + { + // Record allowed origin for NavigationStarting restriction + if (Uri.TryCreate(url, UriKind.Absolute, out var chatUri)) + _allowedOrigin = $"{chatUri.Scheme}://{chatUri.Authority}"; + + var safeUrl = url.Split('?')[0]; + Logger.Info($"[OnboardingChat] Navigating to {safeUrl} (session=onboarding)"); + _chatWebView.CoreWebView2.Navigate(url); + } + else + { + Logger.Warn($"[OnboardingChat] URL build failed: {error}"); + ShowChatError(error); + } + } + catch (Exception ex) + { + Logger.Error($"[OnboardingChat] WebView2 init failed: {ex.Message}"); + ShowChatError($"Chat unavailable: {ex.Message}\n\nYou can chat from the tray menu after setup."); + } + } + + private void ShowChatError(string message) + { + if (_chatLoadingRing != null) + { + _chatLoadingRing.IsActive = false; + _chatLoadingRing.Visibility = Visibility.Collapsed; + } + if (_chatErrorText != null) + { + _chatErrorText.Text = message; + _chatErrorText.Visibility = Visibility.Visible; + } + + // Add retry button if not already present + if (_chatRetryButton == null && _chatErrorText?.Parent is Panel parentPanel) + { + _chatRetryButton = new Button + { + Content = "Retry", + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 40, 0, 0) + }; + _chatRetryButton.Click += (s, e) => + { + _chatWebViewInitialized = false; + if (_chatErrorText != null) _chatErrorText.Visibility = Visibility.Collapsed; + if (_chatRetryButton != null) _chatRetryButton.Visibility = Visibility.Collapsed; + if (_chatLoadingRing != null) + { + _chatLoadingRing.IsActive = true; + _chatLoadingRing.Visibility = Visibility.Visible; + } + _ = InitializeChatWebViewAsync(); + }; + parentPanel.Children.Add(_chatRetryButton); + } + else if (_chatRetryButton != null) + { + _chatRetryButton.Visibility = Visibility.Visible; + } + } + + private bool _bootstrapSent; + + /// + /// Auto-sends the bootstrap kickoff message after the web chat loads. + /// Waits for the WebSocket to connect, then injects the message via JS. + /// Matches macOS's maybeKickoffOnboardingChat behavior. + /// + private async Task SendBootstrapMessageAsync() + { + if (_bootstrapSent || _chatWebView?.CoreWebView2 == null) return; + _bootstrapSent = true; + + const string bootstrapMessage = + "Hi! I just installed OpenClaw and you're my brand-new agent. " + + "Please start the first-run ritual from BOOTSTRAP.md, ask one question at a time, " + + "and before we talk about WhatsApp/Telegram, visit soul.md with me to craft SOUL.md: " + + "ask what matters to me and how you should be. Then guide me through choosing " + + "how we should talk (web-only, WhatsApp, or Telegram)."; + + try + { + // Wait for the web UI to initialize its WebSocket connection + await Task.Delay(3000); + + // Inject JS that finds the chat input and sends the bootstrap message. + // The Lit-based UI uses shadow DOM, so we traverse through custom elements. + // SECURITY: Use JsonSerializer to safely encode the message as a JS string literal, + // preventing XSS via template expression injection (${...}), quotes, or backslashes. + var safeMsg = System.Text.Json.JsonSerializer.Serialize(bootstrapMessage); + var js = $$""" + (function() { + const msg = {{safeMsg}}; + + // Strategy 1: Find textarea/input in the page (may be in shadow DOM) + function findInput(root) { + const inputs = root.querySelectorAll('textarea, input[type="text"]'); + for (const input of inputs) { + if (input.offsetParent !== null || input.offsetHeight > 0) return input; + } + // Search shadow DOMs + const elements = root.querySelectorAll('*'); + for (const el of elements) { + if (el.shadowRoot) { + const found = findInput(el.shadowRoot); + if (found) return found; + } + } + return null; + } + + function findButton(root) { + // Look for send buttons + const buttons = root.querySelectorAll('button'); + for (const btn of buttons) { + const text = (btn.textContent || '').toLowerCase(); + const label = (btn.getAttribute('aria-label') || '').toLowerCase(); + if (text.includes('send') || label.includes('send') || + btn.querySelector('svg') && btn.closest('form')) { + return btn; + } + } + const elements = root.querySelectorAll('*'); + for (const el of elements) { + if (el.shadowRoot) { + const found = findButton(el.shadowRoot); + if (found) return found; + } + } + return null; + } + + const input = findInput(document); + if (input) { + // Set value and dispatch events to trigger Lit's data binding + input.value = msg; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + + // Try to find and click the send button + setTimeout(() => { + const btn = findButton(document); + if (btn) { + btn.click(); + console.log('[OpenClaw] Bootstrap message sent via button click'); + } else { + // Try Enter key as fallback + input.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true + })); + console.log('[OpenClaw] Bootstrap message sent via Enter key'); + } + }, 200); + } else { + console.warn('[OpenClaw] Could not find chat input for bootstrap'); + } + })(); + """; + + await _chatWebView.CoreWebView2.ExecuteScriptAsync(js); + Logger.Info("[OnboardingChat] Bootstrap message injection executed"); + } + catch (Exception ex) + { + Logger.Warn($"[OnboardingChat] Bootstrap injection failed: {ex.Message}"); + // Not fatal — user can type manually + } + } + + /// + /// Captures the current window content to a PNG file. + /// Called automatically on page navigation when OPENCLAW_VISUAL_TEST=1. + /// + public async Task CaptureCurrentPageAsync() + { + if (_visualTestDir == null) return; + try + { + await Task.Delay(300); + + var fileName = $"page-{_captureIndex:D2}.png"; + var filePath = Path.Combine(_visualTestDir, fileName); + + var rtb = new RenderTargetBitmap(); + await rtb.RenderAsync(_rootGrid); + var pixels = await rtb.GetPixelsAsync(); + var pixelBytes = pixels.ToArray(); + + using var stream = new InMemoryRandomAccessStream(); + var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, stream); + encoder.SetPixelData( + BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied, + (uint)rtb.PixelWidth, (uint)rtb.PixelHeight, + 96, 96, pixelBytes); + await encoder.FlushAsync(); + + stream.Seek(0); + var reader = new DataReader(stream); + await reader.LoadAsync((uint)stream.Size); + var bytes = new byte[stream.Size]; + reader.ReadBytes(bytes); + await File.WriteAllBytesAsync(filePath, bytes); + + Logger.Info($"[VisualTest] Captured {fileName} ({rtb.PixelWidth}x{rtb.PixelHeight})"); + _captureIndex++; + } + catch (Exception ex) + { + Logger.Warn($"[VisualTest] Capture failed: {ex.Message}"); + } + } + + + private void OnOnboardingFinished(object? sender, EventArgs e) + { + _settings.Save(); + Completed = true; + _state.GatewayClient = null; + OnboardingCompleted?.Invoke(this, EventArgs.Empty); + Close(); + } + + private void OnClosed(object sender, WindowEventArgs args) + { + if (_stateDisposed) return; + _stateDisposed = true; + _state.Finished -= OnOnboardingFinished; + _state.RouteChanged -= OnRouteChanged; + if (Completed) + { + _state.GatewayClient = null; + } + _state.Dispose(); + } + + /// + /// SECURITY: Validate visual test directory path to prevent directory traversal. + /// Returns null if the path is suspicious. + /// + private static string? ValidateTestDir(string path) + { + if (string.IsNullOrWhiteSpace(path)) return null; + if (path.Contains('\0')) return null; + + try + { + var fullPath = Path.GetFullPath(path); + // Ensure it doesn't escape via .. traversal to unexpected locations + if (fullPath.Contains("..")) return null; + return fullPath; + } + catch + { + return null; + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ChatPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ChatPage.cs new file mode 100644 index 00000000..c4896e1d --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ChatPage.cs @@ -0,0 +1,39 @@ +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using OpenClawTray.Helpers; +using OpenClawTray.Onboarding.Services; +using static OpenClawTray.FunctionalUI.Factories; +using Microsoft.UI.Xaml; + +namespace OpenClawTray.Onboarding.Pages; + +/// +/// Page 4: Meet your Agent — embedded gateway chat via WebView2 overlay. +/// The actual chat UI is managed by OnboardingWindow's WebView2 overlay + /// which shows/hides based on the current route. This functional UI component +/// serves as a transparent placeholder that lets the overlay show through. +/// +public sealed class ChatPage : Component +{ + public override Element Render() + { + // This page is intentionally minimal — the WebView2 overlay + // in OnboardingWindow renders the real chat UI on top. + // We show a brief loading message that's visible only until + // the WebView2 finishes initializing. + return VStack(16, + TextBlock(LocalizationHelper.GetString("Onboarding_Chat_Title")) + .FontSize(22) + .FontWeight(new global::Windows.UI.Text.FontWeight(700)) + .HAlign(HorizontalAlignment.Center), + + TextBlock(LocalizationHelper.GetString("Onboarding_Chat_Loading")) + .FontSize(14) + .Opacity(0.5) + .HAlign(HorizontalAlignment.Center) + ) + .MaxWidth(460) + .VAlign(VerticalAlignment.Center) + .Padding(0, 16, 0, 0); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ConnectionPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ConnectionPage.cs new file mode 100644 index 00000000..4d7caf1e --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ConnectionPage.cs @@ -0,0 +1,828 @@ +using System.Text; +using System.Text.Json; +using OpenClaw.Shared; +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using OpenClawTray.Helpers; +using OpenClawTray.Onboarding.Services; +using OpenClawTray.Services; +using static OpenClawTray.FunctionalUI.Factories; +using Microsoft.UI.Xaml; +using Windows.Storage.Pickers; + +namespace OpenClawTray.Onboarding.Pages; + +/// +/// Page 1: Connection / Gateway Selection. +/// Lets users choose Local, Remote, or Configure Later, +/// enter gateway URL + token (or paste setup code), +/// toggle Node Mode, and performs a REAL WebSocket handshake +/// with Ed25519 device authentication. +/// +public sealed class ConnectionPage : Component +{ + private const string DefaultLocalUrl = ConnectionPageModeSelector.DefaultLocalUrl; + private const string DevLocalUrl = ConnectionPageModeSelector.DevLocalUrl; + private const string VisualTestPairingDeviceId = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + // Cache the detected URL so we only probe once per app session + private static string? s_detectedLocalUrl; + + /// + /// Returns the detected local gateway URL. Does a fast synchronous TCP probe + /// on common ports. Safe to call from Render() — no async/await. + /// + private static string GetDetectedLocalUrl() + { + if (s_detectedLocalUrl != null) return s_detectedLocalUrl; + + // Quick TCP port probe — much faster than HTTP health check + foreach (var candidate in new[] { DefaultLocalUrl, DevLocalUrl }) + { + try + { + var uri = new Uri(candidate); + using var tcp = new System.Net.Sockets.TcpClient(); + var result = tcp.BeginConnect(uri.Host, uri.Port, null, null); + var connected = result.AsyncWaitHandle.WaitOne(TimeSpan.FromMilliseconds(500)); + if (connected) + { + try { tcp.EndConnect(result); } catch { /* connection refused */ } + if (tcp.Connected) + { + Logger.Info($"[Connection] Detected local gateway at {candidate}"); + s_detectedLocalUrl = candidate; + return candidate; + } + } + } + catch + { + // Port not reachable + } + } + + s_detectedLocalUrl = DefaultLocalUrl; + return DefaultLocalUrl; + } + + /// + /// Probes common local gateway ports and returns the first reachable URL. + /// Checks the default port (18789) first, then the dev port (19001). + /// Uses a very short timeout for responsiveness. + /// + private static async Task DetectLocalGatewayUrlAsync() + { + foreach (var candidate in new[] { DefaultLocalUrl, DevLocalUrl }) + { + try + { + var uri = new Uri(candidate.Replace("ws://", "http://")); + using var client = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromMilliseconds(800) }; + var response = await client.GetAsync($"{uri.GetLeftPart(UriPartial.Authority)}/health"); + if (response.IsSuccessStatusCode) + { + Logger.Info($"[Connection] Detected local gateway at {candidate}"); + return candidate; + } + } + catch + { + // Port not reachable, try next + } + } + return DefaultLocalUrl; // Fallback to default + } + + private static string GetVisualTestPairingDeviceId() => + Environment.GetEnvironmentVariable("OPENCLAW_VISUAL_TEST_PAIRING") == "1" + ? VisualTestPairingDeviceId + : ""; + + public override Element Render() + { + var visualPairingDeviceId = GetVisualTestPairingDeviceId(); + var (mode, setMode) = UseState(Props.Mode); + // For Local mode, use the detected gateway URL (probes 18789 and 19001) + var initialUrl = ConnectionPageModeSelector.GetInitialUrl( + Props.Mode, + Props.Settings.GatewayUrl, + Props.Settings.SshTunnelLocalPort, + GetDetectedLocalUrl); + var (url, setUrl) = UseState(initialUrl); + var (token, setToken) = UseState(Props.Settings.Token); + var (nodeMode, setNodeMode) = UseState(Props.Settings.EnableNodeMode); + var (setupCode, setSetupCode) = UseState(""); + + // SSH tunnel state — bound to Props.Settings.SshTunnel* + var (useSshTunnel, setUseSshTunnel) = UseState(Props.Mode == ConnectionMode.Ssh || Props.Settings.UseSshTunnel); + var (sshUser, setSshUser) = UseState(Props.Settings.SshTunnelUser ?? ""); + var (sshHost, setSshHost) = UseState(Props.Settings.SshTunnelHost ?? ""); + var (sshRemotePort, setSshRemotePort) = UseState(Props.Settings.SshTunnelRemotePort > 0 ? Props.Settings.SshTunnelRemotePort : 18789); + var (sshLocalPort, setSshLocalPort) = UseState(Props.Settings.SshTunnelLocalPort > 0 ? Props.Settings.SshTunnelLocalPort : 18789); + + var detectedUrl = GetDetectedLocalUrl(); + var detectedMsg = detectedUrl != DefaultLocalUrl + ? $"✅ {LocalizationHelper.GetString("Onboarding_Connection_StatusDetected")}" + : LocalizationHelper.GetString("Onboarding_Connection_Ready"); + var (statusMsg, setStatusMsg) = UseState(Props.Mode == ConnectionMode.Local ? detectedMsg : LocalizationHelper.GetString("Onboarding_Connection_Ready")); + var (testing, setTesting) = UseState(false); + var (pairingDeviceId, setPairingDeviceId) = UseState(visualPairingDeviceId); + var (pairingCommand, setPairingCommand) = UseState(string.IsNullOrEmpty(visualPairingDeviceId) ? "" : App.BuildPairingApprovalCommand(visualPairingDeviceId)); + var (copied, setCopied) = UseState(!string.IsNullOrEmpty(visualPairingDeviceId)); + var (copyFailed, setCopyFailed) = UseState(false); + + var urlReadOnly = ConnectionPageModeSelector.IsGatewayUrlReadOnly(mode); // Ssh mode pins the local-forward URL + + void SelectMode(ConnectionMode m) + { + var result = ConnectionPageModeSelector.SelectMode( + m, + url, + GetDetectedLocalUrl(), + sshLocalPort, + $"✅ {LocalizationHelper.GetString("Onboarding_Connection_StatusDetected")}", + LocalizationHelper.GetString("Onboarding_Connection_LaterStatus")); + + setMode(m); + Props.Mode = m; + Props.ConnectionTested = result.ConnectionTested; + setStatusMsg(result.StatusMessage); + setPairingDeviceId(result.PairingDeviceId); + setPairingCommand(""); + setCopied(false); + setCopyFailed(false); + setUseSshTunnel(result.UseSshTunnel); + Props.Settings.UseSshTunnel = result.UseSshTunnel; + + if (result.UpdateGatewayUrl) + { + setUrl(result.Url); + Props.Settings.GatewayUrl = result.Url; + } + } + + void OnSetupCodeChanged(string code) + { + setSetupCode(code); + if (string.IsNullOrWhiteSpace(code)) return; + + var result = SetupCodeDecoder.Decode(code); + + if (!result.Success) + { + // Not a valid setup code — user might be still typing + if (code.Length > 2048) + Logger.Warn("[Connection] Setup code rejected: exceeds 2048 character limit"); + else + Logger.Debug($"[Connection] Setup code parse attempt failed: {result.Error}"); + return; + } + + if (result.Url != null) + { + setUrl(result.Url); + Props.Settings.GatewayUrl = result.Url; + } + if (result.Token != null) + { + setToken(result.Token); + Props.Settings.Token = result.Token; + Props.Settings.BootstrapToken = result.Token; + } + setStatusMsg($"✅ {LocalizationHelper.GetString("Onboarding_Connection_StatusDecoded")}"); + } + + void OnUrlChanged(string v) + { + setUrl(v); + Props.Settings.GatewayUrl = v; + Props.ConnectionTested = false; + setStatusMsg(""); + } + + void OnTokenChanged(string v) + { + setToken(v); + Props.Settings.Token = v; + Props.Settings.BootstrapToken = ""; + Props.ConnectionTested = false; + setStatusMsg(""); + } + + void OnNodeModeToggled(bool v) + { + setNodeMode(v); + Props.Settings.EnableNodeMode = v; + } + + bool TryCopyPairingCommand(string command) + { + try + { + App.CopyTextToClipboard(command); + return true; + } + catch (Exception ex) + { + Logger.Warn($"[Connection] Failed to copy pairing command: {ex.Message}"); + return false; + } + } + + async void TestConnection() + { + Props.Settings.GatewayUrl = url; + Props.Settings.Token = token; + + // When SSH mode, start the managed tunnel before health-checking the local URL. + if (mode == ConnectionMode.Ssh) + { + if (string.IsNullOrWhiteSpace(sshUser)) + { + setStatusMsg($"⚠️ {LocalizationHelper.GetString("Onboarding_Connection_SshUserInvalid")}"); + return; + } + if (string.IsNullOrWhiteSpace(sshHost)) + { + setStatusMsg($"⚠️ {LocalizationHelper.GetString("Onboarding_Connection_SshHostInvalid")}"); + return; + } + Props.Settings.UseSshTunnel = true; + Props.Settings.SshTunnelUser = sshUser; + Props.Settings.SshTunnelHost = sshHost; + Props.Settings.SshTunnelRemotePort = sshRemotePort; + Props.Settings.SshTunnelLocalPort = sshLocalPort; + Props.Settings.Save(); + try + { + ((App)Microsoft.UI.Xaml.Application.Current).EnsureSshTunnelStarted(); + // Give the tunnel a brief moment to bind the local port before the health probe. + await Task.Delay(800); + } + catch (Exception ex) + { + Logger.Warn($"[Connection] SSH tunnel start failed: {ex.Message}"); + setStatusMsg($"❌ {ex.Message}"); + return; + } + } + else + { + Props.Settings.UseSshTunnel = false; + } + + if (!GatewayUrlHelper.IsValidGatewayUrl(url)) + { + setStatusMsg($"⚠️ {GatewayUrlHelper.ValidationMessage}"); + return; + } + + if (string.IsNullOrWhiteSpace(token)) + { + setStatusMsg($"⚠️ {LocalizationHelper.GetString("Onboarding_Connection_StatusTokenRequired")}"); + return; + } + + setTesting(true); + setStatusMsg(LocalizationHelper.GetString("Onboarding_Connection_StatusConnecting")); + setPairingDeviceId(""); + setPairingCommand(""); + setCopied(false); + setCopyFailed(false); + Props.ConnectionTested = false; + + try + { + // Phase 1: Quick HTTP health check — instant reachability feedback + var healthResult = await GatewayHealthCheck.TestAsync(url, token); + if (!healthResult.Success) + { + Logger.Warn($"[Connection] Health check failed: {healthResult.Error}"); + setStatusMsg($"❌ {healthResult.Error}"); + setTesting(false); + return; + } + + // Phase 2: Use App's PERSISTENT client (matching Mac app architecture) + setStatusMsg($"🔄 {LocalizationHelper.GetString("Onboarding_Connection_StatusAuthenticating")}"); + Props.Settings.Save(); + + var app = (App)Microsoft.UI.Xaml.Application.Current; + + // Reuse existing client if it already has a result, otherwise (re)initialize + var existingClient = app.GatewayClient; + if (existingClient == null || + (!existingClient.IsConnectedToGateway && !existingClient.IsPairingRequired && !existingClient.IsAuthFailed)) + { + var useBootstrapHandoffAuth = + !string.IsNullOrWhiteSpace(Props.Settings.BootstrapToken) && + string.Equals(token, Props.Settings.BootstrapToken, StringComparison.Ordinal); + app.ReinitializeGatewayClient(useBootstrapHandoffAuth); + } + + // Set Props.GatewayClient IMMEDIATELY so WizardPage can access it + // even if still connecting (WizardPage will poll for Connected status) + Props.GatewayClient = app.GatewayClient; + + // Poll for definitive auth result (V3→V2 fallback takes ~8s) + bool connected = false; + bool pairingRequired = false; + bool authFailed = false; + + for (int attempt = 0; attempt < 30; attempt++) + { + await Task.Delay(1000); + var client = app.GatewayClient; + Props.GatewayClient = client; // Keep in sync + + if (client == null) continue; + if (client.IsConnectedToGateway) { connected = true; break; } + if (client.IsPairingRequired) { pairingRequired = true; break; } + if (client.IsAuthFailed) { authFailed = true; break; } + } + + if (connected) + { + setStatusMsg($"✅ {LocalizationHelper.GetString("Onboarding_Connection_StatusConnected")}"); + Props.ConnectionTested = true; + } + else if (pairingRequired) + { + var dataPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenClawTray"); + var identity = new DeviceIdentity(dataPath); + identity.Initialize(); + var deviceId = identity.DeviceId; + + setStatusMsg($"⏳ {LocalizationHelper.GetString("Onboarding_Connection_StatusPairing")}"); + setPairingDeviceId(deviceId); + var cmd = App.BuildPairingApprovalCommand(deviceId); + setPairingCommand(cmd); + var commandCopied = TryCopyPairingCommand(cmd); + setCopied(commandCopied); + setCopyFailed(!commandCopied); + app.ShowPairingPendingNotification(deviceId, cmd); + } + else if (authFailed) + { + Logger.Warn("[Connection] Auth failed (all signature modes exhausted)"); + setStatusMsg($"❌ {LocalizationHelper.GetString("Onboarding_Connection_StatusFailed")}"); + } + else + { + setStatusMsg($"❌ {LocalizationHelper.GetString("Onboarding_Connection_StatusTimeout")}"); + } + } + catch (Exception ex) + { + Logger.Error($"[Connection] Test exception: {ex}"); + setStatusMsg($"❌ {LocalizationHelper.GetString("Onboarding_Connection_StatusFailed")}"); + } + finally + { + setTesting(false); + } + } + + var showFields = ConnectionPageModeSelector.ShouldShowConnectionFields(mode); + + // Build the full status text for the always-visible status area + var fullStatus = statusMsg; + if (!string.IsNullOrEmpty(pairingDeviceId)) + { + var shortId = pairingDeviceId.Length > 16 ? pairingDeviceId[..16] + "…" : pairingDeviceId; + fullStatus += $"\nDevice ID: {shortId}"; + fullStatus += $"\n\n{LocalizationHelper.GetString("Onboarding_Connection_RunOnGateway")}\n" + pairingCommand; + fullStatus += copied + ? $"\n{LocalizationHelper.GetString("Onboarding_Connection_Copied")}" + : copyFailed + ? $"\n{LocalizationHelper.GetString("Onboarding_Connection_CopyFailed")}" + : ""; + } + + var children = new List + { + // Title + TextBlock(LocalizationHelper.GetString("Onboarding_Connection_Title")) + .FontSize(22) + .FontWeight(new global::Windows.UI.Text.FontWeight(700)) + .HAlign(HorizontalAlignment.Left) + }; + + static string ModeAutomationId(ConnectionMode option) => option switch + { + ConnectionMode.Local => "OnboardingConnectionModeLocal", + ConnectionMode.Ssh => "OnboardingConnectionModeSsh", + ConnectionMode.Wsl => "OnboardingConnectionModeWsl", + ConnectionMode.Later => "OnboardingConnectionModeLater", + ConnectionMode.Remote => "OnboardingConnectionModeRemote", + _ => "OnboardingConnectionModeUnknown" + }; + + Element ModeOption(ConnectionMode option, string label) => + RadioButton(label, mode == option, isChecked => + { + if (isChecked) + SelectMode(option); + }, + groupName: "connection-mode") + .Set(rb => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(rb, ModeAutomationId(option))); + + // Build card content — mode selector always first, fields conditionally below + var cardChildren = new List + { + Grid(["1*", "1*"], ["Auto", "Auto", "Auto"], + ModeOption(ConnectionMode.Local, $"🖥️ {LocalizationHelper.GetString("Onboarding_Connection_Local")}").Grid(0, 0), + ModeOption(ConnectionMode.Ssh, $"🔐 {LocalizationHelper.GetString("Onboarding_Connection_Ssh")}").Grid(0, 1), + ModeOption(ConnectionMode.Wsl, $"🐧 {LocalizationHelper.GetString("Onboarding_Connection_Wsl")}").Grid(1, 0), + ModeOption(ConnectionMode.Later, $"⏭️ {LocalizationHelper.GetString("Onboarding_Connection_Later")}").Grid(1, 1), + ModeOption(ConnectionMode.Remote, $"🌐 {LocalizationHelper.GetString("Onboarding_Connection_Remote")}").Grid(2, 0)) + }; + + if (showFields) + { + // QR import handler — uses Helpers.QrSetupCodeReader on a stream from FileOpenPicker + async void ImportQrFromFile() + { + try + { + var picker = new FileOpenPicker(); + var hwnd = ((App)Microsoft.UI.Xaml.Application.Current).GetOnboardingWindowHandle(); + if (hwnd != IntPtr.Zero) + WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd); + picker.FileTypeFilter.Add(".png"); + picker.FileTypeFilter.Add(".jpg"); + picker.FileTypeFilter.Add(".jpeg"); + picker.FileTypeFilter.Add(".bmp"); + picker.FileTypeFilter.Add(".gif"); + + var file = await picker.PickSingleFileAsync(); + if (file == null) return; + + using var ras = await file.OpenReadAsync(); + using var stream = ras.AsStreamForRead(); + var decoded = QrSetupCodeReader.Decode(stream); + if (string.IsNullOrWhiteSpace(decoded)) + { + setStatusMsg($"⚠️ {LocalizationHelper.GetString("Onboarding_Connection_QrDecodeFailed")}"); + return; + } + setSetupCode(decoded); + OnSetupCodeChanged(decoded); + } + catch (Exception ex) + { + Logger.Warn($"[Connection] QR import failed: {ex.Message}"); + setStatusMsg($"⚠️ {LocalizationHelper.GetString("Onboarding_Connection_QrDecodeFailed")}"); + } + } + + void PasteSetupCode() + { + try + { + var content = global::Windows.ApplicationModel.DataTransfer.Clipboard.GetContent(); + if (!content.Contains(global::Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text)) + return; + var task = content.GetTextAsync(); + task.Completed = (op, status) => + { + if (status != global::Windows.Foundation.AsyncStatus.Completed) return; + var text = op.GetResults(); + Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread()?.TryEnqueue(() => + { + setSetupCode(text); + OnSetupCodeChanged(text); + }); + }; + } + catch { /* clipboard unavailable — ignore */ } + } + + // Setup code row: TextField + Paste + QR buttons (Grid keeps the field expanding) + cardChildren.Add( + Grid(["1*", "Auto", "Auto"], ["Auto"], + TextField(setupCode, OnSetupCodeChanged, + placeholder: LocalizationHelper.GetString("Onboarding_Connection_SetupCodePlaceholder"), + header: LocalizationHelper.GetString("Onboarding_Connection_SetupCode")) + .OnGotFocus((sender, _) => + { + if (sender is Microsoft.UI.Xaml.Controls.TextBox tb && string.IsNullOrEmpty(tb.Text)) + { + try + { + var content = global::Windows.ApplicationModel.DataTransfer.Clipboard.GetContent(); + if (content.Contains(global::Windows.ApplicationModel.DataTransfer.StandardDataFormats.Text)) + { + var task = content.GetTextAsync(); + task.Completed = (op, status) => + { + if (status == global::Windows.Foundation.AsyncStatus.Completed) + { + var text = op.GetResults(); + tb.DispatcherQueue.TryEnqueue(() => + { + tb.Text = text; + OnSetupCodeChanged(text); + }); + } + }; + } + } + catch { } + } + }) + .Grid(row: 0, column: 0), + Button(LocalizationHelper.GetString("Onboarding_Connection_PasteSetup"), PasteSetupCode) + .VAlign(VerticalAlignment.Bottom) + .Margin(6, 0, 0, 0) + .Grid(row: 0, column: 1) + .Set(b => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingPasteSetupCode")), + Button(LocalizationHelper.GetString("Onboarding_Connection_QrButton"), ImportQrFromFile) + .VAlign(VerticalAlignment.Bottom) + .Margin(6, 0, 0, 0) + .Grid(row: 0, column: 2) + .Set(b => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingImportQr")) + ) + ); + + // Gateway URL — read-only when SSH (the local-forward URL is fixed) + cardChildren.Add( + TextField(url, OnUrlChanged, + placeholder: "ws://host:port", + header: LocalizationHelper.GetString("Onboarding_Connection_GatewayUrl")) + .ReadOnly(urlReadOnly) + .OnGotFocus((sender, _) => + { + if (sender is Microsoft.UI.Xaml.Controls.TextBox tb) + tb.SelectAll(); + }) + .Set(tb => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(tb, "OnboardingGatewayUrl")) + ); + + // Token + cardChildren.Add( + TextField(token, OnTokenChanged, + placeholder: LocalizationHelper.GetString("Onboarding_Connection_TokenPlaceholder"), + header: LocalizationHelper.GetString("Onboarding_Connection_Token")) + .Set(tb => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(tb, "OnboardingToken")) + ); + + // ── SSH panel (only shown when mode == Ssh) ───────────────────── + if (mode == ConnectionMode.Ssh) + { + void OnSshUserChanged(string v) + { + setSshUser(v); + Props.Settings.SshTunnelUser = v; + } + void OnSshHostChanged(string v) + { + setSshHost(v); + Props.Settings.SshTunnelHost = v; + } + void OnSshRemotePortChanged(string v) + { + if (int.TryParse(v, out var p) && p > 0 && p <= 65535) + { + setSshRemotePort(p); + Props.Settings.SshTunnelRemotePort = p; + } + } + void OnSshLocalPortChanged(string v) + { + if (int.TryParse(v, out var p) && p > 0 && p <= 65535) + { + setSshLocalPort(p); + Props.Settings.SshTunnelLocalPort = p; + var sshUrl = $"ws://127.0.0.1:{p}"; + setUrl(sshUrl); + Props.Settings.GatewayUrl = sshUrl; + } + } + + // Live `ssh ...` preview — defensively wrapped so an in-progress invalid host + // doesn't break the entire render pass. + string sshPreview; + try + { + var args = SshTunnelCommandLine.BuildArguments( + sshUser, sshHost, sshRemotePort, sshLocalPort, + includeBrowserProxyForward: true); + sshPreview = $"ssh {args}"; + } + catch + { + sshPreview = "ssh -N -L :127.0.0.1: user@host"; + } + + cardChildren.Add( + Border( + VStack(8, + TextBlock(LocalizationHelper.GetString("Onboarding_Connection_SshHint")) + .FontSize(12) + .Opacity(0.7) + .TextWrapping(), + Grid(["1*", "1*"], ["Auto", "Auto"], + TextField(sshUser, OnSshUserChanged, + placeholder: "user", + header: LocalizationHelper.GetString("Onboarding_Connection_SshUser")) + .Grid(row: 0, column: 0) + .Set(tb => { tb.Margin = new Thickness(0, 0, 4, 0); Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(tb, "OnboardingSshUser"); }), + TextField(sshHost, OnSshHostChanged, + placeholder: "mac-studio.local", + header: LocalizationHelper.GetString("Onboarding_Connection_SshHost")) + .Grid(row: 0, column: 1) + .Set(tb => { tb.Margin = new Thickness(4, 0, 0, 0); Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(tb, "OnboardingSshHost"); }), + TextField(sshRemotePort.ToString(), OnSshRemotePortChanged, + placeholder: "18789", + header: LocalizationHelper.GetString("Onboarding_Connection_SshRemotePort")) + .Grid(row: 1, column: 0) + .Set(tb => { tb.Margin = new Thickness(0, 8, 4, 0); Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(tb, "OnboardingSshRemotePort"); }), + TextField(sshLocalPort.ToString(), OnSshLocalPortChanged, + placeholder: "18789", + header: LocalizationHelper.GetString("Onboarding_Connection_SshLocalPort")) + .Grid(row: 1, column: 1) + .Set(tb => { tb.Margin = new Thickness(4, 8, 0, 0); Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(tb, "OnboardingSshLocalPort"); }) + ), + VStack(2, + TextBlock(LocalizationHelper.GetString("Onboarding_Connection_SshPreviewLabel")) + .FontSize(11) + .Opacity(0.6), + TextBlock(sshPreview) + .FontSize(11) + .FontFamily("Consolas") + .TextWrapping() + ) + ).Padding(12) + ) + .CornerRadius(6) + .Background("#FFFFFF") + ); + } + + // Topology detection line — defensive against transient invalid SSH host/ports + string topologyText; + try + { + var info = GatewayTopologyClassifier.Classify( + url, + useSshTunnel: mode == ConnectionMode.Ssh, + sshHost: sshHost, + sshLocalPort: sshLocalPort, + sshRemotePort: sshRemotePort); + var summary = string.IsNullOrEmpty(info.Detail) + ? $"{info.DisplayName} · {info.Transport} · {info.Host}" + : $"{info.DisplayName} · {info.Transport} · {info.Detail}"; + topologyText = string.Format( + LocalizationHelper.GetString("Onboarding_Connection_TopologyDetectedFmt"), + summary); + } + catch + { + topologyText = string.Empty; + } + if (!string.IsNullOrEmpty(topologyText)) + { + cardChildren.Add( + TextBlock("● " + topologyText) + .FontSize(12) + .Opacity(0.75) + .TextWrapping() + ); + } + + // Node Mode left + Test Connection right (same row via Grid) + cardChildren.Add( + Grid(["1*", "Auto"], ["Auto"], + HStack(4, + TextBlock(LocalizationHelper.GetString("Onboarding_Connection_NodeMode")) + .FontSize(14) + .VAlign(VerticalAlignment.Center), + TextBlock("\uE946") + .FontFamily("Segoe MDL2 Assets") + .FontSize(14) + .Opacity(0.5) + .VAlign(VerticalAlignment.Center) + .Set(tb => Microsoft.UI.Xaml.Controls.ToolTipService.SetToolTip(tb, + "Node Mode turns this PC into a remote compute node.\n" + + "The gateway can invoke screen capture, camera, system\n" + + "commands, and other capabilities on this machine.\n\n" + + "⚠️ This is a heavy hammer — it grants the gateway\n" + + "significant control over your PC. Only enable this if\n" + + "you trust the gateway operator and understand that\n" + + "remote commands will execute locally.\n\n" + + "Most users should leave this OFF (Operator mode)\n" + + "which only monitors and sends chat.")), + ToggleSwitch(nodeMode, OnNodeModeToggled) + .Set(ts => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(ts, "OnboardingNodeMode")) + ).Grid(row: 0, column: 0), + Button(LocalizationHelper.GetString("Onboarding_Connection_TestConnection"), TestConnection) + .Disabled(testing) + .VAlign(VerticalAlignment.Center) + .Grid(row: 0, column: 1) + .Set(b => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingTestConnection")) + ) + ); + + // Status display — always visible + cardChildren.Add( + Border( + TextBlock(string.IsNullOrEmpty(fullStatus) ? LocalizationHelper.GetString("Onboarding_Connection_Ready") : fullStatus) + .FontSize(12) + .TextWrapping() + .Padding(8) + ) + .MinHeight(40) + .CornerRadius(4) + .Background("#FFFFFF") + ); + } + else + { + cardChildren.Add( + TextBlock(LocalizationHelper.GetString("Onboarding_Connection_ConfigureLaterMsg")) + .FontSize(13) + .Opacity(0.6) + .TextWrapping() + .Margin(0, 8, 0, 0) + ); + } + + // Card wrapper — always shown, contains RadioButtons + config fields + children.Add( + Border( + VStack(8, cardChildren.ToArray()).Padding(12) + ) + .CornerRadius(8) + .Background("#FFFFFF") + .Margin(0, 4, 0, 0) + ); + + if (showFields) + { + if (!string.IsNullOrEmpty(pairingDeviceId)) + { + children.Add( + Button( + VStack(2, + TextBlock(pairingCommand) + .FontSize(11) + .FontFamily("Consolas") + .TextWrapping(), + TextBlock(copied + ? $"✅ {LocalizationHelper.GetString("Onboarding_Connection_Copied")}" + : copyFailed + ? $"⚠️ {LocalizationHelper.GetString("Onboarding_Connection_CopyFailed")}" + : LocalizationHelper.GetString("Onboarding_Connection_ClickToCopy")) + .FontSize(11) + .FontWeight(new global::Windows.UI.Text.FontWeight(700)) + .Opacity(copied ? 1.0 : 0.7) + ), + () => + { + var commandCopied = TryCopyPairingCommand(pairingCommand); + setCopied(commandCopied); + setCopyFailed(!commandCopied); + }) + .HAlign(HorizontalAlignment.Stretch) + .Set(b => Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(b, "OnboardingCopyPairingCommand")) + ); + } + } + + return ScrollView( + VStack(8, children.ToArray()) + .MaxWidth(460) + .Padding(0, 12, 0, 12) + ); + } + + /// + /// Lightweight logger that captures the first and last error/warning for UI display. + /// Preserves the first error so reconnect noise doesn't overwrite the real cause. + /// + private sealed class ConnectionTestLogger : IOpenClawLogger + { + /// The first error captured — preserves the original cause. + public string? FirstError { get; private set; } + public string? LastError { get; private set; } + public string? LastWarn { get; private set; } + + public void Info(string message) { } + public void Debug(string message) { } + public void Warn(string message) + { + LastWarn = message; + FirstError ??= message; + LastError ??= message; + } + public void Error(string message, Exception? ex = null) + { + FirstError ??= message; + LastError = message; + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/PermissionsPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/PermissionsPage.cs new file mode 100644 index 00000000..866bd229 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/PermissionsPage.cs @@ -0,0 +1,167 @@ +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using OpenClawTray.Helpers; +using OpenClawTray.Onboarding.Services; +using OpenClawTray.Services; +using static OpenClawTray.FunctionalUI.Factories; +using Microsoft.UI.Xaml; +using Windows.System; + +namespace OpenClawTray.Onboarding.Pages; + +/// +/// Page 5: Grant Permissions. +/// Shows real Windows permission status for 5 capabilities and lets users +/// open system settings to grant each one. Auto-refreshes when permissions change. +/// +public sealed class PermissionsPage : Component +{ + public override Element Render() + { + var (permissions, setPermissions) = UseState?>(null); + var (refreshKey, setRefreshKey) = UseState(0); + + // Check permissions on mount and whenever refreshKey changes + UseEffect(() => + { + async void LoadPermissions() + { + var results = await PermissionChecker.CheckAllAsync(); + setPermissions(results); + } + LoadPermissions(); + }, refreshKey); + + // Subscribe to camera/mic access changes for auto-refresh + UseEffect(() => + { + var unsubscribe = PermissionChecker.SubscribeToAccessChanges(() => + { + setRefreshKey(refreshKey + 1); + }); + return unsubscribe; + }); + + async void OpenSettings(string settingsUri) + { + if (string.IsNullOrEmpty(settingsUri)) return; + + // SECURITY: Only allow ms-settings: URIs to prevent launching arbitrary protocols + if (!Uri.TryCreate(settingsUri, UriKind.Absolute, out var uri) + || !uri.Scheme.Equals("ms-settings", StringComparison.OrdinalIgnoreCase)) + { + Logger.Warn($"[Permissions] Blocked non-settings URI: {settingsUri}"); + return; + } + + try + { + var launched = await Launcher.LaunchUriAsync(uri); + if (!launched) + { + // Fallback: refresh status anyway in case it changed + setRefreshKey(refreshKey + 1); + } + } + catch + { + setRefreshKey(refreshKey + 1); + } + } + + // Build permission rows from real data + var rows = new List(); + if (permissions != null) + { + foreach (var perm in permissions) + { + var p = perm; // capture for closure + rows.Add(PermissionRow(p, () => + { + OpenSettings(p.SettingsUri); + // Refresh after a brief delay to let user return from Settings + _ = Task.Delay(TimeSpan.FromSeconds(1)).ContinueWith(_ => + setRefreshKey(refreshKey + 1)); + })); + } + } + else + { + rows.Add(TextBlock(LocalizationHelper.GetString("Onboarding_Permissions_Checking")) + .FontSize(13) + .Opacity(0.6) + .HAlign(HorizontalAlignment.Center)); + } + + return VStack(16, + TextBlock(LocalizationHelper.GetString("Onboarding_Permissions_Title")) + .FontSize(22) + .FontWeight(new global::Windows.UI.Text.FontWeight(700)) + .HAlign(HorizontalAlignment.Center), + + TextBlock(LocalizationHelper.GetString("Onboarding_Permissions_Description")) + .FontSize(14) + .Opacity(0.6) + .HAlign(HorizontalAlignment.Center) + .TextWrapping(), + + Border( + VStack(4, + VStack(4, rows.ToArray()), + Button($"↻ {LocalizationHelper.GetString("Onboarding_Permissions_Refresh")}", () => setRefreshKey(refreshKey + 1)) + .HAlign(HorizontalAlignment.Center) + .Margin(0, 8, 0, 0) + ).Padding(12) + ) + .CornerRadius(8) + .Background("#FFFFFF") + .Margin(0, 8, 0, 0) + ) + .MaxWidth(460) + .Padding(0, 32, 0, 0); + } + + private static Element PermissionRow(PermissionChecker.PermissionResult perm, Action onOpenSettings) + { + var statusIcon = perm.Status switch + { + PermissionChecker.PermissionStatus.Granted => "✅", + PermissionChecker.PermissionStatus.Supported => "✅", + PermissionChecker.PermissionStatus.Denied => "❌", + PermissionChecker.PermissionStatus.NoDevice => "➖", + PermissionChecker.PermissionStatus.NotSupported => "➖", + _ => "⚪" + }; + + // Left: icon + name/status (fills available width) + var nameCol = HStack(8, + TextBlock(perm.Icon).FontSize(18).Width(28), + VStack(2, + TextBlock(perm.Name).FontSize(14).TextWrapping(), + TextBlock(perm.StatusLabel) + .FontSize(11) + .Opacity(0.6) + .TextWrapping() + ).MinWidth(120).MaxWidth(180) + ).VAlign(VerticalAlignment.Center).Grid(row: 0, column: 0); + + // Right: emoji + button grouped as one unit so emoji always aligns + // regardless of whether a button is present. + // MinWidth ensures consistent column width even without a button. + var rightSide = HStack(4, + TextBlock(statusIcon).FontSize(16) + .VAlign(VerticalAlignment.Center) + .Width(30) + .HAlign(HorizontalAlignment.Center), + !string.IsNullOrEmpty(perm.SettingsUri) + ? Button(LocalizationHelper.GetString("Onboarding_Permissions_OpenSettings"), onOpenSettings) + .VAlign(VerticalAlignment.Center) + : (Element)TextBlock("") + ).VAlign(VerticalAlignment.Center).MinWidth(150).Grid(row: 0, column: 1); + + return Grid(["1*", "Auto"], ["Auto"], + nameCol, + rightSide + ).Padding(6, 8, 6, 8); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ReadyPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ReadyPage.cs new file mode 100644 index 00000000..a5c88f43 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/ReadyPage.cs @@ -0,0 +1,163 @@ +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using OpenClawTray.Helpers; +using OpenClawTray.Onboarding.Services; +using static OpenClawTray.FunctionalUI.Factories; +using Microsoft.UI.Xaml; + +namespace OpenClawTray.Onboarding.Pages; + +/// +/// Page 9: Ready / "All set" summary. +/// Feature links, Settings links, Launch at Login checkbox. +/// Matches macOS readyPage(). +/// +public sealed class ReadyPage : Component +{ + public override Element Render() + { + var (launchAtLogin, setLaunchAtLogin) = UseState(false); + + return ScrollView( + VStack(12, + TextBlock("🎉").FontSize(40) + .HAlign(HorizontalAlignment.Center) + .Margin(0, 4, 0, 2), + + TextBlock(LocalizationHelper.GetString("Onboarding_Ready_Title")) + .FontSize(22) + .FontWeight(new global::Windows.UI.Text.FontWeight(700)) + .HAlign(HorizontalAlignment.Center), + + TextBlock(LocalizationHelper.GetString("Onboarding_Ready_Subtitle")) + .FontSize(14) + .Opacity(0.7) + .HAlign(HorizontalAlignment.Center) + .TextWrapping(), + + // Mode-specific info card + ModeInfoCard(), + + // Feature rows — different content for Node Mode vs Operator Mode + Border( + VStack(4, + Props.Settings.EnableNodeMode ? NodeModeFeatureRows() : OperatorModeFeatureRows() + ).Padding(12) + ) + .CornerRadius(8) + .Background("#FFFFFF"), + + // Launch at Login toggle + HStack(8, + ToggleSwitch(launchAtLogin, v => setLaunchAtLogin(v)), + TextBlock(LocalizationHelper.GetString("Onboarding_Ready_LaunchAtLogin")) + .FontSize(13) + .VAlign(VerticalAlignment.Center) + ) + ) + .HAlign(HorizontalAlignment.Center) + .MaxWidth(460) + .Padding(0, 8, 0, 0) + ).HorizontalScrollMode(Microsoft.UI.Xaml.Controls.ScrollMode.Disabled); + } + + private Element ModeInfoCard() + { + if (Props.Settings.EnableNodeMode) + { + return Border( + VStack(8, + TextBlock("🔌 Node Mode Active") + .FontSize(14) + .FontWeight(new global::Windows.UI.Text.FontWeight(600)), + TextBlock("This PC will operate as a remote compute node. " + + "The gateway can invoke screen capture, camera, and system " + + "commands on this machine.") + .FontSize(12) + .Opacity(0.8) + .TextWrapping() + ).Padding(12) + ) + .CornerRadius(8) + .Background("#FFF3E0") + .Margin(0, 8, 0, 0); + } + + var message = Props.Mode switch + { + ConnectionMode.Later => LocalizationHelper.GetString("Onboarding_Ready_ConfigureLater"), + ConnectionMode.Remote => LocalizationHelper.GetString("Onboarding_Ready_RemoteInfo"), + _ => null, + }; + + if (message is null || message.StartsWith("Onboarding_")) + { + message = Props.Mode switch + { + ConnectionMode.Later => "You can configure your gateway connection anytime from the tray menu → Setup Guide.", + ConnectionMode.Remote => "Make sure your remote gateway is running and accessible.", + _ => null, + }; + } + + if (message is null) return VStack(); // Empty for Local mode + + return Border( + TextBlock(message).FontSize(12).Opacity(0.8).TextWrapping().Padding(12) + ) + .CornerRadius(8) + .Background("#E8F4FD") + .Margin(0, 8, 0, 0); + } + + private static Element FeatureActionRow(string icon, string labelKey, string fallback, string subtitleKey, string subtitleFallback) + { + var label = LocalizationHelper.GetString(labelKey); + if (label == labelKey) label = fallback; + + var subtitle = LocalizationHelper.GetString(subtitleKey); + if (subtitle == subtitleKey) subtitle = subtitleFallback; + + return HStack(12, + TextBlock(icon).FontSize(18).Width(24), + VStack(2, + TextBlock(label).FontSize(13).FontWeight(new global::Windows.UI.Text.FontWeight(500)), + TextBlock(subtitle).FontSize(11).Opacity(0.6) + ), + TextBlock("›") + .FontSize(16) + .Opacity(0.3) + .VAlign(VerticalAlignment.Center) + .HAlign(HorizontalAlignment.Right) + .Margin(0, 0, 4, 0) + ).Padding(6, 8, 6, 8); + } + + private static Element[] NodeModeFeatureRows() => + [ + FeatureActionRow("🖥️", "Onboarding_Ready_Node_ScreenCapture", "Screen Capture", + "Onboarding_Ready_Node_ScreenCapture_Sub", "Remote screen access"), + FeatureActionRow("📷", "Onboarding_Ready_Node_Camera", "Camera", + "Onboarding_Ready_Node_Camera_Sub", "Remote camera access"), + FeatureActionRow("⚙️", "Onboarding_Ready_Node_SystemCmd", "System Commands", + "Onboarding_Ready_Node_SystemCmd_Sub", "Remote command execution"), + FeatureActionRow("🎨", "Onboarding_Ready_Node_Canvas", "Canvas Rendering", + "Onboarding_Ready_Node_Canvas_Sub", "Visual workspace output"), + FeatureActionRow("🔔", "Onboarding_Ready_Node_Notify", "Notifications", + "Onboarding_Ready_Node_Notify_Sub", "System notifications"), + ]; + + private static Element[] OperatorModeFeatureRows() => + [ + FeatureActionRow("📋", "Onboarding_Ready_Feature_TrayMenu", "Open menu bar panel", + "Onboarding_Ready_Feature_TrayMenu_Subtitle", "Access from system tray"), + FeatureActionRow("💬", "Onboarding_Ready_Feature_Channels", "Connect WhatsApp, Telegram", + "Onboarding_Ready_Feature_Channels_Subtitle", "Settings → Channels"), + FeatureActionRow("🎤", "Onboarding_Ready_Feature_Voice", "Try Voice Wake", + "Onboarding_Ready_Feature_Voice_Subtitle", "Wake with your voice"), + FeatureActionRow("🎨", "Onboarding_Ready_Feature_Canvas", "Use Canvas", + "Onboarding_Ready_Feature_Canvas_Subtitle", "Visual workspace"), + FeatureActionRow("⚡", "Onboarding_Ready_Feature_Skills", "Enable Skills", + "Onboarding_Ready_Feature_Skills_Subtitle", "Settings → Skills"), + ]; +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WelcomePage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WelcomePage.cs new file mode 100644 index 00000000..ade7b508 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WelcomePage.cs @@ -0,0 +1,78 @@ +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using OpenClawTray.Helpers; +using static OpenClawTray.FunctionalUI.Factories; +using Microsoft.UI.Xaml; + +namespace OpenClawTray.Onboarding.Pages; + +/// +/// Page 0: Welcome & Security Notice. +/// Matches macOS welcomePage() — title, subtitle, security warning card. +/// +public sealed class WelcomePage : Component +{ + public override Element Render() + { + return VStack(10, + TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_Title")) + .FontSize(22) + .FontWeight(new global::Windows.UI.Text.FontWeight(700)) + .HAlign(HorizontalAlignment.Center) + .TextWrapping(), + + TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_Subtitle")) + .FontSize(14) + .Opacity(0.6) + .HAlign(HorizontalAlignment.Center) + .TextWrapping(), + + TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_GetConnected")) + .FontSize(13) + .Opacity(0.5) + .HAlign(HorizontalAlignment.Center) + .TextWrapping() + .Margin(0, 4, 0, 0), + + // Combined security notice + trust card + Border( + VStack(8, + HStack(6, + TextBlock("⚠️").FontSize(14), + TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_SecurityTitle")) + .FontSize(13) + .FontWeight(new global::Windows.UI.Text.FontWeight(600)) + ), + TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_SecurityBody")) + .FontSize(12) + .Opacity(0.85) + .TextWrapping(), + TextBlock(LocalizationHelper.GetString("Onboarding_Welcome_TrustTitle")) + .FontSize(13) + .FontWeight(new global::Windows.UI.Text.FontWeight(600)) + .Margin(0, 4, 0, 0), + BulletItem("Onboarding_Welcome_Trust_Commands", "Run commands on your computer"), + BulletItem("Onboarding_Welcome_Trust_Files", "Read and write files"), + BulletItem("Onboarding_Welcome_Trust_Screen", "Capture screenshots") + ).Padding(14) + ) + .CornerRadius(8) + .Background("#FFF4E0") + .Margin(0, 12, 0, 0) + ) + .HAlign(HorizontalAlignment.Center) + .VAlign(VerticalAlignment.Center) + .MaxWidth(460) + .Padding(0, 8, 0, 0); + } + + private static Element BulletItem(string key, string fallback) + { + var text = LocalizationHelper.GetString(key); + if (text == key) text = fallback; + return HStack(6, + TextBlock("•").FontSize(12).Opacity(0.6), + TextBlock(text).FontSize(12).Opacity(0.7) + ); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WizardPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WizardPage.cs new file mode 100644 index 00000000..d532ed67 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WizardPage.cs @@ -0,0 +1,642 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using OpenClaw.Shared; +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using OpenClawTray.Helpers; +using OpenClawTray.Onboarding.Services; +using OpenClawTray.Services; +using static OpenClawTray.FunctionalUI.Factories; +using Microsoft.UI.Xaml; + +namespace OpenClawTray.Onboarding.Pages; + +/// +/// Page 3: RPC-Driven Wizard — server-defined setup steps. +/// Reads/writes wizard state from OnboardingState so it persists across page navigations. +/// Falls back to an offline skip message when gateway is unreachable. +/// +public sealed class WizardPage : Component +{ + public override Element Render() + { + // Read persisted wizard state from shared OnboardingState + var (wizardState, setWizardState) = UseState(Props.WizardLifecycleState ?? "loading"); + var (stepTitle, setStepTitle) = UseState(""); + var (stepMessage, setStepMessage) = UseState(""); + var (stepType, setStepType) = UseState("note"); + var (optionLabels, setOptionLabels) = UseState(Array.Empty()); + var (optionValues, setOptionValues) = UseState(Array.Empty()); + var (optionHints, setOptionHints) = UseState(Array.Empty()); + var (stepId, setStepId) = UseState(""); + var (stepInput, setStepInput) = UseState(""); + var (stepNumber, setStepNumber) = UseState(0); + var (totalSteps, setTotalSteps) = UseState(0); + var (errorMsg, setErrorMsg) = UseState(Props.WizardError ?? ""); + var (placeholder, setPlaceholder) = UseState(""); + var (submitting, setSubmitting) = UseState(false); + + void SaveState(string state, string? error = null) + { + Props.WizardLifecycleState = state; + Props.WizardError = error; + } + + void ApplyStep(JsonElement payload) + { + // Guard against default/undefined JsonElement + if (payload.ValueKind == JsonValueKind.Undefined || payload.ValueKind == JsonValueKind.Null) + { + setErrorMsg("Empty response from gateway"); + setWizardState("error"); + SaveState("error", "Empty response from gateway"); + return; + } + + try + { + // Extract sessionId from wizard.start response + if (payload.TryGetProperty("sessionId", out var sidProp)) + { + var sid = sidProp.GetString() ?? ""; + Props.WizardSessionId = sid; + } + + // Store payload for persistence + Props.WizardStepPayload = payload; + + // Check for completion + if (payload.TryGetProperty("done", out var doneProp) && doneProp.GetBoolean()) + { + setWizardState("complete"); + SaveState("complete"); + return; + } + + // Extract step fields — use ToString() instead of GetString() to handle non-string values + if (payload.TryGetProperty("step", out var step)) + { + var typeStr = step.TryGetProperty("type", out var tp) ? tp.ToString() : "note"; + var newTitle = step.TryGetProperty("title", out var t) ? t.ToString() : ""; + var newMessage = step.TryGetProperty("message", out var m) ? m.ToString() : ""; + // If no title, use the type as a fallback label + if (string.IsNullOrEmpty(newTitle) && !string.IsNullOrEmpty(newMessage)) + newTitle = typeStr switch { "confirm" => "Confirm", "select" => "Select", "text" => "Input", _ => "Setup" }; + setStepTitle(newTitle); + setStepMessage(newMessage); + setStepType(typeStr); + setStepId(step.TryGetProperty("id", out var id) ? id.ToString() : ""); + setPlaceholder(step.TryGetProperty("placeholder", out var ph) ? ph.ToString() : ""); + var iv = step.TryGetProperty("initialValue", out var ivp) ? ivp.ToString() : ""; + setStepInput(iv); + + // Parse options — may be plain strings OR objects {value, label, hint} + if (step.TryGetProperty("options", out var opts) && opts.ValueKind == JsonValueKind.Array) + { + var labels = new List(); + var values = new List(); + var hints = new List(); + foreach (var o in opts.EnumerateArray()) + { + if (o.ValueKind == JsonValueKind.Object) + { + var label = o.TryGetProperty("label", out var lp) ? lp.ToString() : ""; + var value = o.TryGetProperty("value", out var vp) ? vp.ToString() : label; + var hint = o.TryGetProperty("hint", out var hp) ? hp.ToString() : ""; + labels.Add(string.IsNullOrEmpty(hint) ? label : $"{label} — {hint}"); + values.Add(value); + hints.Add(hint); + } + else + { + var s = o.ToString(); + labels.Add(s); + values.Add(s); + hints.Add(""); + } + } + setOptionLabels(labels.ToArray()); + setOptionValues(values.ToArray()); + setOptionHints(hints.ToArray()); + } + else + { + setOptionLabels(Array.Empty()); + setOptionValues(Array.Empty()); + setOptionHints(Array.Empty()); + } + + // For select: default to first option's value if no initialValue + var typeStr2 = step.TryGetProperty("type", out var tp3) ? tp3.ToString() : ""; + if (string.IsNullOrEmpty(iv) && typeStr2 == "select") + { + // Re-read first option value directly + if (step.TryGetProperty("options", out var opts2) && opts2.ValueKind == JsonValueKind.Array) + { + foreach (var o in opts2.EnumerateArray()) + { + if (o.ValueKind == JsonValueKind.Object && o.TryGetProperty("value", out var fv)) + { + setStepInput(fv.ToString()); + break; + } + else + { + setStepInput(o.ToString()); + break; + } + } + } + } + } + + if (payload.TryGetProperty("stepIndex", out var si)) + setStepNumber(si.GetInt32()); + if (payload.TryGetProperty("totalSteps", out var ts)) + setTotalSteps(ts.GetInt32()); + + setWizardState("active"); + SaveState("active"); + } + catch (Exception ex) + { + setErrorMsg(ex.Message); + setWizardState("error"); + SaveState("error", ex.Message); + } + } + + // Start wizard on mount only (empty dependency array = run once) + UseEffect(() => + { + async void StartWizard() + { + // If wizard already has a session, restore from saved payload + if (!string.IsNullOrEmpty(Props.WizardSessionId) && Props.WizardStepPayload.HasValue) + { + ApplyStep(Props.WizardStepPayload.Value); + return; + } + + // If previously completed or in error, restore that state + if (Props.WizardLifecycleState is "complete" or "offline") + { + setWizardState(Props.WizardLifecycleState); + return; + } + + // Read client from App directly (persistent singleton, not Props) + var app = (App)Microsoft.UI.Xaml.Application.Current; + var client = app.GatewayClient ?? Props.GatewayClient; + + // Show loading UX and poll for client + connection (up to 30s) + setWizardState("loading"); + setErrorMsg(""); + + for (int wait = 0; wait < 30; wait++) + { + client = app.GatewayClient ?? Props.GatewayClient; + if (client?.IsConnectedToGateway == true) break; + await Task.Delay(1000); + } + + if (client == null || !client.IsConnectedToGateway) + { + setWizardState("offline"); + SaveState("offline"); + return; + } + + try + { + var response = await client.SendWizardRequestAsync("wizard.start"); + ApplyStep(response); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("already running", StringComparison.OrdinalIgnoreCase)) + { + // Wizard session exists — try to get current status instead of restarting + Logger.Info("[Wizard] Session already running, fetching current status..."); + try + { + var response = await client.SendWizardRequestAsync("wizard.status"); + ApplyStep(response); + } + catch + { + // wizard.status not available — skip wizard gracefully + Logger.Warn("[Wizard] Could not resume existing wizard session, skipping"); + setWizardState("offline"); + SaveState("offline"); + } + } + catch (TimeoutException) + { + setWizardState("offline"); + SaveState("offline"); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("unknown method", StringComparison.OrdinalIgnoreCase) + || ex.Message.Contains("not found", StringComparison.OrdinalIgnoreCase)) + { + setWizardState("offline"); + SaveState("offline"); + } + catch (Exception ex) + { + Logger.Error($"[Wizard] Start failed: {ex}"); + var genericMsg = "Failed to start wizard setup"; + setErrorMsg(genericMsg); + setWizardState("error"); + SaveState("error", genericMsg); + } + } + StartWizard(); + }, Array.Empty()); + + async void SubmitStep() + { + // Read client from App directly (same as StartWizard — Props.GatewayClient may be null) + var app = (App)Microsoft.UI.Xaml.Application.Current; + var client = app.GatewayClient ?? Props.GatewayClient; + if (client == null) return; + + if (!client.IsConnectedToGateway) + { + setErrorMsg("Lost connection to gateway. Click Next to skip the wizard, or wait for reconnection."); + setWizardState("error"); + SaveState("error", "Gateway disconnected"); + return; + } + + // If retrying from error state, restore active state + if (wizardState == "error") + { + setWizardState("active"); + setErrorMsg(""); + } + + setSubmitting(true); + try + { + // All step types need an answer to advance. + // For note/confirm: send "true". For text/select: send user input. + var answerValue = string.IsNullOrEmpty(stepInput) ? "true" : stepInput; + + // Smart timeout: 5min for auth-related steps (device code polling), 30s for everything else + var isAuthStep = !string.IsNullOrEmpty(stepMessage) && + (stepMessage.Contains("device", StringComparison.OrdinalIgnoreCase) || + stepMessage.Contains("authorize", StringComparison.OrdinalIgnoreCase) || + stepMessage.Contains("login", StringComparison.OrdinalIgnoreCase) || + stepMessage.Contains("sign in", StringComparison.OrdinalIgnoreCase) || + stepMessage.Contains("OAuth", StringComparison.OrdinalIgnoreCase)); + var timeoutMs = isAuthStep ? 300_000 : 30_000; + + var response = await client.SendWizardRequestAsync("wizard.next", new + { + sessionId = Props.WizardSessionId ?? "", + answer = new { stepId, value = answerValue } + }, timeoutMs: timeoutMs); + + // Validate response before applying + if (response.ValueKind == JsonValueKind.Undefined || response.ValueKind == JsonValueKind.Null) + { + setErrorMsg("Gateway returned empty response for wizard.next"); + setWizardState("error"); + SaveState("error", "Empty wizard.next response"); + } + else + { + ApplyStep(response); + } + } + catch (Exception ex) + { + // SECURITY: Log full exception, show only generic error type to user + Logger.Error($"[Wizard] Step '{stepId}' ({stepType}) failed: {ex}"); + var msg = LocalizationHelper.GetString("Onboarding_Wizard_StepError"); + if (msg == "Onboarding_Wizard_StepError") msg = "An error occurred processing this step"; + setErrorMsg(msg); + setWizardState("error"); + SaveState("error", msg); + } + finally + { + await Task.Delay(400); // Brief delay so button disable is visible + setSubmitting(false); + } + } + + async void SkipStep() + { + var app = (App)Microsoft.UI.Xaml.Application.Current; + var client = app.GatewayClient ?? Props.GatewayClient; + if (client == null) return; + + if (!client.IsConnectedToGateway) + { + setErrorMsg("Lost connection to gateway. Click Next to skip the wizard, or wait for reconnection."); + setWizardState("error"); + SaveState("error", "Gateway disconnected"); + return; + } + + setSubmitting(true); + try + { + // Send a proper skip answer based on step type: + // - confirm: "false" (decline) + // - select/multiselect: NO answer (gateway keeps current value) + // - note/text/other: "true" to acknowledge and advance + object parameters; + if (stepType == "confirm") + { + parameters = new { sessionId = Props.WizardSessionId ?? "", answer = new { stepId, value = "false" } }; + } + else if (stepType is "select" or "multiselect") + { + // No answer — gateway keeps current value or skips + parameters = new { sessionId = Props.WizardSessionId ?? "" }; + } + else + { + // note, text, etc. — send "true" to acknowledge (gateway repeats step if no answer) + parameters = new { sessionId = Props.WizardSessionId ?? "", answer = new { stepId, value = "true" } }; + } + + var response = await client.SendWizardRequestAsync("wizard.next", parameters); + ApplyStep(response); + } + catch (Exception ex) + { + Logger.Error($"[Wizard] Skip step failed: {ex}"); + var msg = LocalizationHelper.GetString("Onboarding_Wizard_StepError"); + if (msg == "Onboarding_Wizard_StepError") msg = "An error occurred processing this step"; + setErrorMsg(msg); + setWizardState("error"); + SaveState("error", msg); + } + finally + { + await Task.Delay(400); // Brief delay so button disable is visible + setSubmitting(false); + } + } + + // Always render exactly the same element tree structure. + // Use empty strings for unused fields to keep a consistent child count. + string displayTitle = ""; + string displayMessage = ""; + Element inputArea = TextBlock(""); // placeholder for input controls + string buttonLabel1 = LocalizationHelper.GetString("Onboarding_Wizard_Continue"); + string buttonLabel2 = LocalizationHelper.GetString("Onboarding_Wizard_Skip"); + bool showButtons = false; + + switch (wizardState) + { + case "active": + displayTitle = stepTitle; + displayMessage = stepMessage; + showButtons = true; + + // Check sensitive flag from stored payload + bool isSensitive = false; + if (Props.WizardStepPayload.HasValue) + { + var sp = Props.WizardStepPayload.Value; + if (sp.TryGetProperty("step", out var ss)) + isSensitive = ss.TryGetProperty("sensitive", out var sv) && sv.ValueKind == JsonValueKind.True; + } + + if (stepType == "text") + { + // Use PasswordBox for sensitive inputs (API keys, tokens) + if (isSensitive) + { + inputArea = PasswordBox(stepInput, v => setStepInput(v), + placeholderText: string.IsNullOrEmpty(placeholder) ? "Enter value..." : placeholder); + } + else + { + inputArea = TextField(stepInput, v => setStepInput(v), + placeholder: string.IsNullOrEmpty(placeholder) ? "Enter value..." : placeholder); + } + } + else if (stepType == "select" || stepType == "multiselect") + { + // Read options directly from stored payload to avoid state timing issues + var labels = new List(); + var values = new List(); + if (Props.WizardStepPayload.HasValue) + { + var p = Props.WizardStepPayload.Value; + if (p.TryGetProperty("step", out var s) && s.TryGetProperty("options", out var opts) && opts.ValueKind == JsonValueKind.Array) + { + foreach (var o in opts.EnumerateArray()) + { + if (o.ValueKind == JsonValueKind.Object) + { + var label = o.TryGetProperty("label", out var lp) ? lp.ToString() : ""; + var value = o.TryGetProperty("value", out var vp) ? vp.ToString() : label; + var hint = o.TryGetProperty("hint", out var hp) ? hp.ToString() : ""; + labels.Add(string.IsNullOrEmpty(hint) ? label : $"{label} — {hint}"); + values.Add(value); + } + else + { + labels.Add(o.ToString()); + values.Add(o.ToString()); + } + } + } + } + + if (labels.Count > 0) + { + var selIdx = values.IndexOf(stepInput); + var labelsArr = labels.ToArray(); + var valuesArr = values.ToArray(); + inputArea = RadioButtons(labelsArr, selIdx >= 0 ? selIdx : 0, + idx => + { + if (idx >= 0 && idx < valuesArr.Length) + setStepInput(valuesArr[idx]); + }) + .Set(rb => { rb.MaxColumns = 1; rb.MaxWidth = 400; }); + } + else + { + inputArea = TextBlock("No options available").FontSize(12).Opacity(0.5); + showButtons = false; // Don't allow submit with no valid selection + } + } + else if (stepType == "confirm") + { + buttonLabel1 = "Yes"; + buttonLabel2 = "No / Skip"; + } + else if (stepType == "progress") + { + // Show spinner while gateway polls for auth completion + inputArea = HStack(8, + ProgressRing().Width(24).Height(24), + TextBlock("Waiting...").FontSize(13).Opacity(0.7) + .VAlign(VerticalAlignment.Center) + ); + showButtons = false; // Gateway auto-advances on completion + } + + break; + + case "complete": + displayTitle = $"✅ {LocalizationHelper.GetString("Onboarding_Wizard_Complete")}"; + displayMessage = "Click Next to continue."; + break; + + case "error": + displayTitle = "❌ Wizard error"; + displayMessage = errorMsg; + showButtons = true; + buttonLabel1 = "Retry"; + buttonLabel2 = "Skip Wizard"; + break; + + case "loading": + displayTitle = $"🔄 {LocalizationHelper.GetString("Onboarding_Connection_StatusAuthenticating")}"; + displayMessage = "Connecting to gateway..."; + inputArea = HStack(8, + ProgressRing().Width(24).Height(24), + TextBlock("Please wait while the connection is established...") + .FontSize(13).Opacity(0.7) + .VAlign(VerticalAlignment.Center) + ); + break; + + default: + displayTitle = $"🔌 {LocalizationHelper.GetString("Onboarding_Wizard_Offline")}"; + displayMessage = $"{LocalizationHelper.GetString("Onboarding_Wizard_OfflineMessage")}\n\nClick Next to continue."; + break; + } + + // Detect URLs and device codes in the message for auth flows + Element urlButton = TextBlock(""); // placeholder + Element deviceCodeDisplay = TextBlock(""); // placeholder + + if (!string.IsNullOrEmpty(displayMessage)) + { + // URL detection — find https:// URLs in the message + var urlMatch = Regex.Match(displayMessage, @"(https?://[^\s\)\"",]+)"); + if (urlMatch.Success) + { + var detectedUrl = urlMatch.Value; + urlButton = Button($"🌐 Open in browser: {detectedUrl}", () => + { + try + { + if (Uri.TryCreate(detectedUrl, UriKind.Absolute, out var btnUri) && (btnUri.Scheme == "https" || btnUri.Scheme == "http")) + _ = global::Windows.System.Launcher.LaunchUriAsync(btnUri); + } + catch { } + }).HAlign(HorizontalAlignment.Left); + + // Auto-open the browser on first render of this step + // (UseEffect runs once per step since stepId changes) + } + + // Device code detection — look for "Code: XXXX-XXXX" or similar. + // Capture must contain a digit or hyphen (or be all uppercase) to avoid + // matching common English words like "below" that follow "code". + // Case-sensitive on the value to require the GitHub-style uppercase code. + var codeMatch = Regex.Match( + displayMessage, + @"(?:^|\s)(?:[Cc]ode|user_code|USER_CODE)\s*[:=]\s*([A-Z0-9]{2,8}(?:-[A-Z0-9]{2,8})+|[A-Z0-9]{4,12})\b"); + if (codeMatch.Success) + { + var code = codeMatch.Groups[1].Value; + deviceCodeDisplay = Border( + HStack(12, + TextBlock(code) + .FontSize(28) + .FontFamily("Consolas") + .FontWeight(new global::Windows.UI.Text.FontWeight(700)) + .VAlign(VerticalAlignment.Center), + Button("Copy", () => + { + try + { + var dp = new global::Windows.ApplicationModel.DataTransfer.DataPackage(); + dp.SetText(code); + global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(dp); + } + catch { } + }).VAlign(VerticalAlignment.Center) + ).Padding(12) + ) + .CornerRadius(6) + .Background("#E8F4FD") + .HAlign(HorizontalAlignment.Center); + } + } + + // Auto-open browser for auth URLs when a new step arrives + UseEffect(() => + { + if (!string.IsNullOrEmpty(displayMessage)) + { + var urlMatch = Regex.Match(displayMessage, @"(https?://[^\s\)\"",]+)"); + if (urlMatch.Success) + { + try + { + if (Uri.TryCreate(urlMatch.Value, UriKind.Absolute, out var autoUri) && (autoUri.Scheme == "https" || autoUri.Scheme == "http")) + _ = global::Windows.System.Launcher.LaunchUriAsync(autoUri); + } + catch { } + } + } + }, stepId); // Re-runs when stepId changes (new step) + + return VStack(8, + TextBlock(LocalizationHelper.GetString("Onboarding_Wizard_Title")) + .FontSize(22) + .FontWeight(new global::Windows.UI.Text.FontWeight(700)) + .HAlign(HorizontalAlignment.Center), + + Border( + ScrollView( + VStack(10, + TextBlock(displayTitle) + .FontSize(15) + .FontWeight(new global::Windows.UI.Text.FontWeight(700)) + .TextWrapping(), + TextBlock(displayMessage) + .FontSize(13) + .TextWrapping(), + inputArea + ).Padding(16).MaxWidth(420) + ).HorizontalScrollMode(Microsoft.UI.Xaml.Controls.ScrollMode.Disabled) + ) + .CornerRadius(8) + .Background("#FFFFFF") + .MaxHeight(350), + + // Device code display (large, copyable — for auth flows) + deviceCodeDisplay, + + // "Open in browser" button (for auth URLs) + urlButton, + + showButtons + ? HStack(8, + Button(buttonLabel1, SubmitStep).Disabled(submitting), + Button(buttonLabel2, SkipStep).Disabled(submitting)) + : TextBlock(""), + + totalSteps > 0 && wizardState == "active" + ? TextBlock($"Step {stepNumber + 1} of {totalSteps}") + .FontSize(12).Opacity(0.5).HAlign(HorizontalAlignment.Center) + : TextBlock("") + ) + .MaxWidth(460) + .Padding(0, 8, 0, 0); + } +} + diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/ConnectionPageModeSelector.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/ConnectionPageModeSelector.cs new file mode 100644 index 00000000..d80eea09 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/ConnectionPageModeSelector.cs @@ -0,0 +1,93 @@ +namespace OpenClawTray.Onboarding.Services; + +public sealed record ConnectionModeSelectionResult( + ConnectionMode Mode, + string Url, + bool UpdateGatewayUrl, + bool UseSshTunnel, + bool ConnectionTested, + string StatusMessage, + string PairingDeviceId, + bool ShowFields, + bool UrlReadOnly); + +public static class ConnectionPageModeSelector +{ + public const string DefaultLocalUrl = "ws://localhost:18789"; + public const string DevLocalUrl = "ws://localhost:19001"; + public const string WslUrl = "ws://wsl.localhost:18789"; + public const int DefaultSshTunnelPort = 18789; + + public static string GetInitialUrl( + ConnectionMode mode, + string settingsGatewayUrl, + int sshTunnelLocalPort, + Func getDetectedLocalUrl) + { + return mode switch + { + ConnectionMode.Local => getDetectedLocalUrl(), + ConnectionMode.Wsl => WslUrl, + ConnectionMode.Ssh => $"ws://127.0.0.1:{Math.Max(1, sshTunnelLocalPort)}", + ConnectionMode.Later => "", + _ => settingsGatewayUrl + }; + } + + public static bool ShouldShowConnectionFields(ConnectionMode mode) => mode != ConnectionMode.Later; + + public static bool IsGatewayUrlReadOnly(ConnectionMode mode) => mode == ConnectionMode.Ssh; + + public static ConnectionModeSelectionResult SelectMode( + ConnectionMode mode, + string currentUrl, + string detectedLocalUrl, + int sshTunnelLocalPort, + string detectedStatusMessage, + string laterStatusMessage) + { + var useSshTunnel = false; + var updateGatewayUrl = false; + var nextUrl = currentUrl; + var statusMessage = ""; + + switch (mode) + { + case ConnectionMode.Local: + nextUrl = detectedLocalUrl; + updateGatewayUrl = true; + statusMessage = detectedLocalUrl != DefaultLocalUrl ? detectedStatusMessage : ""; + break; + + case ConnectionMode.Wsl: + nextUrl = WslUrl; + updateGatewayUrl = true; + break; + + case ConnectionMode.Remote: + break; + + case ConnectionMode.Ssh: + var localPort = sshTunnelLocalPort > 0 ? sshTunnelLocalPort : DefaultSshTunnelPort; + nextUrl = $"ws://127.0.0.1:{localPort}"; + updateGatewayUrl = true; + useSshTunnel = true; + break; + + case ConnectionMode.Later: + statusMessage = laterStatusMessage; + break; + } + + return new ConnectionModeSelectionResult( + mode, + nextUrl, + updateGatewayUrl, + useSshTunnel, + ConnectionTested: false, + statusMessage, + PairingDeviceId: "", + ShouldShowConnectionFields(mode), + IsGatewayUrlReadOnly(mode)); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/GatewayHealthCheck.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/GatewayHealthCheck.cs new file mode 100644 index 00000000..20187109 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/GatewayHealthCheck.cs @@ -0,0 +1,96 @@ +using System; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using OpenClawTray.Services; + +namespace OpenClawTray.Onboarding.Services; + +/// +/// Tests gateway reachability by hitting the /health HTTP endpoint. +/// Converts ws:// → http:// and wss:// → https:// for the health check. +/// +public static class GatewayHealthCheck +{ + private static readonly HttpClient s_client = new() + { + Timeout = TimeSpan.FromSeconds(5) + }; + + public record TestResult(bool Success, string? Error = null); + + /// + /// Builds the health check URI from a gateway WebSocket URL. + /// Extracted for testability. + /// + public static bool TryBuildHealthUri(string gatewayUrl, out Uri? healthUri, out string error) + { + healthUri = null; + error = string.Empty; + + if (string.IsNullOrWhiteSpace(gatewayUrl)) + { + error = "Gateway URL is empty"; + return false; + } + + try + { + var builder = new UriBuilder(gatewayUrl); + builder.Scheme = builder.Scheme switch + { + "ws" => "http", + "wss" => "https", + _ => builder.Scheme + }; + builder.Path = builder.Path.TrimEnd('/') + "/health"; + healthUri = builder.Uri; + return true; + } + catch (Exception ex) + { + error = $"Invalid URL: {ex.Message}"; + return false; + } + } + + public static async Task TestAsync(string gatewayUrl, string? token) + { + if (!TryBuildHealthUri(gatewayUrl, out var healthUri, out var buildError)) + return new TestResult(false, buildError); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, healthUri); + if (!string.IsNullOrWhiteSpace(token)) + { + request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}"); + } + + using var response = await s_client.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + // SECURITY: Don't expose HTTP status details — they reveal gateway software info + return new TestResult(false, "Gateway returned an error response"); + } + + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + var ok = doc.RootElement.TryGetProperty("ok", out var okProp) && okProp.GetBoolean(); + + return ok + ? new TestResult(true) + : new TestResult(false, "Gateway responded but health check failed"); + } + catch (TaskCanceledException) + { + return new TestResult(false, "Connection timed out (5s)"); + } + catch (HttpRequestException ex) + { + // SECURITY: Log full exception, return generic message + Logger.Warn($"[HealthCheck] Request failed: {ex.Message}"); + return new TestResult(false, "Cannot reach gateway — check URL and network"); + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/InputValidator.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/InputValidator.cs new file mode 100644 index 00000000..9f6db28d --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/InputValidator.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Linq; + +namespace OpenClawTray.Onboarding.Services; + +/// +/// Centralizes input validation for security-sensitive operations. +/// Extracted from inline checks in App.xaml.cs, OnboardingWindow.cs, and PermissionsPage.cs. +/// +public static class InputValidator +{ + private static readonly string[] AllowedLocales = ["en-us", "fr-fr", "nl-nl", "zh-cn", "zh-tw"]; + + /// + /// Validates a locale string against the allowed whitelist. + /// + public static bool IsValidLocale(string locale) + { + if (string.IsNullOrWhiteSpace(locale)) return false; + return AllowedLocales.Contains(locale.ToLowerInvariant()); + } + + /// + /// Validates a port string is a number in the range 1–65535. + /// + public static bool IsValidPort(string portStr) + { + if (string.IsNullOrWhiteSpace(portStr)) return false; + return int.TryParse(portStr, out var p) && p >= 1 && p <= 65535; + } + + /// + /// Validates a directory path is safe (no null bytes, no path traversal). + /// Returns the full path if valid, null otherwise. + /// + public static string? ValidateTestDir(string path) + { + if (string.IsNullOrWhiteSpace(path)) return null; + if (path.Contains('\0')) return null; + + try + { + var fullPath = Path.GetFullPath(path); + if (fullPath.Contains("..")) return null; + return fullPath; + } + catch + { + return null; + } + } + + /// + /// Validates that a URI uses the ms-settings: scheme. + /// + public static bool IsSettingsUri(string uri) + { + if (string.IsNullOrWhiteSpace(uri)) return false; + return Uri.TryCreate(uri, UriKind.Absolute, out var u) + && u.Scheme.Equals("ms-settings", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalGatewayApprover.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalGatewayApprover.cs new file mode 100644 index 00000000..a13c0eed --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/LocalGatewayApprover.cs @@ -0,0 +1,27 @@ +using System; + +namespace OpenClawTray.Onboarding.Services; + +/// +/// Classifies gateway URLs that point at the local machine. +/// +public static class LocalGatewayApprover +{ + /// + /// Checks if the gateway URL points to localhost. + /// + public static bool IsLocalGateway(string gatewayUrl) + { + if (string.IsNullOrWhiteSpace(gatewayUrl)) return false; + try + { + var uri = new Uri(gatewayUrl); + var host = uri.Host.ToLowerInvariant(); + return host is "localhost" or "127.0.0.1" or "::1" or "[::1]"; + } + catch + { + return false; + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs new file mode 100644 index 00000000..8bc1f595 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs @@ -0,0 +1,139 @@ +using System.Text.Json; +using OpenClawTray.Services; +using OpenClaw.Shared; + +namespace OpenClawTray.Onboarding.Services; + +/// +/// Shared state across all onboarding pages. +/// Tracks the selected connection mode, current page, and completion. +/// +public sealed class OnboardingState : IDisposable +{ + public event EventHandler? Finished; + public event EventHandler? PageChanged; + + /// + /// The currently displayed route. Updated by OnboardingApp on navigation. + /// + public OnboardingRoute CurrentRoute { get; set; } = OnboardingRoute.Welcome; + + /// + /// Raised when the current route changes to or from the Chat page. + /// OnboardingWindow uses this to show/hide the WebView2 overlay. + /// + public event EventHandler? RouteChanged; + + public SettingsManager Settings { get; } + + /// + /// Selected gateway connection mode. + /// + public ConnectionMode Mode { get; set; } = ConnectionMode.Local; + + /// + /// Whether the onboarding chat page should be shown. + /// + public bool ShowChat { get; set; } = true; + + /// + /// Whether the connection was successfully tested during onboarding. + /// + public bool ConnectionTested { get; set; } + + /// + /// Shared gateway client established during connection testing. + /// Available for the Wizard page to make RPC calls (wizard.start/wizard.next). + /// Null until connection is successfully tested. + /// + public OpenClawGatewayClient? GatewayClient { get; set; } + + // ── Wizard session state (persisted across page navigations) ── + + /// Wizard session ID from gateway wizard.start response. + public string? WizardSessionId { get; set; } + + /// Current wizard step payload (JSON from last wizard.start/wizard.next response). + public JsonElement? WizardStepPayload { get; set; } + + /// Wizard lifecycle state: null=not started, "active", "complete", "offline", "error". + public string? WizardLifecycleState { get; set; } + + /// Wizard error message if in error state. + public string? WizardError { get; set; } + + public OnboardingState(SettingsManager settings) + { + Settings = settings; + } + + /// + /// Returns the page order based on the selected mode and chat preference, + /// matching the macOS onboarding flow. + /// + public OnboardingRoute[] GetPageOrder() + { + // Node mode: skip Wizard and Chat — node clients can't use operator RPCs + if (Settings.EnableNodeMode) + { + return Mode switch + { + ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Remote or ConnectionMode.Ssh => + [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready], + _ => // Later or unknown + [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready], + }; + } + + return (Mode, ShowChat) switch + { + // Local-style flows (Local, WSL, SSH tunnel) all run wizard locally + (ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Ssh, true) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Chat, OnboardingRoute.Ready], + (ConnectionMode.Local or ConnectionMode.Wsl or ConnectionMode.Ssh, false) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Wizard, OnboardingRoute.Permissions, OnboardingRoute.Ready], + (ConnectionMode.Remote, true) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Chat, OnboardingRoute.Ready], + (ConnectionMode.Remote, false) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Permissions, OnboardingRoute.Ready], + (ConnectionMode.Later, _) => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready], + _ => [OnboardingRoute.Welcome, OnboardingRoute.Connection, OnboardingRoute.Ready], + }; + } + + public void NotifyPageChanged() => PageChanged?.Invoke(this, EventArgs.Empty); + + public void NotifyRouteChanged(OnboardingRoute route) + { + CurrentRoute = route; + RouteChanged?.Invoke(this, route); + } + + public void Complete() + { + Settings.Save(); + Finished?.Invoke(this, EventArgs.Empty); + } + + public void Dispose() + { + if (GatewayClient is IDisposable d) + d.Dispose(); + GatewayClient = null; + } +} + +public enum ConnectionMode +{ + Local, + Wsl, + Remote, + Ssh, + Later, +} + +public enum OnboardingRoute +{ + Welcome, + Connection, + Wizard, + Permissions, + Chat, + Ready, +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/PermissionChecker.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/PermissionChecker.cs new file mode 100644 index 00000000..f7b3303a --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/PermissionChecker.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Win32; +using Windows.Devices.Enumeration; +using Windows.Foundation; +using Windows.Graphics.Capture; +using Windows.UI.Notifications; +using OpenClawTray.Helpers; + +namespace OpenClawTray.Onboarding.Services; + +/// +/// Checks real Windows permission status for onboarding. +/// Uses lightweight passive checks — never triggers OS consent dialogs. +/// Designed for unpackaged apps (WindowsPackageType=None). +/// +public static class PermissionChecker +{ + public enum PermissionStatus + { + /// Permission is granted and ready to use. + Granted, + /// Permission is denied by user or system policy. + Denied, + /// No hardware device available (camera/mic). + NoDevice, + /// Feature is supported but uses on-demand consent (screen capture picker). + Supported, + /// Feature is not supported on this device/OS version. + NotSupported, + /// Status could not be determined. + Unknown + } + + public record PermissionResult( + string Name, + string Icon, + PermissionStatus Status, + string SettingsUri, + string StatusLabel); + + /// + /// Checks all 5 permissions and returns current status for each. + /// All checks are passive — no OS consent dialogs are triggered. + /// + public static async Task> CheckAllAsync() + { + var results = new List(); + + results.Add(CheckNotifications()); + results.Add(await CheckCameraAsync()); + results.Add(await CheckMicrophoneAsync()); + results.Add(CheckLocation()); + results.Add(CheckScreenCapture()); + + return results; + } + + /// + /// Subscribes to camera and microphone access changes for auto-refresh. + /// Returns an Action that unsubscribes when called. + /// + public static Action SubscribeToAccessChanges(Action onChanged) + { + var cameraAccess = DeviceAccessInformation.CreateFromDeviceClass(DeviceClass.VideoCapture); + var micAccess = DeviceAccessInformation.CreateFromDeviceClass(DeviceClass.AudioCapture); + + TypedEventHandler handler = + (_, _) => onChanged(); + + cameraAccess.AccessChanged += handler; + micAccess.AccessChanged += handler; + + return () => + { + cameraAccess.AccessChanged -= handler; + micAccess.AccessChanged -= handler; + }; + } + + private static PermissionResult CheckNotifications() + { + try + { + // Use the Compat API which handles unpackaged app identity correctly + var notifier = Microsoft.Toolkit.Uwp.Notifications.ToastNotificationManagerCompat.CreateToastNotifier(); + var setting = notifier.Setting; + + var (status, label) = setting switch + { + NotificationSetting.Enabled => (PermissionStatus.Granted, LocalizationHelper.GetString("Onboarding_Perm_Enabled")), + NotificationSetting.DisabledByManifest => (PermissionStatus.Denied, LocalizationHelper.GetString("Onboarding_Perm_DisabledManifest")), + NotificationSetting.DisabledByGroupPolicy => (PermissionStatus.Denied, LocalizationHelper.GetString("Onboarding_Perm_DisabledPolicy")), + NotificationSetting.DisabledForUser => (PermissionStatus.Denied, LocalizationHelper.GetString("Onboarding_Perm_DisabledUser")), + _ => (PermissionStatus.Denied, LocalizationHelper.GetString("Onboarding_Perm_Disabled")) + }; + + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_Notifications"), "🔔", status, + "ms-settings:notifications", label); + } + catch (Exception) + { + // Fallback: check global notification setting via registry + return CheckNotificationsViaRegistry(); + } + } + + private static PermissionResult CheckNotificationsViaRegistry() + { + try + { + using var key = Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\CurrentVersion\PushNotifications"); + if (key != null) + { + var val = key.GetValue("ToastEnabled"); + if (val is int intVal) + { + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_Notifications"), "🔔", + intVal == 1 ? PermissionStatus.Granted : PermissionStatus.Denied, + "ms-settings:notifications", + intVal == 1 ? LocalizationHelper.GetString("Onboarding_Perm_Enabled") : LocalizationHelper.GetString("Onboarding_Perm_DisabledUser")); + } + } + + // Key absent = notifications enabled by default + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_Notifications"), "🔔", PermissionStatus.Granted, + "ms-settings:notifications", LocalizationHelper.GetString("Onboarding_Perm_EnabledDefault")); + } + catch (Exception) + { + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_Notifications"), "🔔", PermissionStatus.Unknown, + "ms-settings:notifications", LocalizationHelper.GetString("Onboarding_Perm_UnableToCheck")); + } + } + + private static async Task CheckCameraAsync() + { + try + { + var devices = await DeviceInformation.FindAllAsync(DeviceClass.VideoCapture); + if (devices.Count == 0) + { + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_Camera"), "📷", PermissionStatus.NoDevice, + "ms-settings:privacy-webcam", LocalizationHelper.GetString("Onboarding_Perm_NoCameraDetected")); + } + + var access = DeviceAccessInformation.CreateFromDeviceClass(DeviceClass.VideoCapture); + var (status, label) = access.CurrentStatus switch + { + DeviceAccessStatus.Allowed => (PermissionStatus.Granted, LocalizationHelper.GetString("Onboarding_Perm_Allowed")), + DeviceAccessStatus.DeniedByUser => (PermissionStatus.Denied, LocalizationHelper.GetString("Onboarding_Perm_DeniedUser")), + DeviceAccessStatus.DeniedBySystem => (PermissionStatus.Denied, LocalizationHelper.GetString("Onboarding_Perm_DeniedSystem")), + _ => (PermissionStatus.Unknown, LocalizationHelper.GetString("Onboarding_Perm_NotDetermined")) + }; + + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_Camera"), "📷", status, + "ms-settings:privacy-webcam", label); + } + catch (Exception) + { + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_Camera"), "📷", PermissionStatus.Unknown, + "ms-settings:privacy-webcam", LocalizationHelper.GetString("Onboarding_Perm_UnableToCheck")); + } + } + + private static async Task CheckMicrophoneAsync() + { + try + { + var devices = await DeviceInformation.FindAllAsync(DeviceClass.AudioCapture); + if (devices.Count == 0) + { + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_Microphone"), "🎤", PermissionStatus.NoDevice, + "ms-settings:privacy-microphone", LocalizationHelper.GetString("Onboarding_Perm_NoMicDetected")); + } + + var access = DeviceAccessInformation.CreateFromDeviceClass(DeviceClass.AudioCapture); + var (status, label) = access.CurrentStatus switch + { + DeviceAccessStatus.Allowed => (PermissionStatus.Granted, LocalizationHelper.GetString("Onboarding_Perm_Allowed")), + DeviceAccessStatus.DeniedByUser => (PermissionStatus.Denied, LocalizationHelper.GetString("Onboarding_Perm_DeniedUser")), + DeviceAccessStatus.DeniedBySystem => (PermissionStatus.Denied, LocalizationHelper.GetString("Onboarding_Perm_DeniedSystem")), + _ => (PermissionStatus.Unknown, LocalizationHelper.GetString("Onboarding_Perm_NotDetermined")) + }; + + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_Microphone"), "🎤", status, + "ms-settings:privacy-microphone", label); + } + catch (Exception) + { + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_Microphone"), "🎤", PermissionStatus.Unknown, + "ms-settings:privacy-microphone", LocalizationHelper.GetString("Onboarding_Perm_UnableToCheck")); + } + } + + private static PermissionResult CheckScreenCapture() + { + try + { + bool supported = GraphicsCaptureSession.IsSupported(); + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_ScreenCapture"), "🖥️", + supported ? PermissionStatus.Supported : PermissionStatus.NotSupported, + "", // No persistent settings URI — uses picker at capture time + supported ? LocalizationHelper.GetString("Onboarding_Perm_ScreenCaptureAvailable") : LocalizationHelper.GetString("Onboarding_Perm_NotSupported")); + } + catch (Exception) + { + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_ScreenCapture"), "🖥️", PermissionStatus.Unknown, + "", LocalizationHelper.GetString("Onboarding_Perm_UnableToCheck")); + } + } + + /// + /// Checks location permission passively via registry. + /// NEVER calls Geolocator.RequestAccessAsync() which triggers an OS consent dialog. + /// + private static PermissionResult CheckLocation() + { + try + { + // Check system-wide location service status + using var sysKey = Registry.LocalMachine.OpenSubKey( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\location"); + var sysValue = sysKey?.GetValue("Value") as string; + + if (string.Equals(sysValue, "Deny", StringComparison.OrdinalIgnoreCase)) + { + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_Location"), "📍", PermissionStatus.Denied, + "ms-settings:privacy-location", LocalizationHelper.GetString("Onboarding_Perm_LocationDisabledSystem")); + } + + // Check per-user location setting + using var userKey = Registry.CurrentUser.OpenSubKey( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\location"); + var userValue = userKey?.GetValue("Value") as string; + + if (string.Equals(userValue, "Deny", StringComparison.OrdinalIgnoreCase)) + { + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_Location"), "📍", PermissionStatus.Denied, + "ms-settings:privacy-location", LocalizationHelper.GetString("Onboarding_Perm_LocationDisabledUser")); + } + + if (string.Equals(userValue, "Allow", StringComparison.OrdinalIgnoreCase) + || string.Equals(sysValue, "Allow", StringComparison.OrdinalIgnoreCase)) + { + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_Location"), "📍", PermissionStatus.Granted, + "ms-settings:privacy-location", LocalizationHelper.GetString("Onboarding_Perm_LocationEnabled")); + } + + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_Location"), "📍", PermissionStatus.Unknown, + "ms-settings:privacy-location", LocalizationHelper.GetString("Onboarding_Perm_NotDetermined")); + } + catch (Exception) + { + return new PermissionResult(LocalizationHelper.GetString("Onboarding_Perm_Location"), "📍", PermissionStatus.Unknown, + "ms-settings:privacy-location", LocalizationHelper.GetString("Onboarding_Perm_UnableToCheck")); + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/SetupCodeDecoder.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/SetupCodeDecoder.cs new file mode 100644 index 00000000..d9985f5d --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/SetupCodeDecoder.cs @@ -0,0 +1,74 @@ +using System; +using System.Text; +using System.Text.Json; +using OpenClaw.Shared; + +namespace OpenClawTray.Onboarding.Services; + +/// +/// Decodes base64url-encoded setup codes into gateway URL and token. +/// Extracted from ConnectionPage for testability. +/// +public static class SetupCodeDecoder +{ + public record DecodeResult(bool Success, string? Url = null, string? Token = null, string? Error = null); + + public static DecodeResult Decode(string setupCode) + { + if (string.IsNullOrWhiteSpace(setupCode)) + return new DecodeResult(false, Error: "Setup code is empty"); + + if (setupCode.Length > 2048) + return new DecodeResult(false, Error: "Setup code exceeds 2048 character limit"); + + string json; + try + { + // Base64url decode: replace URL-safe chars, add padding + var b64 = setupCode.Trim().Replace('-', '+').Replace('_', '/'); + var pad = b64.Length % 4; + if (pad > 0) b64 += new string('=', 4 - pad); + + json = Encoding.UTF8.GetString(Convert.FromBase64String(b64)); + } + catch (Exception ex) + { + return new DecodeResult(false, Error: $"Invalid base64: {ex.Message}"); + } + + if (json.Length > 4096) + return new DecodeResult(false, Error: "Decoded JSON exceeds 4KB"); + + try + { + var doc = JsonDocument.Parse(json); + string? url = null; + string? token = null; + + if (doc.RootElement.TryGetProperty("url", out var urlProp)) + { + var decoded = urlProp.GetString() ?? ""; + if (!string.IsNullOrEmpty(decoded)) + { + if (!GatewayUrlHelper.IsValidGatewayUrl(decoded)) + return new DecodeResult(false, Error: "Invalid gateway URL in setup code"); + url = decoded; + } + } + + if (doc.RootElement.TryGetProperty("bootstrapToken", out var tokenProp)) + { + var decoded = tokenProp.GetString() ?? ""; + if (decoded.Length <= 512) + token = decoded; + // Token exceeding 512 chars is silently ignored (not set) + } + + return new DecodeResult(true, Url: url, Token: token); + } + catch (JsonException ex) + { + return new DecodeResult(false, Error: $"Invalid JSON: {ex.Message}"); + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/WizardStepParser.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/WizardStepParser.cs new file mode 100644 index 00000000..260fd22c --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/WizardStepParser.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace OpenClawTray.Onboarding.Services; + +/// +/// Parses wizard step JSON payloads from gateway wizard.start/wizard.next responses. +/// Extracted from WizardPage.ApplyStep for testability. +/// +public static class WizardStepParser +{ + public record ParsedStep( + bool IsDone, + string? SessionId, + string StepType, + string Title, + string Message, + string StepId, + string[] OptionLabels, + string[] OptionValues, + string[] OptionHints, + string InitialValue, + string Placeholder, + bool Sensitive, + int StepNumber, + int TotalSteps, + string? Error + ); + + public static ParsedStep Parse(JsonElement payload) + { + if (payload.ValueKind == JsonValueKind.Undefined || payload.ValueKind == JsonValueKind.Null) + { + return new ParsedStep( + IsDone: false, SessionId: null, StepType: "", Title: "", Message: "", + StepId: "", OptionLabels: [], OptionValues: [], OptionHints: [], + InitialValue: "", Placeholder: "", Sensitive: false, + StepNumber: 0, TotalSteps: 0, Error: "Empty response from gateway"); + } + + try + { + string? sessionId = null; + if (payload.TryGetProperty("sessionId", out var sidProp)) + sessionId = sidProp.GetString() ?? ""; + + // Check for completion + if (payload.TryGetProperty("done", out var doneProp) && doneProp.GetBoolean()) + { + return new ParsedStep( + IsDone: true, SessionId: sessionId, StepType: "", Title: "", Message: "", + StepId: "", OptionLabels: [], OptionValues: [], OptionHints: [], + InitialValue: "", Placeholder: "", Sensitive: false, + StepNumber: 0, TotalSteps: 0, Error: null); + } + + if (!payload.TryGetProperty("step", out var step)) + { + return new ParsedStep( + IsDone: false, SessionId: sessionId, StepType: "", Title: "", Message: "", + StepId: "", OptionLabels: [], OptionValues: [], OptionHints: [], + InitialValue: "", Placeholder: "", Sensitive: false, + StepNumber: 0, TotalSteps: 0, Error: "Missing 'step' property"); + } + + var typeStr = step.TryGetProperty("type", out var tp) ? tp.ToString() : "note"; + var title = step.TryGetProperty("title", out var t) ? t.ToString() : ""; + var message = step.TryGetProperty("message", out var m) ? m.ToString() : ""; + + if (string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(message)) + title = typeStr switch { "confirm" => "Confirm", "select" => "Select", "text" => "Input", _ => "Setup" }; + + var stepId = step.TryGetProperty("id", out var id) ? id.ToString() : ""; + var placeholder = step.TryGetProperty("placeholder", out var ph) ? ph.ToString() : ""; + var initialValue = step.TryGetProperty("initialValue", out var ivp) ? ivp.ToString() : ""; + var sensitive = step.TryGetProperty("sensitive", out var sp) && sp.GetBoolean(); + + // Parse options + var labels = new List(); + var values = new List(); + var hints = new List(); + + if (step.TryGetProperty("options", out var opts) && opts.ValueKind == JsonValueKind.Array) + { + foreach (var o in opts.EnumerateArray()) + { + if (o.ValueKind == JsonValueKind.Object) + { + var label = o.TryGetProperty("label", out var lp) ? lp.ToString() : ""; + var value = o.TryGetProperty("value", out var vp) ? vp.ToString() : label; + var hint = o.TryGetProperty("hint", out var hp) ? hp.ToString() : ""; + labels.Add(string.IsNullOrEmpty(hint) ? label : $"{label} — {hint}"); + values.Add(value); + hints.Add(hint); + } + else + { + var s = o.ToString(); + labels.Add(s); + values.Add(s); + hints.Add(""); + } + } + } + + int stepNumber = 0, totalSteps = 0; + if (payload.TryGetProperty("stepIndex", out var si)) + stepNumber = si.GetInt32(); + if (payload.TryGetProperty("totalSteps", out var ts)) + totalSteps = ts.GetInt32(); + + return new ParsedStep( + IsDone: false, + SessionId: sessionId, + StepType: typeStr, + Title: title, + Message: message, + StepId: stepId, + OptionLabels: labels.ToArray(), + OptionValues: values.ToArray(), + OptionHints: hints.ToArray(), + InitialValue: initialValue, + Placeholder: placeholder, + Sensitive: sensitive, + StepNumber: stepNumber, + TotalSteps: totalSteps, + Error: null + ); + } + catch (Exception ex) + { + return new ParsedStep( + IsDone: false, SessionId: null, StepType: "", Title: "", Message: "", + StepId: "", OptionLabels: [], OptionValues: [], OptionHints: [], + InitialValue: "", Placeholder: "", Sensitive: false, + StepNumber: 0, TotalSteps: 0, Error: ex.Message); + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/FeatureRow.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/FeatureRow.cs new file mode 100644 index 00000000..a3abdebb --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/FeatureRow.cs @@ -0,0 +1,32 @@ +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using static OpenClawTray.FunctionalUI.Factories; +using Microsoft.UI.Xaml; + +namespace OpenClawTray.Onboarding.Widgets; + +public record FeatureRowProps(string Icon, string Title, string Subtitle); + +/// +/// Icon + title + subtitle row for the Ready page. +/// +public sealed class FeatureRow : Component +{ + public override Element Render() + { + return HStack(12, + TextBlock(Props.Icon) + .FontSize(20) + .Width(28) + .HAlign(HorizontalAlignment.Center), + VStack(2, + TextBlock(Props.Title) + .FontSize(14) + .FontWeight(new global::Windows.UI.Text.FontWeight(600)), + TextBlock(Props.Subtitle) + .FontSize(12) + .Opacity(0.7) + ) + ); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/GlowingIcon.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/GlowingIcon.cs new file mode 100644 index 00000000..f4094312 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/GlowingIcon.cs @@ -0,0 +1,20 @@ +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using static OpenClawTray.FunctionalUI.Factories; +using Microsoft.UI.Xaml; + +namespace OpenClawTray.Onboarding.Widgets; + +/// +/// Lobster emoji icon rendered at large size, centered. +/// Can be enhanced with glow/pulse animation later. +/// +public sealed class GlowingIcon : Component +{ + public override Element Render() + { + return TextBlock("🦞") + .FontSize(48) + .HAlign(HorizontalAlignment.Center); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/OnboardingCard.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/OnboardingCard.cs new file mode 100644 index 00000000..ea093eb1 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/OnboardingCard.cs @@ -0,0 +1,22 @@ +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using static OpenClawTray.FunctionalUI.Factories; + +namespace OpenClawTray.Onboarding.Widgets; + +/// +/// Reusable card with rounded corners, white background, and padding. +/// Props: the child to render inside the card. +/// +public sealed class OnboardingCard : Component +{ + public override Element Render() + { + return Border( + Props + ) + .CornerRadius(12) + .Background("#FFFFFF") + .Padding(20, 20, 20, 20); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/StepIndicator.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/StepIndicator.cs new file mode 100644 index 00000000..39633a6e --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/StepIndicator.cs @@ -0,0 +1,32 @@ +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using static OpenClawTray.FunctionalUI.Factories; +using Microsoft.UI.Xaml; + +namespace OpenClawTray.Onboarding.Widgets; + +public record StepIndicatorProps(int TotalSteps, int CurrentStep); + +/// +/// Dot-based step indicator for the onboarding navigation bar. +/// Current step is highlighted in accent blue; others are grey. +/// +public sealed class StepIndicator : Component +{ + public override Element Render() + { + var dots = new Element[Props.TotalSteps]; + for (var i = 0; i < Props.TotalSteps; i++) + { + var color = i == Props.CurrentStep ? "#0078D4" : "#999999"; + dots[i] = Border(TextBlock("")) + .Width(10) + .Height(10) + .CornerRadius(5) + .Background(color); + } + + return HStack(6, dots) + .HAlign(HorizontalAlignment.Center); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/WizardStepModels.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/WizardStepModels.cs new file mode 100644 index 00000000..e470ce3f --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/WizardStepModels.cs @@ -0,0 +1,26 @@ +using System; + +namespace OpenClawTray.Onboarding.Widgets; + +public enum WizardStepType +{ + Note, + Text, + Confirm, + Select, + MultiSelect, + Progress, + Action, +} + +public record WizardStepProps( + string Id, + string Title, + string Message, + WizardStepType Type, + string[]? Options = null, + string? InitialValue = null, + string? Placeholder = null, + bool Sensitive = false, + Action? OnSubmit = null +); diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/WizardStepView.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/WizardStepView.cs new file mode 100644 index 00000000..4c292872 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Widgets/WizardStepView.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenClawTray.FunctionalUI; +using OpenClawTray.FunctionalUI.Core; +using static OpenClawTray.FunctionalUI.Factories; +using Microsoft.UI.Xaml; + +namespace OpenClawTray.Onboarding.Widgets; + +/// +/// Dynamic wizard step renderer that adapts UI based on . +/// Used by the onboarding wizard to render steps received from the gateway RPC protocol. +/// +public sealed class WizardStepView : Component +{ + public override Element Render() + { + var body = Props.Type switch + { + WizardStepType.Note => RenderNote(), + WizardStepType.Text => RenderText(), + WizardStepType.Confirm => RenderConfirm(), + WizardStepType.Select => RenderSelect(), + WizardStepType.MultiSelect => RenderMultiSelect(), + WizardStepType.Progress => RenderProgress(), + WizardStepType.Action => RenderAction(), + _ => RenderNote(), + }; + + return body + .HAlign(HorizontalAlignment.Center) + .MaxWidth(460); + } + + private Element Header() => + VStack(8, + TextBlock(Props.Title) + .FontSize(20) + .FontWeight(new global::Windows.UI.Text.FontWeight(700)) + .HAlign(HorizontalAlignment.Center), + TextBlock(Props.Message) + .FontSize(14) + .Opacity(0.7) + .HAlign(HorizontalAlignment.Center) + .TextWrapping() + ); + + private Element RenderNote() => + VStack(16, Header()); + + private Element RenderText() + { + var (value, setValue) = UseState(Props.InitialValue ?? ""); + + Element input = Props.Sensitive + ? PasswordBox(value, v => setValue(v), placeholderText: Props.Placeholder) + : TextField(value, v => setValue(v), placeholder: Props.Placeholder, header: null); + + return VStack(16, + Header(), + Border( + VStack(12, input).Padding(16) + ).CornerRadius(8).Background("#FFFFFF"), + Button("Submit", () => Props.OnSubmit?.Invoke(value)) + .HAlign(HorizontalAlignment.Center) + .Disabled(string.IsNullOrWhiteSpace(value)) + ); + } + + private Element RenderConfirm() + { + return VStack(16, + Header(), + HStack(12, + Button("Yes", () => Props.OnSubmit?.Invoke("Yes")), + Button("No", () => Props.OnSubmit?.Invoke("No")) + ).HAlign(HorizontalAlignment.Center) + ); + } + + private Element RenderSelect() + { + var options = Props.Options ?? []; + var initialIndex = Props.InitialValue != null + ? Array.IndexOf(options, Props.InitialValue) + : -1; + var (selected, setSelected) = UseState(initialIndex); + + return VStack(16, + Header(), + Border( + VStack(4, + options.Select((opt, i) => + RadioButton(opt, selected == i, _ => setSelected(i), groupName: Props.Id) + ).ToArray() + ).Padding(16) + ).CornerRadius(8).Background("#FFFFFF"), + Button("Submit", () => + { + if (selected >= 0 && selected < options.Length) + Props.OnSubmit?.Invoke(options[selected]); + }) + .HAlign(HorizontalAlignment.Center) + .Disabled(selected < 0) + ); + } + + private Element RenderMultiSelect() + { + var options = Props.Options ?? []; + var (selections, setSelections) = UseState(new HashSet()); + + var toggles = options.Select((opt, i) => + { + var isChecked = selections.Contains(i); + return HStack(8, + CheckBox(isChecked, _ => + { + var next = new HashSet(selections); + if (isChecked) next.Remove(i); else next.Add(i); + setSelections(next); + }), + TextBlock(opt).FontSize(13) + .VAlign(Microsoft.UI.Xaml.VerticalAlignment.Center) + ); + }).ToArray(); + + return VStack(16, + Header(), + Border( + VStack(6, toggles).Padding(16) + ).CornerRadius(8).Background("#FFFFFF"), + Button("Submit", () => + { + var chosen = selections + .Where(i => i >= 0 && i < options.Length) + .OrderBy(i => i) + .Select(i => options[i]); + Props.OnSubmit?.Invoke(string.Join(",", chosen)); + }) + .HAlign(HorizontalAlignment.Center) + .Disabled(selections.Count == 0) + ); + } + + private Element RenderProgress() + { + return VStack(16, + Header(), + TextBlock("⏳ Processing…") + .FontSize(14) + .Opacity(0.6) + .HAlign(HorizontalAlignment.Center) + ); + } + + private Element RenderAction() + { + return VStack(16, + Header(), + Button(Props.InitialValue ?? "Run", () => Props.OnSubmit?.Invoke("action")) + .HAlign(HorizontalAlignment.Center) + ); + } +} diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index 3285f99a..6f2f15f0 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -44,6 +44,7 @@ + diff --git a/src/OpenClaw.Tray.WinUI/Package.appxmanifest b/src/OpenClaw.Tray.WinUI/Package.appxmanifest index a83348f0..e701fa63 100644 --- a/src/OpenClaw.Tray.WinUI/Package.appxmanifest +++ b/src/OpenClaw.Tray.WinUI/Package.appxmanifest @@ -58,5 +58,6 @@ + diff --git a/src/OpenClaw.Tray.WinUI/Services/DeepLinkHandler.cs b/src/OpenClaw.Tray.WinUI/Services/DeepLinkHandler.cs index 5cef75eb..1361b23c 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DeepLinkHandler.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DeepLinkHandler.cs @@ -180,6 +180,12 @@ public static void Handle(string uri, DeepLinkActions actions) actions.OpenCommandCenter?.Invoke(); break; + case "tray": + case "tray-menu": + case "menu": + actions.OpenTrayMenu?.Invoke(); + break; + case "activity": case "activity-stream": actions.OpenActivityStream?.Invoke(result.Parameters.GetValueOrDefault("filter")); @@ -257,6 +263,7 @@ public class DeepLinkActions public Action? RestartSshTunnel { get; set; } public Action? OpenChat { get; set; } public Action? OpenCommandCenter { get; set; } + public Action? OpenTrayMenu { get; set; } public Action? OpenActivityStream { get; set; } public Action? OpenNotificationHistory { get; set; } public Action? OpenDashboard { get; set; } diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index afcdd28c..59accc1b 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -1077,4 +1077,341 @@ On your gateway host (Mac/Linux), run: No — block it. + + OpenClaw Setup + + + Back + + + Next + + + Finish + + + Welcome to OpenClaw + + + Your AI assistant, right from the system tray + + + Security Notice + + + OpenClaw runs an AI agent that can execute commands, read and write files, and interact with your system on your behalf. + + + Your agent can: + + + Run commands on your computer + + + Read and write files + + + Capture screenshots + + + Choose your Gateway + + + Select how you want to connect to the OpenClaw gateway + + + This PC (Local) + + + Remote Gateway + + + Configure Later + + + All Set! + + + OpenClaw is ready to go. Here are some things to try: + + + Open menu bar panel + + + Connect messaging channels + + + Try Voice Wake + + + Use Canvas + + + Enable Skills + + + Launch OpenClaw at startup + + + You can configure your gateway connection anytime from the tray menu. + + + Make sure your remote gateway is running and accessible. + + + Let's get you connected in just a few steps. + + + Setup Code + + + Click to paste setup code + + + Gateway URL + + + Token + + + Paste bootstrap token + + + Node Mode + + + Test Connection + + + Ready to connect + + + You can configure the gateway connection later from the tray menu. + + + Setup code decoded + + + Token is required + + + Connecting… + + + Connected to gateway + + + Device needs approval + + + Token mismatch — check your bootstrap token + + + Connection timed out (15s) — verify gateway is running + + + Grant Permissions + + + OpenClaw works best when it can send notifications, access your camera and microphone, capture your screen, and know your location. Grant permissions below. + + + Refresh Status + + + Open Settings + + + Checking permissions… + + + Meet your Agent + + + Loading chat... + + + Configuring Gateway + + + Continue + + + Skip + + + Gateway configuration complete! + + + Gateway wizard not available + + + The gateway provides dynamic setup steps. These will run when the connection is established. + + + Notifications + + + Camera + + + Microphone + + + Screen Capture + + + Location (optional) + + + Enabled + + + Enabled (default) + + + Disabled + + + Disabled in app manifest + + + Disabled by policy + + + Disabled — open Settings to enable + + + Allowed + + + Denied — open Settings to allow + + + Denied by system policy + + + Not determined — open Settings + + + Unable to check + + + No camera detected + + + No microphone detected + + + Available — uses picker per capture + + + Not supported on this device + + + Location services disabled system-wide + + + Location disabled for this user + + + Location services enabled + + + Access from system tray + + + Settings → Channels + + + Wake with your voice + + + Visual workspace + + + Settings → Skills + + + Origin not allowed — gateway rejects this connection + + + Rate limited — wait a moment and try again + + + Run on your gateway: + + + Click to copy + + + Copy failed — click to retry + + + Copied! + + + Connection failed — check URL and token, then try again + + + An error occurred processing this step + + + Detecting gateway... + + + Gateway detected on dev port + + + Authenticating... + + + WSL Gateway + + + SSH Tunnel + + + Paste + + + QR + + + Import QR image + + + Could not decode setup code from this image + + + OpenClaw will create a managed SSH tunnel to forward the gateway port from the remote host to this PC. + + + SSH User + + + SSH Host + + + Remote Gateway Port + + + Local Forward Port + + + Managed tunnel: + + + Enter a valid SSH user before connecting. + + + Enter a valid SSH host (e.g., mac-studio.local) before connecting. + + + Detected: {0} + + + You can configure the gateway later in Settings. + + diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index e33c5756..c592a252 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -1076,4 +1076,341 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : Non — la bloquer. + + Configuration d'OpenClaw + + + Retour + + + Suivant + + + Terminer + + + Bienvenue dans OpenClaw + + + Votre assistant IA, directement depuis la barre des tâches + + + Avis de sécurité + + + OpenClaw exécute un agent IA capable d'exécuter des commandes, de lire et écrire des fichiers, et d'interagir avec votre système en votre nom. + + + Votre agent peut : + + + Exécuter des commandes sur votre ordinateur + + + Lire et écrire des fichiers + + + Capturer des captures d'écran + + + Choisissez votre passerelle + + + Sélectionnez comment vous souhaitez vous connecter à la passerelle OpenClaw + + + Cet ordinateur (local) + + + Passerelle distante + + + Configurer plus tard + + + Tout est prêt ! + + + OpenClaw est prêt. Voici quelques fonctionnalités à essayer : + + + Ouvrir le panneau de la barre de menus + + + Connecter des canaux de messagerie + + + Essayer l'activation vocale + + + Utiliser le canevas + + + Activer les compétences + + + Lancer OpenClaw au démarrage + + + Vous pouvez configurer votre connexion à la passerelle à tout moment depuis le menu de la barre des tâches. + + + Assurez-vous que votre passerelle distante est en cours d'exécution et accessible. + + + Connectons-nous en quelques étapes. + + + Code de configuration + + + Cliquez pour coller le code de configuration + + + URL de la passerelle + + + Token + + + Collez le token d'amorçage + + + Node Mode + + + Tester la connexion + + + Prêt à se connecter + + + Vous pouvez configurer la connexion à la passerelle ultérieurement depuis le menu de la barre des tâches. + + + Code de configuration décodé + + + Le token est requis + + + Connexion en cours… + + + Connecté à la passerelle + + + L'appareil nécessite une approbation + + + Token incorrect — vérifiez votre token d'amorçage + + + Connexion expirée (15 s) — vérifiez que la passerelle est en cours d'exécution + + + Accorder les autorisations + + + OpenClaw fonctionne mieux lorsqu'il peut envoyer des notifications, accéder à votre caméra et microphone, capturer votre écran et connaître votre position. Accordez les autorisations ci-dessous. + + + Actualiser le statut + + + Ouvrir les paramètres + + + Vérification des autorisations… + + + Rencontrez votre agent + + + Chargement du chat… + + + Configuration de la passerelle + + + Continuer + + + Passer + + + Configuration de la passerelle terminée ! + + + Assistant de configuration non disponible + + + La passerelle fournit des étapes de configuration dynamiques. Celles-ci s'exécuteront une fois la connexion établie. + + + Notifications + + + Caméra + + + Microphone + + + Capture d'écran + + + Localisation (optionnel) + + + Activé + + + Activé (par défaut) + + + Désactivé + + + Désactivé dans le manifeste + + + Désactivé par la stratégie + + + Désactivé — ouvrir les paramètres pour activer + + + Autorisé + + + Refusé — ouvrir les paramètres pour autoriser + + + Refusé par la stratégie système + + + Non déterminé — ouvrir les paramètres + + + Impossible à vérifier + + + Aucune caméra détectée + + + Aucun microphone détecté + + + Disponible — utilise un sélecteur par capture + + + Non pris en charge sur cet appareil + + + Services de localisation désactivés à l'échelle du système + + + Localisation désactivée pour cet utilisateur + + + Services de localisation activés + + + Accéder depuis la barre des tâches + + + Paramètres → Canaux + + + Réveillez avec votre voix + + + Espace de travail visuel + + + Paramètres → Compétences + + + Origine non autorisée — la passerelle rejette cette connexion + + + Limite de débit atteinte — attendez un moment et réessayez + + + Exécutez sur votre passerelle : + + + Cliquer pour copier + + + Échec de la copie — cliquez pour réessayer + + + Copié ! + + + Échec de la connexion — vérifiez l'URL et le jeton, puis réessayez + + + Une erreur est survenue lors du traitement de cette étape + + + Détection de la passerelle... + + + Passerelle détectée sur le port dev + + + Authentification... + + + Passerelle WSL + + + Tunnel SSH + + + Coller + + + QR + + + Importer une image QR + + + Impossible de décoder le code de configuration de cette image + + + OpenClaw créera un tunnel SSH géré pour transférer le port de la passerelle depuis l'hôte distant vers ce PC. + + + Utilisateur SSH + + + Hôte SSH + + + Port de la passerelle distante + + + Port local de transfert + + + Tunnel géré : + + + Saisissez un utilisateur SSH valide avant de vous connecter. + + + Saisissez un hôte SSH valide (par exemple mac-studio.local) avant de vous connecter. + + + Détecté : {0} + + + Vous pouvez configurer la passerelle plus tard dans les paramètres. + + diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index aa0d694e..443c34b9 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -1077,4 +1077,341 @@ Voer op uw gateway-host (Mac/Linux) uit: Nee — blokkeer hem. + + OpenClaw instellen + + + Terug + + + Volgende + + + Voltooien + + + Welkom bij OpenClaw + + + Uw AI-assistent, direct vanuit het systeemvak + + + Beveiligingsmelding + + + OpenClaw draait een AI-agent die opdrachten kan uitvoeren, bestanden kan lezen en schrijven, en namens u met uw systeem kan communiceren. + + + Uw agent kan: + + + Opdrachten uitvoeren op uw computer + + + Bestanden lezen en schrijven + + + Schermafbeeldingen maken + + + Kies uw gateway + + + Selecteer hoe u verbinding wilt maken met de OpenClaw-gateway + + + Deze pc (lokaal) + + + Externe gateway + + + Later configureren + + + Alles klaar! + + + OpenClaw is klaar voor gebruik. Hier zijn enkele dingen om te proberen: + + + Menubalkpaneel openen + + + Berichtkanalen verbinden + + + Spraakactivering proberen + + + Canvas gebruiken + + + Vaardigheden inschakelen + + + OpenClaw starten bij het opstarten + + + U kunt uw gatewayverbinding op elk moment configureren via het systeemvakmenu. + + + Zorg ervoor dat uw externe gateway actief en bereikbaar is. + + + Laten we u in enkele stappen verbinden. + + + Configuratiecode + + + Klik om configuratiecode te plakken + + + Gateway-URL + + + Token + + + Plak bootstrap-token + + + Node Mode + + + Verbinding testen + + + Klaar om te verbinden + + + U kunt de gatewayverbinding later configureren via het systeemvakmenu. + + + Configuratiecode gedecodeerd + + + Token is vereist + + + Verbinden… + + + Verbonden met gateway + + + Apparaat vereist goedkeuring + + + Token onjuist — controleer uw bootstrap-token + + + Verbinding verlopen (15s) — controleer of de gateway actief is + + + Machtigingen verlenen + + + OpenClaw werkt het beste wanneer het meldingen kan verzenden, toegang heeft tot uw camera en microfoon, uw scherm kan vastleggen en uw locatie kent. Verleen hieronder machtigingen. + + + Status vernieuwen + + + Instellingen openen + + + Machtigingen controleren… + + + Maak kennis met uw agent + + + Chat laden… + + + Gateway configureren + + + Doorgaan + + + Overslaan + + + Gatewayconfiguratie voltooid! + + + Gateway-wizard niet beschikbaar + + + De gateway biedt dynamische configuratiestappen. Deze worden uitgevoerd zodra de verbinding tot stand is gebracht. + + + Meldingen + + + Camera + + + Microfoon + + + Schermopname + + + Locatie (optioneel) + + + Ingeschakeld + + + Ingeschakeld (standaard) + + + Uitgeschakeld + + + Uitgeschakeld in app-manifest + + + Uitgeschakeld door beleid + + + Uitgeschakeld — open Instellingen + + + Toegestaan + + + Geweigerd — open Instellingen + + + Geweigerd door systeembeleid + + + Niet bepaald — open Instellingen + + + Kan niet worden gecontroleerd + + + Geen camera gedetecteerd + + + Geen microfoon gedetecteerd + + + Beschikbaar — gebruikt kiezer per opname + + + Niet ondersteund op dit apparaat + + + Locatieservices uitgeschakeld voor het hele systeem + + + Locatie uitgeschakeld voor deze gebruiker + + + Locatieservices ingeschakeld + + + Toegang via het systeemvak + + + Instellingen → Kanalen + + + Activeer met uw stem + + + Visuele werkruimte + + + Instellingen → Vaardigheden + + + Oorsprong niet toegestaan — gateway weigert deze verbinding + + + Snelheidsbeperking — wacht even en probeer opnieuw + + + Voer uit op uw gateway: + + + Klik om te kopiëren + + + Kopiëren mislukt — klik om opnieuw te proberen + + + Gekopieerd! + + + Verbinding mislukt — controleer URL en token en probeer opnieuw + + + Er is een fout opgetreden bij het verwerken van deze stap + + + Gateway detecteren... + + + Gateway gedetecteerd op dev-poort + + + Authenticeren... + + + WSL-gateway + + + SSH-tunnel + + + Plakken + + + QR + + + QR-afbeelding importeren + + + Kan installatiecode niet decoderen uit deze afbeelding + + + OpenClaw maakt een beheerde SSH-tunnel om de gateway-poort van de externe host naar deze pc door te sturen. + + + SSH-gebruiker + + + SSH-host + + + Externe gatewaypoort + + + Lokale doorstuurpoort + + + Beheerde tunnel: + + + Voer een geldige SSH-gebruiker in voordat u verbinding maakt. + + + Voer een geldige SSH-host in (bijv. mac-studio.local) voordat u verbinding maakt. + + + Gedetecteerd: {0} + + + U kunt de gateway later configureren in Instellingen. + + diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index ee7efb7e..3bd92a3e 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -1077,4 +1077,341 @@ 否 — 阻止该请求。 + + OpenClaw 设置 + + + 上一步 + + + 下一步 + + + 完成 + + + 欢迎使用 OpenClaw + + + 您的 AI 助手,就在系统托盘中 + + + 安全提示 + + + OpenClaw 运行的 AI 代理可以执行命令、读写文件,并代表您与系统进行交互。 + + + 您的代理可以: + + + 在您的计算机上运行命令 + + + 读取和写入文件 + + + 捕获屏幕截图 + + + 选择网关 + + + 选择您希望如何连接到 OpenClaw 网关 + + + 本机(本地) + + + 远程网关 + + + 稍后配置 + + + 一切就绪! + + + OpenClaw 已准备就绪。以下是一些您可以尝试的功能: + + + 打开菜单栏面板 + + + 连接消息频道 + + + 试用语音唤醒 + + + 使用画布 + + + 启用技能 + + + 开机时启动 OpenClaw + + + 您可以随时从托盘菜单配置网关连接。 + + + 请确保您的远程网关正在运行且可以访问。 + + + 只需几步即可完成连接。 + + + 配置码 + + + 点击粘贴配置码 + + + 网关 URL + + + Token + + + 粘贴引导 Token + + + Node Mode + + + 测试连接 + + + 准备连接 + + + 您可以稍后从托盘菜单配置网关连接。 + + + 配置码已解码 + + + Token 不能为空 + + + 正在连接… + + + 已连接到网关 + + + 设备需要审批 + + + Token 不匹配 — 请检查您的引导 Token + + + 连接超时 (15s) — 请确认网关正在运行 + + + 授予权限 + + + 当 OpenClaw 能够发送通知、访问摄像头和麦克风、捕获屏幕及获取位置信息时,它的表现最佳。请在下方授予权限。 + + + 刷新状态 + + + 打开设置 + + + 正在检查权限… + + + 认识您的代理 + + + 正在加载聊天… + + + 配置网关 + + + 继续 + + + 跳过 + + + 网关配置完成! + + + 网关向导不可用 + + + 网关提供动态配置步骤,将在连接建立后执行。 + + + 通知 + + + 相机 + + + 麦克风 + + + 屏幕截图 + + + 位置(可选) + + + 已启用 + + + 已启用(默认) + + + 已禁用 + + + 在应用清单中已禁用 + + + 被策略禁用 + + + 已禁用 — 打开设置以启用 + + + 已允许 + + + 被拒绝 — 打开设置以允许 + + + 被系统策略拒绝 + + + 未确定 — 打开设置 + + + 无法检查 + + + 未检测到相机 + + + 未检测到麦克风 + + + 可用 — 每次截图使用选择器 + + + 此设备不支持 + + + 位置服务已在系统范围内禁用 + + + 此用户的位置已禁用 + + + 位置服务已启用 + + + 从系统托盘访问 + + + 设置 → 频道 + + + 用语音唤醒 + + + 视觉工作区 + + + 设置 → 技能 + + + 来源不允许 — 网关拒绝此连接 + + + 速率限制 — 请稍等后重试 + + + 在您的网关上运行: + + + 点击复制 + + + 复制失败 — 点击重试 + + + 已复制! + + + 连接失败 — 请检查 URL 和令牌,然后重试 + + + 处理此步骤时出错 + + + 正在检测网关... + + + 已在开发端口检测到网关 + + + 正在验证... + + + WSL 网关 + + + SSH 隧道 + + + 粘贴 + + + 二维码 + + + 导入二维码图像 + + + 无法从此图像解码安装代码 + + + OpenClaw 将创建一个托管的 SSH 隧道,以将网关端口从远程主机转发到此电脑。 + + + SSH 用户 + + + SSH 主机 + + + 远程网关端口 + + + 本地转发端口 + + + 托管隧道: + + + 请在连接前输入有效的 SSH 用户。 + + + 请在连接前输入有效的 SSH 主机(例如 mac-studio.local)。 + + + 已检测:{0} + + + 您可以稍后在“设置”中配置网关。 + + diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 8584d291..dd76c719 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -1077,4 +1077,341 @@ 否 — 封鎖此項。 + + OpenClaw 設定 + + + 上一步 + + + 下一步 + + + 完成 + + + 歡迎使用 OpenClaw + + + 您的 AI 助手,就在系統匣中 + + + 安全性通知 + + + OpenClaw 執行的 AI 代理程式可以執行命令、讀寫檔案,並代表您與系統進行互動。 + + + 您的代理程式可以: + + + 在您的電腦上執行命令 + + + 讀取和寫入檔案 + + + 擷取螢幕截圖 + + + 選擇閘道 + + + 選擇您要如何連線到 OpenClaw 閘道 + + + 本機(本地) + + + 遠端閘道 + + + 稍後設定 + + + 一切就緒! + + + OpenClaw 已準備就緒。以下是一些您可以嘗試的功能: + + + 開啟功能表列面板 + + + 連接訊息頻道 + + + 試用語音喚醒 + + + 使用畫布 + + + 啟用技能 + + + 開機時啟動 OpenClaw + + + 您可以隨時從系統匣選單設定閘道連線。 + + + 請確認您的遠端閘道正在執行且可以存取。 + + + 只需幾個步驟即可完成連線。 + + + 設定碼 + + + 點擊貼上設定碼 + + + 閘道 URL + + + Token + + + 貼上引導 Token + + + Node Mode + + + 測試連線 + + + 準備連線 + + + 您可以稍後從系統匣選單設定閘道連線。 + + + 設定碼已解碼 + + + Token 不能為空 + + + 正在連線… + + + 已連線到閘道 + + + 裝置需要核准 + + + Token 不符 — 請檢查您的引導 Token + + + 連線逾時 (15s) — 請確認閘道正在執行 + + + 授予權限 + + + 當 OpenClaw 能夠傳送通知、存取攝影機和麥克風、擷取螢幕及取得位置資訊時,它的表現最佳。請在下方授予權限。 + + + 重新整理狀態 + + + 開啟設定 + + + 正在檢查權限… + + + 認識您的代理程式 + + + 正在載入聊天… + + + 設定閘道 + + + 繼續 + + + 略過 + + + 閘道設定完成! + + + 閘道精靈無法使用 + + + 閘道提供動態設定步驟,將在連線建立後執行。 + + + 通知 + + + 相機 + + + 麥克風 + + + 螢幕擷取 + + + 位置(選用) + + + 已啟用 + + + 已啟用(預設) + + + 已停用 + + + 在應用程式資訊清單中已停用 + + + 被原則停用 + + + 已停用 — 開啟設定以啟用 + + + 已允許 + + + 被拒絕 — 開啟設定以允許 + + + 被系統原則拒絕 + + + 未確定 — 開啟設定 + + + 無法檢查 + + + 未偵測到相機 + + + 未偵測到麥克風 + + + 可用 — 每次擷取使用選擇器 + + + 此裝置不支援 + + + 定位服務已在系統範圍停用 + + + 此使用者的位置已停用 + + + 定位服務已啟用 + + + 從系統匣存取 + + + 設定 → 頻道 + + + 用語音喚醒 + + + 視覺工作區 + + + 設定 → 技能 + + + 來源不允許 — 閘道拒絕此連線 + + + 速率限制 — 請稍候再試 + + + 在您的閘道上執行: + + + 點擊複製 + + + 複製失敗 — 點擊重試 + + + 已複製! + + + 連線失敗 — 請檢查 URL 和權杖,然後重試 + + + 處理此步驟時發生錯誤 + + + 正在偵測閘道... + + + 已在開發連接埠偵測到閘道 + + + 正在驗證... + + + WSL 閘道 + + + SSH 通道 + + + 貼上 + + + QR 碼 + + + 匯入 QR 影像 + + + 無法從此影像解碼設定代碼 + + + OpenClaw 會建立受控 SSH 通道,將閘道連接埠從遠端主機轉送到此電腦。 + + + SSH 使用者 + + + SSH 主機 + + + 遠端閘道連接埠 + + + 本地轉送連接埠 + + + 受控通道: + + + 連線前請輸入有效的 SSH 使用者。 + + + 連線前請輸入有效的 SSH 主機(例如 mac-studio.local)。 + + + 已偵測:{0} + + + 您可以稍後在「設定」中設定閘道。 + + diff --git a/src/OpenClaw.Tray.WinUI/Windows/ActivityStreamWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/ActivityStreamWindow.xaml index e41085fd..7c8185c9 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/ActivityStreamWindow.xaml +++ b/src/OpenClaw.Tray.WinUI/Windows/ActivityStreamWindow.xaml @@ -10,7 +10,7 @@ - + diff --git a/src/OpenClaw.Tray.WinUI/Windows/ActivityStreamWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/ActivityStreamWindow.xaml.cs index 9e578fe0..2a47edee 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/ActivityStreamWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/ActivityStreamWindow.xaml.cs @@ -21,6 +21,7 @@ public sealed partial class ActivityStreamWindow : WindowEx public ActivityStreamWindow(Action openDashboard) { InitializeComponent(); + VisualTestCapture.CaptureOnLoaded(RootGrid, "Activity"); Title = LocalizationHelper.GetString("WindowTitle_ActivityStream"); _openDashboard = openDashboard; diff --git a/src/OpenClaw.Tray.WinUI/Windows/NotificationHistoryWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/NotificationHistoryWindow.xaml index 6061bdbe..5f9aa6d0 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/NotificationHistoryWindow.xaml +++ b/src/OpenClaw.Tray.WinUI/Windows/NotificationHistoryWindow.xaml @@ -10,7 +10,7 @@ - + diff --git a/src/OpenClaw.Tray.WinUI/Windows/NotificationHistoryWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/NotificationHistoryWindow.xaml.cs index 92f88ce4..bd09c064 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/NotificationHistoryWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/NotificationHistoryWindow.xaml.cs @@ -18,6 +18,7 @@ public sealed partial class NotificationHistoryWindow : WindowEx public NotificationHistoryWindow() { InitializeComponent(); + VisualTestCapture.CaptureOnLoaded(RootGrid, "History"); Title = LocalizationHelper.GetString("WindowTitle_NotificationHistory"); // Window configuration diff --git a/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml index e5dbee62..e601254c 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml +++ b/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml @@ -11,7 +11,7 @@ - + diff --git a/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml.cs index 89f0b91b..4ad6ad65 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/SettingsWindow.xaml.cs @@ -33,6 +33,7 @@ public SettingsWindow(SettingsManager settings, Func nodeServicePr _settings = settings; _nodeServiceProvider = nodeServiceProvider; InitializeComponent(); + VisualTestCapture.CaptureOnLoaded(RootGrid, "Settings"); Title = LocalizationHelper.GetString("WindowTitle_Settings"); diff --git a/src/OpenClaw.Tray.WinUI/Windows/StatusDetailWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/StatusDetailWindow.xaml index 717d6e10..cabb7492 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/StatusDetailWindow.xaml +++ b/src/OpenClaw.Tray.WinUI/Windows/StatusDetailWindow.xaml @@ -10,7 +10,7 @@ - + diff --git a/src/OpenClaw.Tray.WinUI/Windows/StatusDetailWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/StatusDetailWindow.xaml.cs index 0b8c5218..f4f12a02 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/StatusDetailWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/StatusDetailWindow.xaml.cs @@ -37,6 +37,7 @@ public StatusDetailWindow(GatewayCommandCenterState state) { _state = state; InitializeComponent(); + VisualTestCapture.CaptureOnLoaded(RootScrollViewer, "CommandCenter"); Title = "Command Center — OpenClaw Tray"; // Window configuration diff --git a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml index 5b693f1a..e5799b62 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml +++ b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml @@ -10,7 +10,7 @@ - + - + diff --git a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs index a2fe3fac..625e26e8 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs @@ -1,4 +1,5 @@ using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; using OpenClaw.Shared; @@ -137,6 +138,9 @@ private TrayMenuWindow(TrayMenuWindow? ownerMenu) private void OnActivated(object sender, WindowActivatedEventArgs args) { + if (Environment.GetEnvironmentVariable("OPENCLAW_UI_AUTOMATION") == "1") + return; + if (args.WindowActivationState == WindowActivationState.Deactivated) { if (_activeFlyoutWindow == null) @@ -201,6 +205,7 @@ public void ShowAtCursor() ApplyRoundedWindowRegion(); Activate(); SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(this)); + _ = VisualTestCapture.CaptureAsync(RootGrid, "TrayMenu"); } private void ShowAdjacentTo(FrameworkElement parentElement) @@ -278,6 +283,7 @@ public void AddMenuItem(string text, string? icon, string action, bool isEnabled Tag = action, CornerRadius = new CornerRadius(4) }; + AutomationProperties.SetAutomationId(button, BuildMenuItemAutomationId(action, text)); if (!isEnabled) content.Opacity = 0.5; @@ -303,6 +309,18 @@ public void AddMenuItem(string text, string? icon, string action, bool isEnabled _itemCount++; } + private static string BuildMenuItemAutomationId(string action, string text) + { + var source = string.IsNullOrWhiteSpace(action) ? text : action; + var chars = source + .Where(char.IsLetterOrDigit) + .Take(48) + .ToArray(); + return chars.Length == 0 + ? "TrayMenuItem" + : "TrayMenuItem" + new string(chars); + } + public void AddFlyoutMenuItem(string text, string? icon, IEnumerable items, bool indent = false) { var content = new TextBlock diff --git a/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml index 9cc4060a..204a5a73 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml +++ b/src/OpenClaw.Tray.WinUI/Windows/WebChatWindow.xaml @@ -10,7 +10,7 @@ - + @@ -54,12 +54,13 @@ HorizontalAlignment="Center" VerticalAlignment="Center"/> - + + Foreground="{ThemeResource SystemFillColorCriticalBrush}"/> + AutomationProperties.AutomationId="WebChatErrorText" + FontFamily="Consolas" Style="{StaticResource CaptionTextBlockStyle}"/>