From 98c7ea5d0d5a479e74cae333eabf5cbfaa2d789b Mon Sep 17 00:00:00 2001 From: AlexAlves87 Date: Wed, 8 Apr 2026 16:34:46 +0200 Subject: [PATCH 001/320] chore: gitignore internal fork workflow files --- .gitignore | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.gitignore b/.gitignore index 6b3d49e7b..b42ef02cc 100644 --- a/.gitignore +++ b/.gitignore @@ -344,3 +344,24 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd Output/ + +# Internal fork workflow docs โ€” never reach upstream PRs +CLAUDE.md +AGENTS.md +STYLE.md +docs/PR_WORKFLOW.md +docs/CONTRIBUTING.md +PLAN_*.md + +# Internal scratch and analysis files +diff.txt +merge-analysis.txt +*.diff +pr*.txt + +# Development diary โ€” private, never reaches upstream +DIARY.md + +# Internal tooling state +.claude/ +.serena/ From bf62a3d57e345c4550af9ca75ffda0dc50d54c42 Mon Sep 17 00:00:00 2001 From: Christine Yan Date: Tue, 5 May 2026 11:48:29 -0400 Subject: [PATCH 002/320] fix: standardize titlebar treatment across all windows - Add ExtendsContentIntoTitleBar + custom titlebar to OnboardingWindow, SetupWizardWindow, and WelcomeDialog to match HubWindow/CanvasWindow - Standardize titlebar height (48px), padding, emoji (FontSize 20), and title text (FontSize 13, CaptionTextBlockStyle) across all windows - CanvasWindow: update height 40->48px, emoji size 14->20, add FontSize 13 - CanvasWindow: move reload button inline next to title in separate grid column for proper click handling within titlebar drag region - Fix OnboardingWindow chat overlay sizing to use contentGrid.SizeChanged instead of rootGrid to avoid double-subtracting titlebar height Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Dialogs/WelcomeDialog.cs | 33 +++++++++++++- .../Onboarding/OnboardingWindow.cs | 44 ++++++++++++++++--- .../Windows/CanvasWindow.xaml | 25 +++++------ .../Windows/SetupWizardWindow.cs | 33 +++++++++++++- 4 files changed, 113 insertions(+), 22 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/WelcomeDialog.cs b/src/OpenClaw.Tray.WinUI/Dialogs/WelcomeDialog.cs index 0a3a69c76..65637e9d1 100644 --- a/src/OpenClaw.Tray.WinUI/Dialogs/WelcomeDialog.cs +++ b/src/OpenClaw.Tray.WinUI/Dialogs/WelcomeDialog.cs @@ -20,6 +20,7 @@ public sealed class WelcomeDialog : WindowEx public WelcomeDialog() { Title = LocalizationHelper.GetString("WindowTitle_Welcome"); + ExtendsContentIntoTitleBar = true; this.SetWindowSize(480, 440); this.CenterOnScreen(); this.SetIcon("Assets\\openclaw.ico"); @@ -123,7 +124,37 @@ public WelcomeDialog() Grid.SetRow(buttonPanel, 2); root.Children.Add(buttonPanel); - Content = root; + // Wrap content with custom titlebar + var outerGrid = new Grid(); + outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(48) }); + outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + var titleBar = new Grid { Padding = new Thickness(16, 0, 140, 0) }; + var titleIcon = new TextBlock + { + Text = "๐Ÿฆž", + FontSize = 20, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 10, 0) + }; + var titleTextBlock = new TextBlock + { + Text = LocalizationHelper.GetString("WindowTitle_Welcome"), + FontSize = 13, + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + VerticalAlignment = VerticalAlignment.Center + }; + var titleStack = new StackPanel { Orientation = Orientation.Horizontal }; + titleStack.Children.Add(titleIcon); + titleStack.Children.Add(titleTextBlock); + titleBar.Children.Add(titleStack); + Grid.SetRow(titleBar, 0); + outerGrid.Children.Add(titleBar); + + Grid.SetRow(root, 1); + outerGrid.Children.Add(root); + Content = outerGrid; + SetTitleBar(titleBar); Closed += (s, e) => _tcs.TrySetResult(_result); diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs index dfa0224aa..9252942fc 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs @@ -55,6 +55,7 @@ public OnboardingWindow(SettingsManager settings) : null; Title = LocalizationHelper.GetString("Onboarding_Title"); + ExtendsContentIntoTitleBar = true; this.SetWindowSize(720, 900); this.CenterOnScreen(); this.SetIcon("Assets\\openclaw.ico"); @@ -99,19 +100,50 @@ public OnboardingWindow(SettingsManager settings) _chatOverlay.Visibility = Visibility.Collapsed; _chatOverlay.VerticalAlignment = VerticalAlignment.Top; - // Root grid: functional UI host fills everything, overlay sits on top (except nav bar) + // Root grid: titlebar row + content area _rootGrid = new Grid { Background = GetThemeBrush("SolidBackgroundFillColorBaseBrush") }; - _rootGrid.Children.Add(_host); - _rootGrid.Children.Add(_chatOverlay); + _rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(48) }); + _rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + // Custom title bar โ€” matches HubWindow treatment + var titleBar = new Grid { Padding = new Thickness(16, 0, 140, 0) }; + var titleIcon = new TextBlock + { + Text = "๐Ÿฆž", + FontSize = 20, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 10, 0) + }; + var titleText = new TextBlock + { + Text = LocalizationHelper.GetString("Onboarding_Title"), + FontSize = 13, + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + VerticalAlignment = VerticalAlignment.Center + }; + var titleStack = new StackPanel { Orientation = Orientation.Horizontal }; + titleStack.Children.Add(titleIcon); + titleStack.Children.Add(titleText); + titleBar.Children.Add(titleStack); + Grid.SetRow(titleBar, 0); + _rootGrid.Children.Add(titleBar); + SetTitleBar(titleBar); + + // Content area + var contentGrid = new Grid(); + contentGrid.Children.Add(_host); + contentGrid.Children.Add(_chatOverlay); + Grid.SetRow(contentGrid, 1); + _rootGrid.Children.Add(contentGrid); 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) => + // Size the overlay after layout โ€” leave space for the nav bar (~84px) + // contentGrid is already in row 1 (below titlebar), so no need to subtract titlebar height + contentGrid.SizeChanged += (_, args) => { _chatOverlay.Height = Math.Max(0, args.NewSize.Height - 84); }; diff --git a/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml index cbcc5eca5..e7050ba1e 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml +++ b/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml @@ -20,29 +20,26 @@ - + - - + + FontSize="13" VerticalAlignment="Center" FontWeight="SemiBold"/> - - - + diff --git a/src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs b/src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs index 08fc4dbbc..28223e5fe 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs @@ -79,6 +79,7 @@ public SetupWizardWindow(SettingsManager settings) _draftEnableNodeMode = settings.EnableNodeMode; Title = LocalizationHelper.GetString("Setup_Title"); + ExtendsContentIntoTitleBar = true; this.SetWindowSize(720, 900); this.CenterOnScreen(); this.SetIcon("Assets\\openclaw.ico"); @@ -370,7 +371,37 @@ public SetupWizardWindow(SettingsManager settings) Grid.SetRow(navPanel, 3); root.Children.Add(navPanel); - Content = root; + // Wrap content in a container with custom titlebar + var outerGrid = new Grid(); + outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(48) }); + outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + var titleBar = new Grid { Padding = new Thickness(16, 0, 140, 0) }; + var titleIcon = new TextBlock + { + Text = "๐Ÿฆž", + FontSize = 20, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 10, 0) + }; + var titleText = new TextBlock + { + Text = LocalizationHelper.GetString("Setup_Title"), + FontSize = 13, + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + VerticalAlignment = VerticalAlignment.Center + }; + var titleStack = new StackPanel { Orientation = Orientation.Horizontal }; + titleStack.Children.Add(titleIcon); + titleStack.Children.Add(titleText); + titleBar.Children.Add(titleStack); + Grid.SetRow(titleBar, 0); + outerGrid.Children.Add(titleBar); + + Grid.SetRow(root, 1); + outerGrid.Children.Add(root); + Content = outerGrid; + SetTitleBar(titleBar); Logger.Info("[Setup] Wizard opened"); // Load device identity for step 3 From f0704907f81b751b67bc89f5816dcc7ddf7e91a4 Mon Sep 17 00:00:00 2001 From: Ranjesh <28935693+ranjeshj@users.noreply.github.com> Date: Tue, 5 May 2026 09:58:28 -0700 Subject: [PATCH 003/320] Remove fake/sample data from 6 UI pages Replace constructor-injected sample data with empty/loading states across Usage, Sessions, Nodes, Channels, Skills, and Cron pages. Skills and Cron APIs were already wired; this removes stale warnings and misleading placeholder data.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Pages/ChannelsPage.xaml.cs | 8 --- src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml | 17 ++---- .../Pages/CronPage.xaml.cs | 53 ------------------- .../Pages/NodesPage.xaml.cs | 20 ------- .../Pages/SessionsPage.xaml.cs | 10 ---- src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml | 9 +--- .../Pages/SkillsPage.xaml.cs | 49 ----------------- src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml | 8 +-- .../Pages/UsagePage.xaml.cs | 15 ------ 9 files changed, 10 insertions(+), 179 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Pages/ChannelsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ChannelsPage.xaml.cs index 61f00ae8e..1a1519525 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ChannelsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ChannelsPage.xaml.cs @@ -17,14 +17,6 @@ public sealed partial class ChannelsPage : Page public ChannelsPage() { InitializeComponent(); - - // Sample data for design-time preview - var samples = new List - { - new() { Name = "Telegram", Status = "connected", StatusColor = "Green", IsRunning = true, ProbeInfo = "Bot: @myclaw_bot ยท 45ms" }, - new() { Name = "WhatsApp", Status = "disconnected", StatusColor = "Red", IsRunning = false, ProbeInfo = null }, - }; - RenderChannels(samples); } public void Initialize(HubWindow hub) diff --git a/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml index ca037a112..8e77251ac 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml @@ -9,13 +9,6 @@ - - - - + - @@ -116,7 +109,7 @@ - diff --git a/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml.cs index 29bb50b2a..c63b65d32 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/CronPage.xaml.cs @@ -12,7 +12,6 @@ namespace OpenClawTray.Pages; public sealed partial class CronPage : Page { private HubWindow? _hub; - private bool _hasLiveData; public CronPage() { @@ -24,57 +23,9 @@ public void Initialize(HubWindow hub) _hub = hub; if (hub.GatewayClient != null) { - NotWiredInfoBar.IsOpen = false; _ = hub.GatewayClient.RequestCronListAsync(); _ = hub.GatewayClient.RequestCronStatusAsync(); } - LoadSampleJobs(); - } - - private void LoadSampleJobs() - { - if (_hasLiveData) return; - - var jobs = new List - { - new() - { - Id = "sample-1", - Name = "Daily email summary", - Schedule = "0 9 * * *", - IsEnabled = true, - LastRunTime = DateTime.Now.AddHours(-3).ToString("yyyy-MM-dd HH:mm"), - LastResult = "success", - ResultBadgeBackground = new SolidColorBrush(Colors.Green), - NextRunTime = DateTime.Now.AddHours(21).ToString("yyyy-MM-dd HH:mm"), - }, - new() - { - Id = "sample-2", - Name = "Backup config", - Schedule = "0 0 * * 0", - IsEnabled = true, - LastRunTime = DateTime.Now.AddDays(-3).ToString("yyyy-MM-dd HH:mm"), - LastResult = "success", - ResultBadgeBackground = new SolidColorBrush(Colors.Green), - NextRunTime = DateTime.Now.AddDays(4).ToString("yyyy-MM-dd HH:mm"), - }, - new() - { - Id = "sample-3", - Name = "Health check", - Schedule = "*/15 * * * *", - IsEnabled = true, - LastRunTime = DateTime.Now.AddMinutes(-7).ToString("yyyy-MM-dd HH:mm"), - LastResult = "fail", - ResultBadgeBackground = new SolidColorBrush(Colors.Red), - NextRunTime = DateTime.Now.AddMinutes(8).ToString("yyyy-MM-dd HH:mm"), - }, - }; - - JobsList.ItemsSource = jobs; - JobsList.Visibility = Visibility.Visible; - EmptyState.Visibility = Visibility.Collapsed; } private void OnRunNowClick(object sender, RoutedEventArgs e) @@ -167,9 +118,6 @@ private void ParseCronList(JsonElement payload) DispatcherQueue?.TryEnqueue(() => { - _hasLiveData = true; - NotWiredInfoBar.IsOpen = false; - if (jobs.Count > 0) { JobsList.ItemsSource = jobs; @@ -205,7 +153,6 @@ private void ParseCronStatus(JsonElement payload) DispatcherQueue?.TryEnqueue(() => { - NotWiredInfoBar.IsOpen = false; SchedulerToggle.IsOn = enabled; SchedulerStatusText.Text = enabled ? "Enabled" : "Disabled"; SchedulerStatusIndicator.Fill = new SolidColorBrush(enabled ? Colors.LimeGreen : Colors.Gray); diff --git a/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs index e6a54102d..032cb7a9c 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs @@ -19,26 +19,6 @@ public sealed partial class NodesPage : Page public NodesPage() { InitializeComponent(); - - // Sample data - var samples = new List - { - new() - { - Name = "Desktop-PC", DeviceId = "a1b2c3d4e5f6g7h8i9j0", - Platform = "windows", IsOnline = true, - Capabilities = new[] { "canvas", "camera", "screen", "system", "clipboard", "browser" }, - Commands = new[] { "screenshot", "open-url", "run-command", "read-clipboard", "write-clipboard" }, - }, - new() - { - Name = "MacBook-Pro", DeviceId = "z9y8x7w6v5u4t3s2r1q0", - Platform = "macos", IsOnline = false, - Capabilities = new[] { "canvas", "screen", "system" }, - Commands = new[] { "screenshot", "open-url" }, - }, - }; - RenderNodes(samples); } public void Initialize(HubWindow hub) diff --git a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs index 8875285a6..0fee428df 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml.cs @@ -15,16 +15,6 @@ public sealed partial class SessionsPage : Page public SessionsPage() { InitializeComponent(); - - // Sample data for design-time preview - var samples = new List - { - new() { Key = "agent:main", Preview = "Help me refactor the authentication module to use JWT tokens...", TimeAgo = "2m ago", ThinkingLevel = "medium", VerboseLevel = null, IsActive = true }, - new() { Key = "agent:cron:daily-summary", Preview = "Generated daily summary for 3 channels with 47 messages.", TimeAgo = "1h ago", ThinkingLevel = null, VerboseLevel = "detailed", IsActive = false }, - new() { Key = "telegram:user:12345", Preview = "Remind me to check the deployment status at 5pm today.", TimeAgo = "15m ago", ThinkingLevel = null, VerboseLevel = null, IsActive = true }, - }; - SessionListView.ItemsSource = samples; - EmptyState.Visibility = Visibility.Collapsed; } public void Initialize(HubWindow hub) diff --git a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml index 190c18f91..64055a5cc 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml @@ -15,13 +15,6 @@ - - - @@ -74,7 +67,7 @@ - diff --git a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs index 322f9e379..1c5f791b6 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs @@ -11,7 +11,6 @@ namespace OpenClawTray.Pages; public sealed partial class SkillsPage : Page { private HubWindow? _hub; - private bool _hasLiveData; public SkillsPage() { @@ -24,10 +23,8 @@ public void Initialize(HubWindow hub) PopulateAgentFilter(hub); if (hub.GatewayClient != null) { - NotWiredInfoBar.IsOpen = false; _ = hub.GatewayClient.RequestSkillsStatusAsync(GetSelectedAgentId()); } - LoadSampleSkills(); } private void PopulateAgentFilter(HubWindow hub) @@ -58,49 +55,6 @@ private void OnAgentFilterChanged(object sender, SelectionChangedEventArgs e) _ = client.RequestSkillsStatusAsync(GetSelectedAgentId()); } - private void LoadSampleSkills() - { - if (_hasLiveData) return; - - var skills = new List - { - new() - { - Id = "github", - Name = "GitHub Integration", - Version = "v2.1", - Description = "Connect OpenClaw to GitHub for issue tracking, PR reviews, and repository management.", - StatusText = "Active", - StatusBackground = new SolidColorBrush(Colors.Green), - ActionLabel = "Update", - }, - new() - { - Id = "email", - Name = "Email Digest", - Version = "v1.3", - Description = "Automatically summarize and send email digests of daily activity and session outcomes.", - StatusText = "Active", - StatusBackground = new SolidColorBrush(Colors.Green), - ActionLabel = "Update", - }, - new() - { - Id = "calendar", - Name = "Calendar Sync", - Version = "v0.9", - Description = "Sync scheduled tasks and cron jobs with your calendar provider for visibility.", - StatusText = "Inactive", - StatusBackground = new SolidColorBrush(Colors.Gray), - ActionLabel = "Enable", - }, - }; - - SkillsList.ItemsSource = skills; - SkillsList.Visibility = Visibility.Visible; - EmptyState.Visibility = Visibility.Collapsed; - } - private void OnSkillActionClick(object sender, RoutedEventArgs e) { var skillId = (sender as Button)?.Tag as string; @@ -165,9 +119,6 @@ public void UpdateFromGateway(JsonElement data) DispatcherQueue?.TryEnqueue(() => { - _hasLiveData = true; - NotWiredInfoBar.IsOpen = false; - if (skills.Count > 0) { SkillsList.ItemsSource = skills; diff --git a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml index c7a789e53..aae2a52a5 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml @@ -21,7 +21,7 @@ - @@ -34,7 +34,7 @@ - + @@ -44,7 +44,7 @@ - + @@ -54,7 +54,7 @@ - + diff --git a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs index 7af75d9a1..dd3375c55 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/UsagePage.xaml.cs @@ -16,21 +16,6 @@ public sealed partial class UsagePage : Page public UsagePage() { InitializeComponent(); - - // Sample data - ProviderListView.ItemsSource = new List - { - new() { Name = "OpenAI", Requests = "87 req", Tokens = "182.3K tok", Cost = "$1.24" }, - new() { Name = "Anthropic", Requests = "41 req", Tokens = "78.1K tok", Cost = "$0.89" }, - new() { Name = "Google", Requests = "14 req", Tokens = "24.1K tok", Cost = "$0.28" }, - }; - - var today = DateTime.Today; - DailyListView.ItemsSource = Enumerable.Range(0, 7).Select(i => new DailyRow - { - Date = today.AddDays(-i).ToString("ddd, MMM d"), - Cost = $"${(0.15 + i * 0.12 + (i % 3) * 0.18):F2}", - }).ToList(); } public void Initialize(HubWindow hub) From a1ef5e67f39dba62f14048c74a5deebb766ce290 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 10:18:58 -0700 Subject: [PATCH 004/320] Fix canvas jsonl path check on non-Windows Skip the handle-resolved-path containment check when GetFinalPathFromHandle returns an empty value on non-Windows, while preserving the earlier symlink-resolution guard.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Shared/Capabilities/CanvasCapability.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Shared/Capabilities/CanvasCapability.cs b/src/OpenClaw.Shared/Capabilities/CanvasCapability.cs index b093bd439..d31ef54d0 100644 --- a/src/OpenClaw.Shared/Capabilities/CanvasCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/CanvasCapability.cs @@ -404,8 +404,10 @@ private string ReadValidatedJsonlPath(string jsonlPath, string command) } using var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read); + // GetFinalPathFromHandle is a Windows-only guard (returns "" on non-Windows); skip the + // containment check when no resolved path is available โ€” prior symlink resolution covers that case. var finalPath = GetFinalPathFromHandle(stream.SafeFileHandle); - if (!IsPathWithinRoot(finalPath, tempRoot)) + if (!string.IsNullOrEmpty(finalPath) && !IsPathWithinRoot(finalPath, tempRoot)) { Logger.Warn($"{command}: jsonlPath file handle resolves outside temp directory: {finalPath}"); throw new InvalidOperationException("jsonlPath must resolve within the system temp directory"); From 1615554ba33794e6fb7216b52818d7f6aeb7965c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 10:20:30 -0700 Subject: [PATCH 005/320] Skip DPAPI settings test off Windows Add a WindowsFactAttribute for Windows-only tray tests and use it for the DPAPI settings-secret test so non-Windows runs skip the unsupported API cleanly.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SettingsRoundTripTests.cs | 2 +- .../WindowsFactAttribute.cs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 tests/OpenClaw.Tray.Tests/WindowsFactAttribute.cs diff --git a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs index 6181bb344..f1996b3cb 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs @@ -215,7 +215,7 @@ public void InvalidJson_ReturnsNull() Assert.Null(SettingsData.FromJson("not json at all")); } - [Fact] + [WindowsFact] public void SettingsManager_ProtectsElevenLabsApiKeyForStorage() { var protectedValue = SettingsManager.ProtectSettingSecret("elevenlabs-key"); diff --git a/tests/OpenClaw.Tray.Tests/WindowsFactAttribute.cs b/tests/OpenClaw.Tray.Tests/WindowsFactAttribute.cs new file mode 100644 index 000000000..7b7614936 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/WindowsFactAttribute.cs @@ -0,0 +1,19 @@ +using Xunit; + +namespace OpenClaw.Tray.Tests; + +/// +/// Marks a test that can only run on Windows (e.g. tests that exercise +/// Windows Data Protection API, NTFS reparse points, or other Win32 surfaces). +/// The test is automatically skipped on non-Windows platforms. +/// +public sealed class WindowsFactAttribute : FactAttribute +{ + public WindowsFactAttribute() + { + if (!OperatingSystem.IsWindows()) + { + Skip = "Windows-only: requires a Windows platform API."; + } + } +} From 3b5c60e93ab0cbf1134bcb4143ababe752578ed3 Mon Sep 17 00:00:00 2001 From: Christine Yan Date: Tue, 5 May 2026 13:24:17 -0400 Subject: [PATCH 006/320] Add accessibility metadata to Canvas titlebar reload button Add AutomationId (CanvasTitlebarReloadButton) and accessible name (Reload Canvas) to the icon-only reload button in the Canvas window titlebar. This enables UI automation discovery and screen reader announcement for the button. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml index e7050ba1e..3e372f1e3 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml +++ b/src/OpenClaw.Tray.WinUI/Windows/CanvasWindow.xaml @@ -33,6 +33,8 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenClaw.Tray.WinUI/Windows/VoiceOverlayWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/VoiceOverlayWindow.xaml.cs new file mode 100644 index 000000000..e18feb785 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Windows/VoiceOverlayWindow.xaml.cs @@ -0,0 +1,345 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using OpenClaw.Shared; +using OpenClaw.Shared.Audio; +using OpenClawTray.Helpers; +using OpenClawTray.Services; +using WinUIEx; + +namespace OpenClawTray.Windows; + +/// +/// Floating voice overlay window for voice chat sessions. +/// Shows conversation transcript, audio levels, and controls. +/// +public sealed partial class VoiceOverlayWindow : WindowEx +{ + private readonly VoiceService _voiceService; + private readonly IOpenClawLogger _logger; + private readonly DispatcherQueue _dispatcherQueue; + private bool _isMuted; + + /// Fired when the user submits transcribed text to the agent. + public event Action? TextSubmitted; + + /// Fired when the user clicks the Settings button. Hosts should + /// navigate to the Voice & Audio page (e.g. via ShowHub("voice")). + public event Action? SettingsRequested; + + public VoiceOverlayWindow(VoiceService voiceService, IOpenClawLogger logger) + { + InitializeComponent(); + _voiceService = voiceService; + _logger = logger; + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + + // Modern custom title bar + ExtendsContentIntoTitleBar = true; + SetTitleBar(AppTitleBar); + + _voiceService.TranscriptionReceived += OnTranscriptionReceived; + _voiceService.UtteranceCompleted += OnUtteranceCompleted; + _voiceService.SpeakingChanged += OnSpeakingChanged; + _voiceService.AudioLevelChanged += OnAudioLevelChanged; + _voiceService.ModeChanged += OnModeChanged; + _voiceService.PipelineStateChanged += OnPipelineStateChanged; + _voiceService.DiagnosticMessage += OnDiagnosticMessage; + + Closed += WindowClosed; + UpdateUI(); + } + + private DateTime _lastUserBubbleTime = DateTime.MinValue; + private TextBlock? _lastUserTextBlock; + + private void OnTranscriptionReceived(string text) + { + _dispatcherQueue.TryEnqueue(() => + { + // Per-segment bubble update (visual streaming). Consolidate into + // the last user bubble when fragments arrive within 5 seconds so + // a multi-segment utterance reads as one bubble in the transcript. + var elapsed = DateTime.UtcNow - _lastUserBubbleTime; + if (_lastUserTextBlock != null && elapsed.TotalSeconds < 5) + { + _lastUserTextBlock.Text += " " + text; + _lastUserBubbleTime = DateTime.UtcNow; + try + { + TranscriptScroller.UpdateLayout(); + TranscriptScroller.ChangeView(null, TranscriptScroller.ScrollableHeight, null); + } + catch { } + } + else + { + AddTranscriptBubble(text, isUser: true); + } + // NOTE: chat submission moved to OnUtteranceCompleted so the + // gateway receives one message per spoken utterance, not one per + // Whisper segment. + }); + } + + private void OnUtteranceCompleted(OpenClaw.Shared.Audio.UtteranceResult utterance) + { + // Fire once per silence-bounded utterance. The visual bubble already + // shows the streamed text; here we just hand the complete sentence + // to the gateway exactly once. + _dispatcherQueue.TryEnqueue(() => + { + if (!string.IsNullOrWhiteSpace(utterance.Text)) + TextSubmitted?.Invoke(utterance.Text); + }); + } + + /// Add an agent response to the transcript. + public void AddAgentResponse(string text) + { + _dispatcherQueue.TryEnqueue(() => + { + AddTranscriptBubble(text, isUser: false); + }); + } + + private void AddTranscriptBubble(string text, bool isUser) + { + try + { + // Hide empty state on first message + if (EmptyState.Visibility == Visibility.Visible) + EmptyState.Visibility = Visibility.Collapsed; + + var bubble = new Border + { + Background = isUser + ? new SolidColorBrush(Microsoft.UI.Colors.DodgerBlue) + : (Brush)Application.Current.Resources["CardBackgroundFillColorDefaultBrush"], + CornerRadius = isUser + ? new CornerRadius(12, 12, 4, 12) + : new CornerRadius(12, 12, 12, 4), + Padding = new Thickness(12, 10, 12, 10), + HorizontalAlignment = isUser + ? HorizontalAlignment.Right + : HorizontalAlignment.Left, + Margin = new Thickness(isUser ? 24 : 0, 4, isUser ? 0 : 24, 4) + }; + + var icon = isUser ? "\uE77B" : "\uE799"; // Person / Robot + var grid = new Grid { ColumnSpacing = 8 }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var fontIcon = new FontIcon { Glyph = icon, FontSize = 12, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 3, 0, 0) }; + Grid.SetColumn(fontIcon, 0); + grid.Children.Add(fontIcon); + + var textBlock = new TextBlock + { + Text = text, + TextWrapping = TextWrapping.Wrap, + FontSize = 13, + IsTextSelectionEnabled = true + }; + if (isUser) + { + textBlock.Foreground = new SolidColorBrush(Microsoft.UI.Colors.White); + _lastUserTextBlock = textBlock; + _lastUserBubbleTime = DateTime.UtcNow; + } + else + { + // Agent response breaks the consolidation window + _lastUserTextBlock = null; + } + Grid.SetColumn(textBlock, 1); + grid.Children.Add(textBlock); + + bubble.Child = grid; + TranscriptPanel.Children.Add(bubble); + + // Auto-scroll to bottom + TranscriptScroller.UpdateLayout(); + TranscriptScroller.ChangeView(null, TranscriptScroller.ScrollableHeight, null); + } + catch (Exception ex) + { + _logger.Error("Failed to add transcript bubble", ex); + } + } + + private static string L(string key) => LocalizationHelper.GetString(key); + private static string Lf(string key, params object?[] args) => + string.Format(CultureInfo.CurrentCulture, LocalizationHelper.GetString(key), args); + + private void OnSpeakingChanged(bool isSpeaking) + { + _dispatcherQueue.TryEnqueue(() => + { + StatusText.Text = isSpeaking + ? L("VoiceOverlayWindow_StatusListening") + : L("VoiceOverlayWindow_StatusSpeakNow"); + }); + } + + private void OnAudioLevelChanged(float level) + { + _dispatcherQueue.TryEnqueue(() => + { + // Scale the level bar width (max width = parent width) + var maxWidth = AudioLevelBar.Parent is FrameworkElement parent ? parent.ActualWidth : 300; + AudioLevelBar.Width = Math.Max(0, level * maxWidth); + }); + } + + private void OnModeChanged(VoiceMode mode) + { + _dispatcherQueue.TryEnqueue(UpdateUI); + } + + private void OnDiagnosticMessage(string message) + { + _dispatcherQueue.TryEnqueue(() => + { + StatusText.Text = message; + }); + } + + private void OnPipelineStateChanged(AudioPipelineState state) + { + _dispatcherQueue.TryEnqueue(() => + { + StatusBadge.Text = state switch + { + AudioPipelineState.Stopped => L("VoiceOverlayWindow_BadgeStopped"), + AudioPipelineState.Starting => L("VoiceOverlayWindow_BadgeStartingDots"), + AudioPipelineState.Listening => L("VoiceOverlayWindow_BadgeListening"), + AudioPipelineState.Processing => L("VoiceOverlayWindow_BadgeProcessing"), + AudioPipelineState.Error => L("VoiceOverlayWindow_StateError"), + _ => L("VoiceOverlayWindow_BadgeUnknown") + }; + + StatusText.Text = state switch + { + AudioPipelineState.Stopped => L("VoiceOverlayWindow_StatusReadyMessage"), + AudioPipelineState.Starting => L("VoiceOverlayWindow_StatusInitMic"), + AudioPipelineState.Listening => L("VoiceOverlayWindow_StatusSpeakNow"), + AudioPipelineState.Processing => L("VoiceOverlayWindow_StatusTranscribing"), + AudioPipelineState.Error => L("VoiceOverlayWindow_StatusErrorOccurred"), + _ => "" + }; + }); + } + + private void UpdateUI() + { + var isActive = _voiceService.CurrentMode != VoiceMode.Inactive; + + StartStopIcon.Glyph = isActive ? "\uE71A" : "\uE768"; // Stop / Play + StartStopText.Text = isActive + ? L("VoiceOverlayWindow_StopText") + : L("VoiceOverlayWindow_ButtonStartListening"); + MuteButton.IsEnabled = isActive; + + if (!isActive) + { + StatusBadge.Text = L("VoiceOverlayWindow_BadgeReady"); + StatusText.Text = L("VoiceOverlayWindow_StatusReadyMessage"); + AudioLevelBar.Width = 0; + } + } + + private async void OnStartStopClick(object sender, RoutedEventArgs e) + { + try + { + if (_voiceService.CurrentMode == VoiceMode.Inactive) + { + StatusText.Text = L("VoiceOverlayWindow_StateInitializing"); + StatusBadge.Text = L("VoiceOverlayWindow_StateStarting"); + StartStopButton.IsEnabled = false; + + // Initialize models if needed (may trigger downloads) + if (!_voiceService.IsModelLoaded) + { + if (!_voiceService.IsModelDownloaded) + { + StatusText.Text = L("VoiceOverlayWindow_StateDownloadingModel"); + var progress = new Progress<(long downloaded, long total)>(p => + { + _dispatcherQueue.TryEnqueue(() => + { + var pct = p.total > 0 ? (int)(p.downloaded * 100 / p.total) : 0; + StatusText.Text = Lf("VoiceOverlayWindow_StateDownloadingPct", pct); + }); + }); + await _voiceService.DownloadModelAsync(progress: progress); + } + + StatusText.Text = L("VoiceOverlayWindow_StateLoadingModel"); + await _voiceService.InitializeAsync(); + } + + StatusText.Text = L("VoiceOverlayWindow_StateStartingMic"); + await _voiceService.StartVoiceChatAsync(); + } + else + { + StatusText.Text = L("VoiceOverlayWindow_StateStopping"); + await _voiceService.StopAsync(); + } + } + catch (Exception ex) + { + _logger.Error("Voice overlay start/stop failed", ex); + // Sanitized โ€” full ex.Message is in the log. + StatusText.Text = L("VoiceOverlayWindow_StatusError"); + StatusBadge.Text = L("VoiceOverlayWindow_StateError"); + } + finally + { + StartStopButton.IsEnabled = true; + UpdateUI(); + } + } + + private async void OnMuteClick(object sender, RoutedEventArgs e) + { + _isMuted = !_isMuted; + MuteIcon.Glyph = _isMuted ? "\uE74F" : "\uE767"; // Muted / Volume + + if (_isMuted) + { + await _voiceService.StopAsync(); + StatusText.Text = L("VoiceOverlayWindow_StatusMuted"); + } + else + { + await _voiceService.StartVoiceChatAsync(); + } + } + + private void OnSettingsClick(object sender, RoutedEventArgs e) + { + SettingsRequested?.Invoke(); + } + + private void WindowClosed(object sender, WindowEventArgs args) + { + _voiceService.TranscriptionReceived -= OnTranscriptionReceived; + _voiceService.UtteranceCompleted -= OnUtteranceCompleted; + _voiceService.SpeakingChanged -= OnSpeakingChanged; + _voiceService.AudioLevelChanged -= OnAudioLevelChanged; + _voiceService.ModeChanged -= OnModeChanged; + _voiceService.PipelineStateChanged -= OnPipelineStateChanged; + _voiceService.DiagnosticMessage -= OnDiagnosticMessage; + + // Stop voice session when window closes + _ = _voiceService.StopAsync(); + } +} diff --git a/src/OpenClaw.WinNode.Cli/skill.md b/src/OpenClaw.WinNode.Cli/skill.md index acb5add96..d7373fa9b 100644 --- a/src/OpenClaw.WinNode.Cli/skill.md +++ b/src/OpenClaw.WinNode.Cli/skill.md @@ -221,6 +221,124 @@ default camera. ``` Returns `{ format, durationMs, base64 }`. +## Speech-to-text (stt.*) + +Local Whisper.net runs on this device โ€” no audio leaves the box. The +model is downloaded on first use; until then every `stt.*` call returns +a clear error pointing the caller at the Voice Settings page. +**Privacy-sensitive: requires `NodeSttEnabled` in tray Settings.** + +### stt.transcribe +Bounded fixed-duration mic capture + transcription. +``` +{ + "maxDurationMs": 5000, // required, > 0, max 30000 + "language": "en" // optional BCP-47 tag or "auto" โ€” falls back to SttLanguage setting +} +``` +Returns `{ transcribed, text, durationMs, language, engineEffective: "whisper" }`. + +### stt.listen +Mic capture with voice-activity detection. Returns when the user stops +speaking or after `timeoutMs`. Result is the full silence-bounded +utterance (all Whisper segments concatenated), not a partial first +segment. +``` +{ + "timeoutMs": 30000, // optional, default 30000, range 1000..120000 + "language": "auto" // optional BCP-47 tag or "auto" +} +``` +Returns `{ text, language, durationMs, segments[{ text, startMs, endMs }], engineEffective: "whisper" }`. + +### stt.status +Engine readiness. No params. Carries no PII (no transcript history, +no language history, no device IDs, no model paths). +Returns `{ engine: "whisper", readiness, modelDownloadProgress, isListenWithVadSupported, isBoundedTranscribeSupported }` +where `readiness` โˆˆ `"ready" | "initializing" | "model-downloading" | "model-not-downloaded" | "unavailable"`. + +## Text-to-speech (tts.*) + +Three providers โ€” Piper (local neural via Sherpa-ONNX, default), Windows +built-in speech, and ElevenLabs (cloud). Provider + per-provider voice +are configured in tray Settings. + +### tts.speak +Speak text aloud on the Windows node. +``` +{ + "text": "string", // required + "provider": "piper|windows|elevenlabs", // optional, falls back to TtsProvider setting + "voiceId": "string", // optional, overrides the per-provider configured voice + "model": "string", // optional, ElevenLabs only + "interrupt": false // default false; true cuts off any in-progress playback +} +``` +Returns `{ spoken, provider, contentType, durationMs }`. + +## App control (app.*) + +Read-only and small write operations targeting the running tray. Used +by the command palette and by automation that wants to drive the UI. + +### app.navigate +Navigate the companion app to a specific page. +``` +{"page": "home|sessions|settings|chat|voice|connection|capabilities|conversations|...""} +``` +Returns `{ navigated, page }`. + +### app.status +Current connection / node state. +No params. Returns `{ connectionStatus, nodeConnected, nodePaired, nodePendingApproval, gatewayVersion, sessionCount, nodeCount }`. + +### app.sessions +Active sessions, optionally filtered by agent. +``` +{"agentId": "string"} // optional +``` +Returns array of `{ Key, Status, Model, AgeText, tokens }`. + +### app.agents +List agents from the connected gateway. No params. Returns the raw +agents JSON array. + +### app.nodes +List connected nodes and their capabilities. No params. Returns array +of `{ DisplayName, NodeId, IsOnline, Platform, CapabilityCount }`. + +### app.config.get +Read gateway configuration value at a dot-path. +``` +{"path": "string"} // optional; omit to fetch the full config tree +``` +Returns the config subtree (or full config) as JSON. + +### app.settings.get +Read a local app setting by name. +``` +{"name": "string"} // required +``` +Returns the setting value (type depends on the setting). + +### app.settings.set +Set a local app setting. +``` +{"name": "string", "value": "string"} // both required +``` +Returns `{ name, value }`. + +### app.menu +Get tray menu state (status, session count, node count). No params. +Returns array of menu items. + +### app.search +Search the command palette and return matching commands. +``` +{"query": "string"} // required +``` +Returns array of `{ Title, Subtitle, Icon }`. + --- ## A2UI v0.8 grammar (for canvas.a2ui.push) diff --git a/tests/OpenClaw.Shared.Tests/AssetHashPinningTests.cs b/tests/OpenClaw.Shared.Tests/AssetHashPinningTests.cs new file mode 100644 index 000000000..5073353d2 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/AssetHashPinningTests.cs @@ -0,0 +1,70 @@ +using System.Text.RegularExpressions; +using OpenClaw.Shared.Audio; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +/// +/// Pre-GA security guard. Every shipped Whisper model and Piper voice MUST +/// have a pinned SHA-256 hash so the runtime can refuse tampered downloads. +/// New entries that forget the hash will fail this test loudly instead of +/// quietly being installable from a compromised source. +/// +/// See WhisperModelManager.AvailableModels / PiperVoiceManager.AvailableVoices +/// and Audio_FollowUps.md ยง2. +/// +public class AssetHashPinningTests +{ + private static readonly Regex Sha256Hex = new("^[0-9a-f]{64}$", RegexOptions.Compiled); + + [Fact] + public void EveryWhisperModel_HasPinnedSha256() + { + Assert.NotEmpty(WhisperModelManager.AvailableModels); + foreach (var m in WhisperModelManager.AvailableModels) + { + Assert.False(string.IsNullOrWhiteSpace(m.Sha256), + $"Whisper model '{m.Name}' is missing a pinned SHA-256 hash. Add one to AvailableModels."); + Assert.Matches(Sha256Hex, m.Sha256!); + } + } + + [Fact] + public void EveryPiperVoice_HasPinnedSha256() + { + Assert.NotEmpty(PiperVoiceManager.AvailableVoices); + foreach (var v in PiperVoiceManager.AvailableVoices) + { + Assert.False(string.IsNullOrWhiteSpace(v.Sha256), + $"Piper voice '{v.VoiceId}' is missing a pinned SHA-256 hash. Add one to AvailableVoices."); + Assert.Matches(Sha256Hex, v.Sha256!); + } + } + + [Fact] + public void EveryWhisperModel_UsesHttpsDownloadUrl() + { + foreach (var m in WhisperModelManager.AvailableModels) + { + Assert.StartsWith("https://", m.DownloadUrl); + } + } + + [Fact] + public void EveryPiperVoice_UsesHttpsDownloadUrl() + { + foreach (var v in PiperVoiceManager.AvailableVoices) + { + Assert.StartsWith("https://", v.DownloadUrl); + } + } + + [Fact] + public void SileroVadModel_HasPinnedSha256() + { + Assert.False(string.IsNullOrWhiteSpace(SileroVadModelManifest.Sha256), + "Silero VAD model is missing a pinned SHA-256 hash. Add one to SileroVadModelManifest."); + Assert.Matches(Sha256Hex, SileroVadModelManifest.Sha256); + Assert.StartsWith("https://", SileroVadModelManifest.DownloadUrl); + } +} diff --git a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs index 0cda62379..f79c25cde 100644 --- a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs @@ -2583,8 +2583,8 @@ public void CanHandle_TtsSpeak() [InlineData(" ELEVENLABS ", "windows", "elevenlabs")] [InlineData(null, "elevenlabs", "elevenlabs")] [InlineData(" ", "elevenlabs", "elevenlabs")] - [InlineData(null, "", "windows")] - [InlineData(null, " ", "windows")] + [InlineData(null, "", "piper")] + [InlineData(null, " ", "piper")] public void ResolveProvider_NormalizesRequestedAndConfiguredValues( string? requestedProvider, string? configuredProvider, @@ -2712,7 +2712,32 @@ public async Task Speak_ReturnsError_WhenHandlerThrows() }); Assert.False(res.Ok); - Assert.Contains("Audio device unavailable", res.Error); + // Privacy: response surfaces a fixed sanitized error; the underlying + // exception text (which can include device names, ElevenLabs key + // fragments from 401 messages, etc.) stays in the local log only. + Assert.Equal("Speak failed", res.Error); + } + + [Fact] + public async Task Speak_HandlerException_DoesNotLeakExceptionMessageIntoError() + { + // Privacy regression: a 401 from ElevenLabs containing a key prefix + // must not bleed into the response error path (and from there into + // recent activity / support bundles). + var cap = new TtsCapability(NullLogger.Instance); + const string sensitive = "ElevenLabs 401: invalid key sk-secret-prefix-do-not-leak"; + cap.SpeakRequested += (_, _) => throw new InvalidOperationException(sensitive); + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "tts-priv", + Command = "tts.speak", + Args = Parse("""{"text":"hello"}""") + }); + + Assert.False(res.Ok); + Assert.DoesNotContain(sensitive, res.Error); + Assert.DoesNotContain("sk-secret-prefix-do-not-leak", res.Error); } [Fact] @@ -2901,3 +2926,567 @@ public async Task ExecuteAsync_ReturnsError_ForUnknownCommand() Assert.Contains("Unknown command", res.Error); } } + +public class SttCapabilityTests +{ + private static JsonElement Parse(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + [Fact] + public void CanHandle_SttTranscribe() + { + var cap = new SttCapability(NullLogger.Instance); + Assert.True(cap.CanHandle("stt.transcribe")); + Assert.True(cap.CanHandle("stt.listen")); + Assert.True(cap.CanHandle("stt.status")); + Assert.False(cap.CanHandle("stt.stream")); + Assert.False(cap.CanHandle("tts.speak")); + Assert.Equal("stt", cap.Category); + Assert.Contains(SttCapability.TranscribeCommand, cap.Commands); + Assert.Contains(SttCapability.ListenCommand, cap.Commands); + Assert.Contains(SttCapability.StatusCommand, cap.Commands); + } + + [Fact] + public void ResolveLanguage_PrefersRequested() + { + Assert.Equal("ja-JP", SttCapability.ResolveLanguage("ja-JP", "en-GB")); + Assert.Equal("en-GB", SttCapability.ResolveLanguage(null, "en-GB")); + Assert.Equal("en-GB", SttCapability.ResolveLanguage(" ", "en-GB")); + Assert.Equal(SttCapability.DefaultLanguage, SttCapability.ResolveLanguage(null, null)); + } + + [Fact] + public void ResolveLanguage_RejectsNonsense() + { + Assert.Null(SttCapability.ResolveLanguage("not a tag", null)); + Assert.Null(SttCapability.ResolveLanguage("english", null)); + Assert.Null(SttCapability.ResolveLanguage("en_US", null)); + } + + [Fact] + public async Task Transcribe_ReturnsError_WhenMaxDurationMissing() + { + var cap = new SttCapability(NullLogger.Instance); + cap.TranscribeRequested += (_, _) => throw new InvalidOperationException("should not be called"); + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "stt1", + Command = "stt.transcribe", + Args = Parse("""{}""") + }); + + Assert.False(res.Ok); + Assert.Contains("Missing required maxDurationMs", res.Error); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-5000)] + public async Task Transcribe_ReturnsError_WhenMaxDurationNotPositive(int maxMs) + { + var cap = new SttCapability(NullLogger.Instance); + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "stt2", + Command = "stt.transcribe", + Args = Parse($$"""{"maxDurationMs":{{maxMs}}}""") + }); + + Assert.False(res.Ok); + Assert.Contains("Missing required maxDurationMs", res.Error); + } + + [Fact] + public async Task Transcribe_ReturnsError_WhenMaxDurationExceedsBound() + { + var cap = new SttCapability(NullLogger.Instance); + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "stt3", + Command = "stt.transcribe", + Args = Parse("""{"maxDurationMs":60000}""") + }); + + Assert.False(res.Ok); + Assert.Contains("exceeds 30000", res.Error); + } + + [Fact] + public async Task Transcribe_ReturnsError_WhenLanguageInvalid() + { + var cap = new SttCapability(NullLogger.Instance); + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "stt4", + Command = "stt.transcribe", + Args = Parse("""{"maxDurationMs":5000,"language":"english please"}""") + }); + + Assert.False(res.Ok); + Assert.Contains("Invalid language tag", res.Error); + } + + [Fact] + public async Task Transcribe_InvalidLanguageError_DoesNotEchoCallerInput() + { + // Privacy regression: caller-supplied language must not be echoed back + // in the error string, since failed-invoke errors land in recent + // activity / support bundles. + var cap = new SttCapability(NullLogger.Instance); + const string secretish = "ZZ-secret-tag-do-not-leak"; + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "stt-priv-lang", + Command = "stt.transcribe", + Args = Parse($$"""{"maxDurationMs":5000,"language":"{{secretish}}"}""") + }); + + Assert.False(res.Ok); + Assert.DoesNotContain(secretish, res.Error); + } + + [Fact] + public async Task Transcribe_HandlerException_DoesNotLeakExceptionMessageIntoError() + { + // Privacy regression: raw handler exception text could surface mic / + // audio-stack details. Response error must be a fixed sanitized + // string; full detail stays in logs. + var cap = new SttCapability(NullLogger.Instance); + const string sensitive = "secret-mic-device-path-or-stack-trace"; + cap.TranscribeRequested += (_, _) => throw new InvalidOperationException(sensitive); + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "stt-priv-ex", + Command = "stt.transcribe", + Args = Parse("""{"maxDurationMs":5000}""") + }); + + Assert.False(res.Ok); + Assert.DoesNotContain(sensitive, res.Error); + } + + [Fact] + public async Task Transcribe_ReturnsError_WhenHandlerNotWired() + { + var cap = new SttCapability(NullLogger.Instance); + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "stt5", + Command = "stt.transcribe", + Args = Parse("""{"maxDurationMs":5000}""") + }); + + Assert.False(res.Ok); + Assert.Contains("not available", res.Error); + } + + [Fact] + public async Task Transcribe_PassesArgsToHandler_AndReturnsPayload() + { + var cap = new SttCapability(NullLogger.Instance); + SttTranscribeArgs? received = null; + cap.TranscribeRequested += (a, _) => + { + received = a; + return Task.FromResult(new SttTranscribeResult + { + Transcribed = true, + Text = "hello", + DurationMs = 4200, + Language = a.Language ?? SttCapability.DefaultLanguage + }); + }; + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "stt6", + Command = "stt.transcribe", + Args = Parse("""{"maxDurationMs":5000,"language":"en-GB"}""") + }); + + Assert.True(res.Ok); + Assert.NotNull(received); + Assert.Equal(5000, received!.MaxDurationMs); + Assert.Equal("en-GB", received.Language); + + var payload = JsonSerializer.SerializeToElement(res.Payload); + Assert.True(payload.GetProperty("transcribed").GetBoolean()); + Assert.Equal("hello", payload.GetProperty("text").GetString()); + Assert.Equal(4200, payload.GetProperty("durationMs").GetInt32()); + Assert.Equal("en-GB", payload.GetProperty("language").GetString()); + } + + [Fact] + public async Task Transcribe_DropsLanguage_WhenOmitted_LettingTrayUseSetting() + { + var cap = new SttCapability(NullLogger.Instance); + SttTranscribeArgs? received = null; + cap.TranscribeRequested += (a, _) => + { + received = a; + return Task.FromResult(new SttTranscribeResult { Transcribed = true, Text = "hi", DurationMs = 100, Language = "en-US" }); + }; + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "stt7", + Command = "stt.transcribe", + Args = Parse("""{"maxDurationMs":1000}""") + }); + + Assert.True(res.Ok); + Assert.Null(received!.Language); + } + + [Fact] + public async Task Transcribe_ReportsHandlerException() + { + var cap = new SttCapability(NullLogger.Instance); + cap.TranscribeRequested += (_, _) => throw new InvalidOperationException("Microphone unavailable."); + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "stt8", + Command = "stt.transcribe", + Args = Parse("""{"maxDurationMs":2000}""") + }); + + Assert.False(res.Ok); + // Privacy: response surfaces a fixed sanitized error; raw exception + // text stays in the local log only. See + // Transcribe_HandlerException_DoesNotLeakExceptionMessageIntoError. + Assert.Equal("Transcribe failed", res.Error); + } + + [Fact] + public async Task Transcribe_ReturnsCanceled_WhenTokenFires() + { + var cap = new SttCapability(NullLogger.Instance); + cap.TranscribeRequested += async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct); + return new SttTranscribeResult(); + }; + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(50)); + + var res = await cap.ExecuteAsync( + new NodeInvokeRequest { Id = "stt9", Command = "stt.transcribe", Args = Parse("""{"maxDurationMs":5000}""") }, + cts.Token); + + Assert.False(res.Ok); + Assert.Contains("canceled", res.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteAsync_ReturnsError_ForUnknownCommand() + { + var cap = new SttCapability(NullLogger.Instance); + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "stt10", + Command = "stt.stream", + Args = Parse("""{}""") + }); + Assert.False(res.Ok); + Assert.Contains("Unknown command", res.Error); + } + + // ============================================================ + // stt.listen (VAD-driven capture) + // ============================================================ + + [Fact] + public async Task Listen_ClampsTimeoutMs_BelowMin() + { + var cap = new SttCapability(NullLogger.Instance); + SttListenArgs? received = null; + cap.ListenRequested += (a, _) => + { + received = a; + return Task.FromResult(new SttListenResult { Text = "x", Language = "auto", DurationMs = 100 }); + }; + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "listen-min", + Command = "stt.listen", + Args = Parse("""{"timeoutMs":50}""") + }); + + Assert.True(res.Ok); + Assert.NotNull(received); + Assert.Equal(SttCapability.MinListenTimeoutMs, received!.TimeoutMs); + } + + [Fact] + public async Task Listen_ClampsTimeoutMs_AboveMax() + { + var cap = new SttCapability(NullLogger.Instance); + SttListenArgs? received = null; + cap.ListenRequested += (a, _) => + { + received = a; + return Task.FromResult(new SttListenResult { Text = "x", Language = "auto", DurationMs = 100 }); + }; + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "listen-max", + Command = "stt.listen", + Args = Parse("""{"timeoutMs":1000000}""") + }); + + Assert.True(res.Ok); + Assert.NotNull(received); + Assert.Equal(SttCapability.MaxListenTimeoutMs, received!.TimeoutMs); + } + + [Fact] + public async Task Listen_DefaultsLanguageToAuto() + { + var cap = new SttCapability(NullLogger.Instance); + SttListenArgs? received = null; + cap.ListenRequested += (a, _) => + { + received = a; + return Task.FromResult(new SttListenResult { Text = "ok", Language = a.Language, DurationMs = 100 }); + }; + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "listen-auto", + Command = "stt.listen", + Args = Parse("""{"timeoutMs":5000}""") + }); + + Assert.True(res.Ok); + Assert.Equal(SttCapability.AutoLanguage, received!.Language); + } + + [Fact] + public async Task Listen_ReturnsError_WhenLanguageInvalid() + { + var cap = new SttCapability(NullLogger.Instance); + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "listen-bad-lang", + Command = "stt.listen", + Args = Parse("""{"timeoutMs":5000,"language":"english please"}""") + }); + + Assert.False(res.Ok); + Assert.Contains("Invalid language tag", res.Error); + } + + [Fact] + public async Task Listen_InvalidLanguageError_DoesNotEchoCallerInput() + { + var cap = new SttCapability(NullLogger.Instance); + const string secretish = "ZZ-secret-tag-do-not-leak"; + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "listen-priv-lang", + Command = "stt.listen", + Args = Parse($$"""{"timeoutMs":5000,"language":"{{secretish}}"}""") + }); + + Assert.False(res.Ok); + Assert.DoesNotContain(secretish, res.Error); + } + + [Fact] + public async Task Listen_ReturnsError_WhenHandlerNotWired() + { + var cap = new SttCapability(NullLogger.Instance); + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "listen-no-handler", + Command = "stt.listen", + Args = Parse("""{"timeoutMs":5000}""") + }); + + Assert.False(res.Ok); + Assert.Contains("not available", res.Error); + } + + [Fact] + public async Task Listen_HandlerException_DoesNotLeakExceptionMessageIntoError() + { + var cap = new SttCapability(NullLogger.Instance); + const string sensitive = "secret-mic-device-path-or-stack-trace"; + cap.ListenRequested += (_, _) => throw new InvalidOperationException(sensitive); + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "listen-priv-ex", + Command = "stt.listen", + Args = Parse("""{"timeoutMs":5000}""") + }); + + Assert.False(res.Ok); + Assert.DoesNotContain(sensitive, res.Error); + Assert.Equal("Listen failed", res.Error); + } + + [Fact] + public async Task Listen_PassesSegmentsAndEngineMetadata() + { + var cap = new SttCapability(NullLogger.Instance); + cap.ListenRequested += (_, _) => Task.FromResult(new SttListenResult + { + Text = "hello world", + Language = "en-US", + DurationMs = 1500, + Segments = new[] + { + new SttSegment { Text = "hello", StartMs = 0, EndMs = 500 }, + new SttSegment { Text = "world", StartMs = 600, EndMs = 1500 }, + }, + EngineEffective = SttCapability.EngineWhisper + }); + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "listen-payload", + Command = "stt.listen", + Args = Parse("""{"timeoutMs":5000,"language":"en-US"}""") + }); + + Assert.True(res.Ok); + // Round-trip through serialization to make sure the response object + // exposes the new fields. + var json = System.Text.Json.JsonSerializer.Serialize(res.Payload); + Assert.Contains("\"text\":\"hello world\"", json); + Assert.Contains("\"engineEffective\":\"whisper\"", json); + Assert.Contains("\"segments\":", json); + } + + [Fact] + public async Task Listen_ReturnsCanceled_WhenTokenFires() + { + var cap = new SttCapability(NullLogger.Instance); + cap.ListenRequested += async (_, ct) => + { + await Task.Delay(Timeout.Infinite, ct); + return new SttListenResult(); + }; + + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(50)); + + var res = await cap.ExecuteAsync( + new NodeInvokeRequest { Id = "listen-cancel", Command = "stt.listen", Args = Parse("""{"timeoutMs":5000}""") }, + cts.Token); + + Assert.False(res.Ok); + Assert.Contains("canceled", res.Error, StringComparison.OrdinalIgnoreCase); + } + + // ============================================================ + // stt.status + // ============================================================ + + [Fact] + public async Task Status_ReturnsError_WhenHandlerNotWired() + { + var cap = new SttCapability(NullLogger.Instance); + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "status-no-handler", + Command = "stt.status", + Args = Parse("""{}""") + }); + + Assert.False(res.Ok); + Assert.Contains("not available", res.Error); + } + + [Fact] + public async Task Status_HandlerException_DoesNotLeakExceptionMessageIntoError() + { + var cap = new SttCapability(NullLogger.Instance); + const string sensitive = "secret-engine-stack-trace"; + cap.StatusRequested += _ => throw new InvalidOperationException(sensitive); + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "status-priv-ex", + Command = "stt.status", + Args = Parse("""{}""") + }); + + Assert.False(res.Ok); + Assert.DoesNotContain(sensitive, res.Error); + Assert.Equal("Status failed", res.Error); + } + + [Fact] + public async Task Status_ReturnsEngineReadiness() + { + var cap = new SttCapability(NullLogger.Instance); + cap.StatusRequested += _ => Task.FromResult(new SttStatusResult + { + Engine = SttCapability.EngineWhisper, + Readiness = "model-downloading", + ModelDownloadProgress = 0.42, + IsListenWithVadSupported = false, + IsBoundedTranscribeSupported = false, + }); + + var res = await cap.ExecuteAsync(new NodeInvokeRequest + { + Id = "status-ok", + Command = "stt.status", + Args = Parse("""{}""") + }); + + Assert.True(res.Ok); + var json = System.Text.Json.JsonSerializer.Serialize(res.Payload); + Assert.Contains("\"engine\":\"whisper\"", json); + Assert.Contains("\"readiness\":\"model-downloading\"", json); + Assert.Contains("\"modelDownloadProgress\":0.42", json); + // No PII fields ever surface in stt.status โ€” even when synthesizing + // a result, callers can only see flat readiness strings + a single + // engine identifier. + Assert.DoesNotContain("language", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("path", json, StringComparison.OrdinalIgnoreCase); + } + + // ============================================================ + // BCP-47 + "auto" sentinel + // ============================================================ + + [Theory] + [InlineData("en-US", "en-US")] + [InlineData("en-GB", "en-GB")] + [InlineData("ja-JP", "ja-JP")] + [InlineData("zh-Hans-CN", "zh-Hans-CN")] + [InlineData(" en-US ", "en-US")] // leading/trailing whitespace trimmed + [InlineData("auto", "auto")] + [InlineData("AUTO", "auto")] // case-insensitive sentinel, normalized to lowercase + [InlineData("Auto", "auto")] + public void NormalizeLanguageTag_AcceptsValid(string input, string expected) + { + Assert.Equal(expected, SttCapability.NormalizeLanguageTag(input)); + } + + [Theory] + [InlineData("english")] + [InlineData("en_US")] // underscore not allowed + [InlineData("not a tag")] + [InlineData("en US")] // space not allowed + [InlineData("automatic")] // not the sentinel + public void NormalizeLanguageTag_RejectsInvalid(string input) + { + Assert.Null(SttCapability.NormalizeLanguageTag(input)); + } +} diff --git a/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs b/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs index 043b8212c..c25614c46 100644 --- a/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs +++ b/tests/OpenClaw.Shared.Tests/McpToolBridgeTests.cs @@ -352,6 +352,118 @@ public async Task UnhandledException_ReturnsGenericInternalError_NotLeakingMessa Assert.DoesNotContain("secret-internal-detail", error.GetProperty("message").GetString()); } + [Fact] + public async Task ToolsList_SttTranscribe_HasCuratedDescription() + { + var caps = new List + { + new FakeCapability("stt", "stt.transcribe"), + }; + var bridge = CreateBridge(caps); + var resp = await bridge.HandleRequestAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list""}"); + + using var doc = JsonDocument.Parse(resp!); + var description = doc.RootElement.GetProperty("result") + .GetProperty("tools")[0] + .GetProperty("description") + .GetString()!; + + // Must mention the key surface area so MCP clients render something useful. + Assert.Contains("microphone", description, System.StringComparison.OrdinalIgnoreCase); + Assert.Contains("maxDurationMs", description); + Assert.Contains("text", description, System.StringComparison.OrdinalIgnoreCase); + // And explicitly NOT the generic stub. + Assert.DoesNotContain("stt capability:", description); + } + + [Fact] + public async Task ToolsList_SttListen_HasCuratedDescription() + { + var caps = new List { new FakeCapability("stt", "stt.listen") }; + var bridge = CreateBridge(caps); + var resp = await bridge.HandleRequestAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list""}"); + + using var doc = JsonDocument.Parse(resp!); + var description = doc.RootElement.GetProperty("result") + .GetProperty("tools")[0] + .GetProperty("description") + .GetString()!; + + Assert.Contains("voice-activity detection", description, System.StringComparison.OrdinalIgnoreCase); + Assert.Contains("timeoutMs", description); + // Privacy: must mention NodeSttEnabled gate so MCP clients + // know this is opt-in. + Assert.Contains("NodeSttEnabled", description); + // Engine surface must be advertised so callers can read engineEffective. + Assert.Contains("engineEffective", description); + Assert.DoesNotContain("stt capability:", description); + } + + [Fact] + public async Task ToolsList_SttStatus_HasCuratedDescription() + { + var caps = new List { new FakeCapability("stt", "stt.status") }; + var bridge = CreateBridge(caps); + var resp = await bridge.HandleRequestAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list""}"); + + using var doc = JsonDocument.Parse(resp!); + var description = doc.RootElement.GetProperty("result") + .GetProperty("tools")[0] + .GetProperty("description") + .GetString()!; + + Assert.Contains("readiness", description, System.StringComparison.OrdinalIgnoreCase); + Assert.Contains("engine", description, System.StringComparison.OrdinalIgnoreCase); + Assert.Contains("whisper", description, System.StringComparison.OrdinalIgnoreCase); + // Privacy invariant in the description itself: no PII. + Assert.Contains("no PII", description, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ToolsList_AllStt_AppearWhenSttCapabilityRegistered() + { + // Single SttCapability instance advertises all three commands. + var caps = new List + { + new FakeCapability("stt", "stt.transcribe", "stt.listen", "stt.status"), + }; + var bridge = CreateBridge(caps); + var resp = await bridge.HandleRequestAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list""}"); + + using var doc = JsonDocument.Parse(resp!); + var toolNames = new HashSet(); + foreach (var t in doc.RootElement.GetProperty("result").GetProperty("tools").EnumerateArray()) + toolNames.Add(t.GetProperty("name").GetString()!); + + Assert.Contains("stt.transcribe", toolNames); + Assert.Contains("stt.listen", toolNames); + Assert.Contains("stt.status", toolNames); + } + + [Fact] + public async Task ToolsList_AllStt_Absent_WhenSttCapabilityNotRegistered() + { + // STT capability is gated by NodeSttEnabled in NodeService; + // when disabled, no SttCapability is constructed and tools/list + // must omit the three stt.* tools. + var caps = new List + { + new FakeCapability("device", "device.status"), + new FakeCapability("tts", "tts.speak"), + }; + var bridge = CreateBridge(caps); + var resp = await bridge.HandleRequestAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/list""}"); + + using var doc = JsonDocument.Parse(resp!); + var toolNames = new HashSet(); + foreach (var t in doc.RootElement.GetProperty("result").GetProperty("tools").EnumerateArray()) + toolNames.Add(t.GetProperty("name").GetString()!); + + Assert.DoesNotContain("stt.transcribe", toolNames); + Assert.DoesNotContain("stt.listen", toolNames); + Assert.DoesNotContain("stt.status", toolNames); + } + [Fact] public async Task Initialize_ReturnsCustomServerNameAndVersion() { diff --git a/tests/OpenClaw.Shared.Tests/ModelsTests.cs b/tests/OpenClaw.Shared.Tests/ModelsTests.cs index ac9ac83fc..ad93fb34d 100644 --- a/tests/OpenClaw.Shared.Tests/ModelsTests.cs +++ b/tests/OpenClaw.Shared.Tests/ModelsTests.cs @@ -1732,4 +1732,34 @@ public void ContextSummaryShort_FormatsSmallNumbers() var session = new SessionInfo { TotalTokens = 500, ContextTokens = 1000 }; Assert.Contains("500/1.0K", session.ContextSummaryShort); } + + [Fact] + public void DangerousCommands_IncludesSttTranscribe() + { + Assert.Contains("stt.transcribe", CommandCenterCommandGroups.DangerousCommands); + Assert.Contains("stt.transcribe", (IReadOnlySet)CommandCenterCommandGroups.DangerousCommandSet); + // stt.listen and stt.status need the same explicit gateway opt-in so + // chat agents see them once NodeSttEnabled is on. Otherwise the + // gateway's Windows platform default policy keeps them hidden. + Assert.Contains("stt.listen", CommandCenterCommandGroups.DangerousCommands); + Assert.Contains("stt.status", CommandCenterCommandGroups.DangerousCommands); + } + + [Fact] + public void MacNodeParityCommands_ExcludesSttTranscribe() + { + // Mac has no equivalent yet; ensure parity diagnostic does not flag + // Windows nodes for "missing" stt.transcribe. + Assert.DoesNotContain("stt.transcribe", CommandCenterCommandGroups.MacNodeParityCommands); + } + + [Fact] + public void CommonDangerousCommands_StillIncludedInMacParity() + { + // Refactor invariant: the original camera/screen dangerous commands + // still appear in Mac parity via the shared CommonDangerousCommands set. + Assert.Contains("camera.snap", CommandCenterCommandGroups.MacNodeParityCommands); + Assert.Contains("camera.clip", CommandCenterCommandGroups.MacNodeParityCommands); + Assert.Contains("screen.record", CommandCenterCommandGroups.MacNodeParityCommands); + } } diff --git a/tests/OpenClaw.Shared.Tests/SingleFlightDownloadTests.cs b/tests/OpenClaw.Shared.Tests/SingleFlightDownloadTests.cs new file mode 100644 index 000000000..3b2e30573 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/SingleFlightDownloadTests.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using OpenClaw.Shared.Audio; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +public sealed class SingleFlightDownloadTests +{ + [Fact] + public async Task ConcurrentCallers_StartOnlyOneSharedOperation() + { + var inFlight = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var started = 0; + + Task Start(CancellationToken _) + { + Interlocked.Increment(ref started); + return release.Task; + } + + var callers = new Task[50]; + for (var i = 0; i < callers.Length; i++) + { + callers[i] = SingleFlightDownload.RunAsync(inFlight, "asset", Start); + } + + await WaitUntilAsync(() => Volatile.Read(ref started) == 1); + release.SetResult(); + await Task.WhenAll(callers); + + Assert.Equal(1, Volatile.Read(ref started)); + await WaitUntilAsync(() => inFlight.IsEmpty); + } + + [Fact] + public async Task CancelingOneWaiter_DoesNotCancelSharedOperation() + { + var inFlight = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var started = 0; + CancellationToken sharedToken = default; + + Task Start(CancellationToken token) + { + sharedToken = token; + Interlocked.Increment(ref started); + return release.Task; + } + + using var callerCts = new CancellationTokenSource(); + var canceledWaiter = SingleFlightDownload.RunAsync(inFlight, "asset", Start, callerCts.Token); + await WaitUntilAsync(() => Volatile.Read(ref started) == 1); + + var continuingWaiter = SingleFlightDownload.RunAsync(inFlight, "asset", Start); + callerCts.Cancel(); + + await Assert.ThrowsAsync(() => canceledWaiter); + Assert.False(sharedToken.CanBeCanceled); + + release.SetResult(); + await continuingWaiter; + + Assert.Equal(1, Volatile.Read(ref started)); + await WaitUntilAsync(() => inFlight.IsEmpty); + } + + [Fact] + public async Task FailedSharedOperation_IsRemovedSoRetryCanStart() + { + var inFlight = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + var attempts = 0; + + Task Start(CancellationToken _) + { + return Interlocked.Increment(ref attempts) == 1 + ? Task.FromException(new InvalidOperationException("first failure")) + : Task.CompletedTask; + } + + var ex = await Assert.ThrowsAsync( + () => SingleFlightDownload.RunAsync(inFlight, "asset", Start)); + Assert.Equal("first failure", ex.Message); + + await WaitUntilAsync(() => inFlight.IsEmpty); + await SingleFlightDownload.RunAsync(inFlight, "asset", Start); + + Assert.Equal(2, Volatile.Read(ref attempts)); + } + + [Fact] + public async Task SynchronousFactoryFailure_IsRemovedSoRetryCanStart() + { + var inFlight = new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + var attempts = 0; + + Task Start(CancellationToken _) + { + if (Interlocked.Increment(ref attempts) == 1) + { + throw new InvalidOperationException("sync failure"); + } + + return Task.CompletedTask; + } + + var ex = await Assert.ThrowsAsync( + () => SingleFlightDownload.RunAsync(inFlight, "asset", Start)); + Assert.Equal("sync failure", ex.Message); + + await WaitUntilAsync(() => inFlight.IsEmpty); + await SingleFlightDownload.RunAsync(inFlight, "asset", Start); + + Assert.Equal(2, Volatile.Read(ref attempts)); + } + + private static async Task WaitUntilAsync(Func condition) + { + for (var i = 0; i < 100; i++) + { + if (condition()) + { + return; + } + + await Task.Delay(10); + } + + Assert.True(condition()); + } +} diff --git a/tests/OpenClaw.Shared.Tests/SpeechToTextLanguageNormalizationTests.cs b/tests/OpenClaw.Shared.Tests/SpeechToTextLanguageNormalizationTests.cs new file mode 100644 index 000000000..971495a90 --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/SpeechToTextLanguageNormalizationTests.cs @@ -0,0 +1,43 @@ +using OpenClaw.Shared.Audio; +using Xunit; + +namespace OpenClaw.Shared.Tests; + +/// +/// SttCapability accepts BCP-47 language tags (the validator + MCP docs +/// both advertise the wider shape like "en-US"), but Whisper.net's +/// WithLanguage call only understands "auto" or 2-letter ISO 639-1 codes. +/// SpeechToTextService.NormalizeForWhisper bridges the gap. These tests +/// pin the normalization rules so a future change can't silently start +/// passing a region-tagged BCP-47 string straight to Whisper. +/// +public class SpeechToTextLanguageNormalizationTests +{ + [Theory] + [InlineData("auto", "auto")] + [InlineData("AUTO", "auto")] + [InlineData("en", "en")] + [InlineData("EN", "en")] + [InlineData("en-US", "en")] + [InlineData("en-us", "en")] + [InlineData("zh-Hans-CN", "zh")] + [InlineData("fr-FR", "fr")] + [InlineData(" ja-JP ", "ja")] + public void NormalizeForWhisper_StripsRegionAndScript(string input, string expected) + { + Assert.Equal(expected, SpeechToTextService.NormalizeForWhisper(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("abc")] // 3-letter โ€” no safe ISO 639-3 cross-walk + [InlineData("e")] // single letter + [InlineData("123-XX")] // numeric primary subtag + [InlineData("en1-US")] // non-letter primary + public void NormalizeForWhisper_FallsBackToAuto_OnInvalid(string? input) + { + Assert.Equal("auto", SpeechToTextService.NormalizeForWhisper(input)); + } +} diff --git a/tests/OpenClaw.Tray.Tests/ActivityStreamServiceTests.cs b/tests/OpenClaw.Tray.Tests/ActivityStreamServiceTests.cs index b14b9c2a7..128571bf6 100644 --- a/tests/OpenClaw.Tray.Tests/ActivityStreamServiceTests.cs +++ b/tests/OpenClaw.Tray.Tests/ActivityStreamServiceTests.cs @@ -2,6 +2,13 @@ namespace OpenClaw.Tray.Tests; +[CollectionDefinition(ActivityStreamServiceCollection.Name, DisableParallelization = true)] +public sealed class ActivityStreamServiceCollection +{ + public const string Name = "ActivityStreamService"; +} + +[Collection(ActivityStreamServiceCollection.Name)] public class ActivityStreamServiceTests : IDisposable { public ActivityStreamServiceTests() diff --git a/tests/OpenClaw.Tray.Tests/CapabilitiesPageLocalizationCoverageTests.cs b/tests/OpenClaw.Tray.Tests/CapabilitiesPageLocalizationCoverageTests.cs new file mode 100644 index 000000000..4ebee380b --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/CapabilitiesPageLocalizationCoverageTests.cs @@ -0,0 +1,107 @@ +using System.Xml.Linq; + +namespace OpenClaw.Tray.Tests; + +/// +/// Pins that the STT/TTS card controls in CapabilitiesPage.xaml are localized +/// (have an x:Uid) and that en-us\Resources.resw provides matching keys. +/// +/// LocalizationValidationTests catches drift between locales, but does not +/// catch the case where a developer adds a control with hardcoded English +/// text and never registers it. This test closes that hole for the new +/// privacy-sensitive voice surface (the engine picker, the language input, +/// the ElevenLabs panel, and the deep-link to VoiceSettingsPage). +/// +public sealed class CapabilitiesPageLocalizationCoverageTests +{ + private static readonly XNamespace XNs = "http://schemas.microsoft.com/winfx/2006/xaml"; + + private static string GetRepositoryRoot() + { + var envRepoRoot = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (!string.IsNullOrWhiteSpace(envRepoRoot) && Directory.Exists(envRepoRoot)) + return envRepoRoot; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if ((Directory.Exists(Path.Combine(directory.FullName, ".git")) || + File.Exists(Path.Combine(directory.FullName, ".git"))) && + File.Exists(Path.Combine(directory.FullName, "README.md"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new InvalidOperationException( + "Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path."); + } + + private static string GetCapabilitiesXamlPath() => + Path.Combine(GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", "Pages", "CapabilitiesPage.xaml"); + + private static string GetEnUsReswPath() => + Path.Combine(GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", "Strings", "en-us", "Resources.resw"); + + private static HashSet LoadReswKeys() + { + var doc = XDocument.Load(GetEnUsReswPath()); + return doc.Descendants("data") + .Select(e => e.Attribute("name")!.Value) + .ToHashSet(StringComparer.Ordinal); + } + + private static HashSet LoadXamlUids() + { + var doc = XDocument.Load(GetCapabilitiesXamlPath()); + return doc.Descendants() + .Select(e => e.Attribute(XNs + "Uid")?.Value) + .Where(v => !string.IsNullOrEmpty(v)) + .Cast() + .ToHashSet(StringComparer.Ordinal); + } + + /// + /// Contract for the STT/TTS surface introduced by the audio merge. + /// Each entry: x:Uid + the resw key suffixes that MUST exist in en-us. + /// + public static IEnumerable SttAndTtsCardUids => new[] + { + // STT card (deep-link to dedicated voice settings) + new object[] { "CapabilitiesPage_SttCardHeader", new[] { ".Text" } }, + new object[] { "CapabilitiesPage_SttCardDescription", new[] { ".Text" } }, + new object[] { "CapabilitiesPage_SttMoreSettingsLink", new[] { ".Content" } }, + // TTS card (provider picker, ElevenLabs sub-panel) + new object[] { "CapabilitiesPage_TtsCardHeader", new[] { ".Text" } }, + new object[] { "CapabilitiesPage_TtsCardDescription", new[] { ".Text" } }, + new object[] { "CapabilitiesPage_TtsProviderComboBox", new[] { ".Header" } }, + new object[] { "CapabilitiesPage_TtsProviderPiper", new[] { ".Content" } }, + new object[] { "CapabilitiesPage_TtsProviderWindows", new[] { ".Content" } }, + new object[] { "CapabilitiesPage_TtsProviderElevenLabs",new[] { ".Content" } }, + new object[] { "CapabilitiesPage_TtsElevenLabsApiKey", new[] { ".Header" } }, + new object[] { "CapabilitiesPage_TtsElevenLabsVoiceId", new[] { ".Header" } }, + new object[] { "CapabilitiesPage_TtsElevenLabsModel", new[] { ".Header", ".PlaceholderText" } }, + new object[] { "CapabilitiesPage_TtsElevenLabsHelp", new[] { ".Text" } }, + }; + + [Theory] + [MemberData(nameof(SttAndTtsCardUids))] + public void SttOrTtsControl_HasXUid_InCapabilitiesPageXaml(string uid, string[] _) + { + var uids = LoadXamlUids(); + Assert.Contains(uid, uids); + } + + [Theory] + [MemberData(nameof(SttAndTtsCardUids))] + public void SttOrTtsControl_AllExpectedReswKeys_ExistInEnUs(string uid, string[] suffixes) + { + var keys = LoadReswKeys(); + var missing = suffixes + .Select(suffix => uid + suffix) + .Where(key => !keys.Contains(key)) + .ToList(); + + Assert.True(missing.Count == 0, + $"Missing en-us resw keys for x:Uid '{uid}': {string.Join(", ", missing)}"); + } +} diff --git a/tests/OpenClaw.Tray.Tests/DeepLinkParserTests.cs b/tests/OpenClaw.Tray.Tests/DeepLinkParserTests.cs index 6f36dfbae..61d559c3f 100644 --- a/tests/OpenClaw.Tray.Tests/DeepLinkParserTests.cs +++ b/tests/OpenClaw.Tray.Tests/DeepLinkParserTests.cs @@ -121,6 +121,21 @@ public void ParseDeepLink_TrailingSlash_IsStripped() Assert.Equal("settings", result.Path); } + [Theory] + [InlineData("openclaw://send/?message=hello", "send")] + [InlineData("openclaw://agent/?message=hi&key=abc", "agent")] + [InlineData("openclaw://activity/?filter=nodes", "activity")] + public void ParseDeepLink_TrailingSlashBeforeQuery_IsStripped(string uri, string expectedPath) + { + // Windows canonicalizes openclaw://send?... to openclaw://send/?... + // before handing it to us. The slash sits before the `?`, so a naรฏve + // TrimEnd before query split fails to strip it. Regression test for + // the off-by-one fix in DeepLinkParser.ParseDeepLink. + var result = DeepLinkParser.ParseDeepLink(uri); + Assert.NotNull(result); + Assert.Equal(expectedPath, result!.Path); + } + [Fact] public void ParseDeepLink_CaseInsensitiveScheme() { diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs index 68eb2b7b8..824a99f44 100644 --- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs @@ -24,6 +24,18 @@ public class LocalizationValidationTests "Onboarding_Connection_Token", "WindowTitle_TrayMenu", "WindowTitle_Update", + // STT/TTS card invariants โ€” these are protocol/brand identifiers + // not user-visible prose. They intentionally read the same in every + // locale: "eleven_multilingual_v2" is an ElevenLabs model + // identifier, "ElevenLabs" is a brand name. + // VoiceOverlayWindow window-title key โ€” matches the convention + // for ChatWindow / HubWindow / CanvasWindow / TrayMenuWindow. + "VoiceOverlayWindow_winexWindowEx_2.Title", + "CapabilitiesPage_TtsElevenLabsModel.PlaceholderText", + "CapabilitiesPage_TtsProviderElevenLabs.Content", + // Sample IDs / brand identifiers โ€” same across locales. + "VoiceSettingsPage_ElevenLabsVoiceIdBox.PlaceholderText", + "VoiceSettingsPage_ElevenLabsModelBox.PlaceholderText", }; private static readonly string[] RequiredRuntimeOnboardingKeys = diff --git a/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs b/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs new file mode 100644 index 000000000..696710575 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs @@ -0,0 +1,132 @@ +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +/// +/// Pins the optional-capability gating that drives both the gateway client +/// path and the MCP-only path inside NodeService.RegisterCapabilities. +/// +/// Privacy-sensitive defaults must be **off** even when settings are missing. +/// A regression that flips Stt/Tts to default-on would silently advertise +/// stt.transcribe / tts.speak the moment the tray launches with a fresh +/// settings file, with no user opt-in. +/// +public sealed class NodeCapabilityGatingTests : IDisposable +{ + private readonly List _tempDirs = new(); + + public void Dispose() + { + foreach (var dir in _tempDirs) + { + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + } + + private SettingsManager NewSettings() + { + var dir = Path.Combine(Path.GetTempPath(), "openclaw-tray-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + _tempDirs.Add(dir); + return new SettingsManager(dir); + } + + [Fact] + public void NullSettings_DefaultOnCapabilities_AreEnabled() + { + // Defensive default: when settings are not yet loaded, we still + // advertise the non-privacy-sensitive capabilities so the node is + // usable immediately. + Assert.True(NodeCapabilityGating.ShouldRegisterCanvas(null)); + Assert.True(NodeCapabilityGating.ShouldRegisterScreen(null)); + Assert.True(NodeCapabilityGating.ShouldRegisterCamera(null)); + Assert.True(NodeCapabilityGating.ShouldRegisterLocation(null)); + Assert.True(NodeCapabilityGating.ShouldRegisterBrowserProxy(null)); + } + + [Fact] + public void NullSettings_PrivacySensitiveCapabilities_AreDisabled() + { + // Privacy invariant: TTS and STT must require an explicit user + // opt-in. A null/missing settings object must not enable mic capture + // or speaker output. + Assert.False(NodeCapabilityGating.ShouldRegisterTts(null)); + Assert.False(NodeCapabilityGating.ShouldRegisterStt(null)); + } + + [Fact] + public void DefaultSettings_PrivacySensitiveCapabilities_AreDisabled() + { + var s = NewSettings(); + Assert.False(NodeCapabilityGating.ShouldRegisterTts(s)); + Assert.False(NodeCapabilityGating.ShouldRegisterStt(s)); + } + + [Fact] + public void DefaultSettings_OtherCapabilities_AreEnabled() + { + var s = NewSettings(); + Assert.True(NodeCapabilityGating.ShouldRegisterCanvas(s)); + Assert.True(NodeCapabilityGating.ShouldRegisterScreen(s)); + Assert.True(NodeCapabilityGating.ShouldRegisterCamera(s)); + Assert.True(NodeCapabilityGating.ShouldRegisterLocation(s)); + Assert.True(NodeCapabilityGating.ShouldRegisterBrowserProxy(s)); + } + + [Fact] + public void Tts_OnlyAdvertisedWhenExplicitlyEnabled() + { + var s = NewSettings(); + Assert.False(NodeCapabilityGating.ShouldRegisterTts(s)); + s.NodeTtsEnabled = true; + Assert.True(NodeCapabilityGating.ShouldRegisterTts(s)); + s.NodeTtsEnabled = false; + Assert.False(NodeCapabilityGating.ShouldRegisterTts(s)); + } + + [Fact] + public void Stt_OnlyAdvertisedWhenExplicitlyEnabled() + { + var s = NewSettings(); + Assert.False(NodeCapabilityGating.ShouldRegisterStt(s)); + s.NodeSttEnabled = true; + Assert.True(NodeCapabilityGating.ShouldRegisterStt(s)); + s.NodeSttEnabled = false; + Assert.False(NodeCapabilityGating.ShouldRegisterStt(s)); + } + + [Fact] + public void TtsAndStt_Independent() + { + // A user who enables only TTS (output) must not silently enable STT + // (input), and vice versa. Each capability is its own consent surface. + var s = NewSettings(); + s.NodeTtsEnabled = true; + s.NodeSttEnabled = false; + Assert.True(NodeCapabilityGating.ShouldRegisterTts(s)); + Assert.False(NodeCapabilityGating.ShouldRegisterStt(s)); + + s.NodeTtsEnabled = false; + s.NodeSttEnabled = true; + Assert.False(NodeCapabilityGating.ShouldRegisterTts(s)); + Assert.True(NodeCapabilityGating.ShouldRegisterStt(s)); + } + + [Fact] + public void DefaultOnCapabilities_OnlyDisabledWhenExplicitlySetToFalse() + { + var s = NewSettings(); + s.NodeCanvasEnabled = false; + s.NodeScreenEnabled = false; + s.NodeCameraEnabled = false; + s.NodeLocationEnabled = false; + s.NodeBrowserProxyEnabled = false; + + Assert.False(NodeCapabilityGating.ShouldRegisterCanvas(s)); + Assert.False(NodeCapabilityGating.ShouldRegisterScreen(s)); + Assert.False(NodeCapabilityGating.ShouldRegisterCamera(s)); + Assert.False(NodeCapabilityGating.ShouldRegisterLocation(s)); + Assert.False(NodeCapabilityGating.ShouldRegisterBrowserProxy(s)); + } +} diff --git a/tests/OpenClaw.Tray.Tests/NodeInvokeActivityFormatterTests.cs b/tests/OpenClaw.Tray.Tests/NodeInvokeActivityFormatterTests.cs new file mode 100644 index 000000000..06f9a5765 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/NodeInvokeActivityFormatterTests.cs @@ -0,0 +1,127 @@ +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +/// +/// Privacy regression tests for the activity-stream details formatter that +/// powers both the recent-activity menu and the support bundle. +/// +/// The end-to-end persistence path is: +/// NodeService.OnNodeInvokeCompleted (capability handler exception) +/// โ†’ App.OnNodeInvokeCompleted +/// โ†’ NodeInvokeActivityFormatter.BuildDetails +/// โ†’ ActivityStreamService.Add +/// โ†’ ActivityStreamService.BuildSupportBundle (when user shares logs) +/// +/// For privacy-sensitive commands (mic / camera / screen), no caller-supplied +/// arg or runtime detail may reach support bundles. This test pins that. +/// +[Collection(ActivityStreamServiceCollection.Name)] +public sealed class NodeInvokeActivityFormatterTests : IDisposable +{ + public NodeInvokeActivityFormatterTests() => ActivityStreamService.Clear(); + public void Dispose() => ActivityStreamService.Clear(); + + [Theory] + [InlineData("stt.transcribe")] + [InlineData("stt.listen")] + [InlineData("stt.status")] + [InlineData("camera.snap")] + [InlineData("camera.clip")] + [InlineData("screen.snapshot")] + [InlineData("screen.record")] + public void PrivacySensitive_FailedInvoke_OmitsErrorTextFromDetails(string command) + { + const string secret = "secret-language-or-device-detail"; + var details = NodeInvokeActivityFormatter.BuildDetails(command, ok: false, durationMs: 4321, error: secret); + + Assert.Equal("privacy-sensitive ยท 4321 ms ยท error", details); + Assert.DoesNotContain(secret, details); + } + + [Fact] + public void PrivacySensitive_FailedInvoke_SecretDoesNotReachSupportBundle() + { + const string secret = "secret-language-or-device-detail"; + var details = NodeInvokeActivityFormatter.BuildDetails("stt.transcribe", ok: false, durationMs: 1234, error: secret); + + ActivityStreamService.Add( + category: "node.invoke", + title: "node.invoke failed: stt.transcribe", + details: details, + nodeId: "test-node"); + + var bundle = ActivityStreamService.BuildSupportBundle(); + Assert.DoesNotContain(secret, bundle); + Assert.Contains("privacy-sensitive ยท 1234 ms ยท error", bundle); + } + + [Fact] + public void PrivacySensitive_SuccessfulInvoke_OmitsAllDetail() + { + var details = NodeInvokeActivityFormatter.BuildDetails("stt.transcribe", ok: true, durationMs: 800, error: null); + Assert.Equal("privacy-sensitive ยท 800 ms", details); + } + + [Fact] + public void NonPrivacySensitive_FailedInvoke_KeepsErrorForDiagnostics() + { + // Non-privacy-sensitive commands (metadata / exec) keep the error text + // because they're useful for diagnostics and don't carry mic/camera args. + var details = NodeInvokeActivityFormatter.BuildDetails( + "device.status", + ok: false, + durationMs: 50, + error: "gateway unreachable"); + + Assert.Equal("metadata ยท 50 ms ยท gateway unreachable", details); + } + + [Fact] + public void NonPrivacySensitive_FailedInvoke_NullError_FallsBackToUnknown() + { + var details = NodeInvokeActivityFormatter.BuildDetails("device.status", ok: false, durationMs: 0, error: null); + Assert.Equal("metadata ยท 0 ms ยท unknown error", details); + } + + [Fact] + public void Exec_FailedInvoke_KeepsErrorForDiagnostics() + { + var details = NodeInvokeActivityFormatter.BuildDetails( + "system.run", + ok: false, + durationMs: 100, + error: "exit code 1"); + + Assert.Equal("exec ยท 100 ms ยท exit code 1", details); + } + + [Fact] + public void NegativeDuration_ClampsToZero() + { + var details = NodeInvokeActivityFormatter.BuildDetails("device.status", ok: true, durationMs: -7, error: null); + Assert.Equal("metadata ยท 0 ms", details); + } + + [Theory] + [InlineData("stt.transcribe", "privacy-sensitive")] + [InlineData("STT.Transcribe", "privacy-sensitive")] + [InlineData("stt.listen", "privacy-sensitive")] + [InlineData("Stt.Listen", "privacy-sensitive")] + [InlineData("stt.status", "privacy-sensitive")] + [InlineData("stt.future-command", "privacy-sensitive")] // any new stt.* defaults privacy-sensitive + [InlineData("camera.snap", "privacy-sensitive")] + [InlineData("camera.clip", "privacy-sensitive")] + [InlineData("screen.snapshot", "privacy-sensitive")] + [InlineData("screen.record", "privacy-sensitive")] + [InlineData("system.run", "exec")] + [InlineData("system.run.shell", "exec")] + [InlineData("device.status", "metadata")] + [InlineData("tts.speak", "privacy-sensitive")] // TTS errors can leak ElevenLabs key fragments / device names + [InlineData("tts.future-command", "privacy-sensitive")] // any future tts.* defaults privacy-sensitive + [InlineData("", "metadata")] + public void GetPrivacyClass_KnownCommands(string command, string expected) + { + Assert.Equal(expected, NodeInvokeActivityFormatter.GetPrivacyClass(command)); + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 30f0256e1..40340184c 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -26,6 +26,8 @@ + + diff --git a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs index f1996b3cb..03b84c6f1 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs @@ -37,11 +37,20 @@ public void RoundTrip_AllFields_Preserved() NodeCameraEnabled = false, NodeLocationEnabled = true, NodeBrowserProxyEnabled = false, + NodeSttEnabled = true, + SttLanguage = "en-GB", + SttModelName = "tiny", + SttSilenceTimeout = 2.5f, + VoiceTtsEnabled = false, + VoiceAudioFeedback = false, NodeTtsEnabled = true, TtsProvider = "elevenlabs", TtsElevenLabsApiKey = "elevenlabs-key", TtsElevenLabsModel = "eleven_multilingual_v2", TtsElevenLabsVoiceId = "voice-123", + TtsWindowsVoiceId = "Microsoft Zira Desktop", + HubNavPaneOpen = false, + TtsPiperVoiceId = "fr_FR-siwis-low", HasSeenActivityStreamTip = true, SkippedUpdateTag = "v1.2.3", NotifyChatResponses = false, @@ -82,11 +91,20 @@ 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.NodeSttEnabled, restored.NodeSttEnabled); + Assert.Equal(original.SttLanguage, restored.SttLanguage); + Assert.Equal(original.SttModelName, restored.SttModelName); + Assert.Equal(original.SttSilenceTimeout, restored.SttSilenceTimeout); + Assert.Equal(original.VoiceTtsEnabled, restored.VoiceTtsEnabled); + Assert.Equal(original.VoiceAudioFeedback, restored.VoiceAudioFeedback); 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.TtsWindowsVoiceId, restored.TtsWindowsVoiceId); + Assert.Equal(original.HubNavPaneOpen, restored.HubNavPaneOpen); + Assert.Equal(original.TtsPiperVoiceId, restored.TtsPiperVoiceId); Assert.Equal(original.HasSeenActivityStreamTip, restored.HasSeenActivityStreamTip); Assert.Equal(original.SkippedUpdateTag, restored.SkippedUpdateTag); Assert.Equal(original.NotifyChatResponses, restored.NotifyChatResponses); @@ -144,8 +162,10 @@ public void MissingFields_UseDefaults() Assert.True(settings.NodeCameraEnabled); Assert.True(settings.NodeLocationEnabled); Assert.True(settings.NodeBrowserProxyEnabled); + Assert.False(settings.NodeSttEnabled); + Assert.Equal("auto", settings.SttLanguage); Assert.False(settings.NodeTtsEnabled); - Assert.Equal("windows", settings.TtsProvider); + Assert.Equal("piper", settings.TtsProvider); Assert.Null(settings.TtsElevenLabsApiKey); Assert.Null(settings.TtsElevenLabsModel); Assert.Null(settings.TtsElevenLabsVoiceId); @@ -153,9 +173,24 @@ public void MissingFields_UseDefaults() Assert.Null(settings.SkippedUpdateTag); Assert.True(settings.NotifyChatResponses); Assert.True(settings.PreferStructuredCategories); + // HubNavPaneOpen defaults to true (NavView starts expanded for new + // installs and for any settings file that predates the field). + Assert.True(settings.HubNavPaneOpen); Assert.Null(settings.UserRules); } + [Fact] + public void HubNavPaneOpen_DefaultsTrue_ForEmptyJson() + { + // Existing users have a settings file written before HubNavPaneOpen + // existed. The default-true initializer must survive deserialization + // of a missing field so the NavView lands expanded for them, not + // silently collapsed. + var settings = SettingsData.FromJson("{}"); + Assert.NotNull(settings); + Assert.True(settings!.HubNavPaneOpen); + } + [Fact] public void BackwardCompatibility_OldSettingsWithoutNewFields() { @@ -198,14 +233,18 @@ public void BackwardCompatibility_OldSettingsWithoutNewFields() Assert.True(settings.NodeCameraEnabled); Assert.True(settings.NodeLocationEnabled); Assert.True(settings.NodeBrowserProxyEnabled); + Assert.False(settings.NodeSttEnabled); + Assert.Equal("auto", settings.SttLanguage); Assert.False(settings.NodeTtsEnabled); - Assert.Equal("windows", settings.TtsProvider); + Assert.Equal("piper", 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); + // HubNavPaneOpen wasn't in this older JSON shape; default true. + Assert.True(settings.HubNavPaneOpen); Assert.Null(settings.UserRules); } From adcccc9b56f5f622a1fa04b1c20003668f7011d9 Mon Sep 17 00:00:00 2001 From: Christine Yan <34801076+christineyan4@users.noreply.github.com> Date: Thu, 7 May 2026 17:05:17 -0400 Subject: [PATCH 022/320] =?UTF-8?q?feat:=20video=20capture=20frontend=20?= =?UTF-8?q?=E2=80=94=20consent,=20notifications,=20activity=20stream=20&?= =?UTF-8?q?=20settings=20(#292)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add recording state tracking to NodeService Add IsScreenRecording/IsCameraRecording properties and RecordingStateChanged event to NodeService. Wrap OnScreenRecord and OnCameraClip handlers to set state and raise events before/after async recording calls. This enables downstream UI components (tray icon, toasts, activity log) to react to recording lifecycle changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add toast notifications for screen and camera recording Show toasts on recording start, completion, and failure for both screen recording and camera clips. Extract reusable ShowToast helper and add localized strings for all 5 locales (en-us, fr-fr, zh-cn, zh-tw, nl-nl). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: log recording events to activity stream Add recording start/complete events with emoji indicators (๐Ÿ”ด/โœ…) to the activity stream. Render emoji in a separate TextBlock element to prevent color emoji clipping by the card's CornerRadius clip mask. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add recording consent dialog before first recording Show a standalone WindowEx consent dialog the first time an agent requests screen or camera recording. Consent is tracked separately per recording type (ScreenRecordingConsentGiven, CameraRecordingConsentGiven) so users can allow screen recording without granting camera access. The dialog uses extend-into-titlebar styling, Mica backdrop, and SetForegroundWindow to ensure visibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add privacy settings UI and polish consent dialog - Add Privacy section to Settings with screen/camera recording toggles - Settings toggles auto-refresh when consent changes externally - Fix consent dialog z-order with HWND_TOPMOST technique - Fix button width (MinWidth instead of fixed Width) - Add SettingsManager.Saved event for cross-component reactivity - Allow button uses AccentButtonStyle for consistency - Remove misleading 'only asked once' from privacy text Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: serialize consent dialogs and settings saves to prevent races - Add SemaphoreSlim guard in EnsureRecordingConsentAsync so concurrent recording requests coalesce onto a single consent dialog per type - Add lock around SettingsManager.Save() to prevent concurrent file writes - Update privacy toggle text in all 5 locales to clarify that enabling skips future consent prompts (e.g. 'Allow screen recording without prompting') Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add 3-2-1 countdown overlay before recording starts Show a translucent topmost countdown window (3 โ†’ 2 โ†’ 1) before screen and camera recordings begin, similar to Windows Snipping Tool. Gives users clear visual indication that recording is about to start. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(video): harden recording consent persistence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: settings dirty-state guard, consent dialog copy, and tests - Add dirty-state guard to SettingsPage: external consent saves no longer overwrite unsaved user edits on the Settings page - Update consent dialog description in all 5 locales to explicitly state that the choice persists until changed in Settings - Add 4 focused tests for settings save thread safety, Saved event, consent persistence, and consent revocation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Christine Yan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Scott Hanselman --- src/OpenClaw.Shared/SettingsData.cs | 2 + src/OpenClaw.Tray.WinUI/App.xaml.cs | 27 ++ .../Dialogs/RecordingConsentDialog.cs | 195 ++++++++++++++ .../Dialogs/RecordingCountdownWindow.cs | 133 ++++++++++ .../Pages/ActivityPage.xaml | 7 +- .../Pages/ActivityPage.xaml.cs | 4 + .../Pages/SettingsPage.xaml | 23 +- .../Pages/SettingsPage.xaml.cs | 56 ++++ .../Services/ActivityStreamService.cs | 3 + .../Services/NodeService.cs | 247 +++++++++++++++++- .../Services/SettingsManager.cs | 149 ++++++----- .../Strings/en-us/Resources.resw | 112 +++++++- .../Strings/fr-fr/Resources.resw | 112 +++++++- .../Strings/nl-nl/Resources.resw | 112 +++++++- .../Strings/zh-cn/Resources.resw | 112 +++++++- .../Strings/zh-tw/Resources.resw | 112 +++++++- .../ConsentAndSettingsSaveTests.cs | 109 ++++++++ .../SettingsRoundTripTests.cs | 34 +++ 18 files changed, 1462 insertions(+), 87 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Dialogs/RecordingConsentDialog.cs create mode 100644 src/OpenClaw.Tray.WinUI/Dialogs/RecordingCountdownWindow.cs create mode 100644 tests/OpenClaw.Tray.Tests/ConsentAndSettingsSaveTests.cs diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs index a8096c0c7..f0685f5b4 100644 --- a/src/OpenClaw.Shared/SettingsData.cs +++ b/src/OpenClaw.Shared/SettingsData.cs @@ -32,6 +32,8 @@ public class SettingsData public bool NodeCanvasEnabled { get; set; } = true; public bool NodeScreenEnabled { get; set; } = true; public bool NodeCameraEnabled { get; set; } = true; + public bool ScreenRecordingConsentGiven { get; set; } = false; + public bool CameraRecordingConsentGiven { get; set; } = false; public bool NodeLocationEnabled { get; set; } = true; public bool NodeBrowserProxyEnabled { get; set; } = true; public bool NodeSttEnabled { get; set; } = false; diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index fc4f49afa..dfdf27489 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -760,6 +760,7 @@ private static string TruncateMenuText(string text, int maxLength = 96) => private void AddRecentActivity( string line, string category = "general", + string? icon = null, string? dashboardPath = null, string? details = null, string? sessionKey = null, @@ -768,6 +769,7 @@ private void AddRecentActivity( ActivityStreamService.Add( category: category, title: line, + icon: icon, details: details, dashboardPath: dashboardPath, sessionKey: sessionKey, @@ -1670,6 +1672,7 @@ private void InitializeNodeService() _nodeService.ChannelHealthUpdated += OnChannelHealthUpdated; _nodeService.InvokeCompleted += OnNodeInvokeCompleted; _nodeService.GatewaySelfUpdated += OnGatewaySelfUpdated; + _nodeService.RecordingStateChanged += OnRecordingStateChanged; if (canRunGateway) { @@ -1866,6 +1869,30 @@ private void OnNodeStatusChanged(object? sender, ConnectionStatus status) } } + private void OnRecordingStateChanged(object? sender, RecordingStateEventArgs args) + { + var source = args.Type == RecordingType.Screen ? "Screen" : "Camera"; + if (args.IsActive) + { + var title = args.Type == RecordingType.Screen + ? LocalizationHelper.GetString("Activity_ScreenRecordingStarted") + : LocalizationHelper.GetString("Activity_CameraRecordingStarted"); + var duration = args.DurationMs > 0 ? $" ({args.DurationMs / 1000.0:0.#}s)" : ""; + AddRecentActivity($"{title}{duration}", category: "node", + icon: "๐Ÿ”ด", + details: string.Format(LocalizationHelper.GetString("Activity_RecordingRequestedByAgent"), source)); + } + else + { + var title = args.Type == RecordingType.Screen + ? LocalizationHelper.GetString("Activity_ScreenRecordingComplete") + : LocalizationHelper.GetString("Activity_CameraRecordingComplete"); + AddRecentActivity(title, category: "node", + icon: "โœ…", + details: string.Format(LocalizationHelper.GetString("Activity_RecordingSentToAgent"), source)); + } + } + private void OnPairingStatusChanged(object? sender, OpenClaw.Shared.PairingStatusEventArgs args) { Logger.Info($"Pairing status: {args.Status}"); diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/RecordingConsentDialog.cs b/src/OpenClaw.Tray.WinUI/Dialogs/RecordingConsentDialog.cs new file mode 100644 index 000000000..5eee5db41 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Dialogs/RecordingConsentDialog.cs @@ -0,0 +1,195 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using OpenClaw.Shared.Capabilities; +using OpenClawTray.Helpers; +using OpenClawTray.Services; +using System; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using WinUIEx; + +namespace OpenClawTray.Dialogs; + +/// +/// Privacy consent dialog shown before the first screen or camera recording. +/// Parameterized by recording type so each capability gets its own consent. +/// +public sealed class RecordingConsentDialog : WindowEx +{ + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); + + private static readonly IntPtr HWND_TOPMOST = new(-1); + private static readonly IntPtr HWND_NOTOPMOST = new(-2); + private const uint SWP_NOMOVE = 0x0002; + private const uint SWP_NOSIZE = 0x0001; + + private readonly TaskCompletionSource _tcs = new(); + private bool _consented; + + public RecordingConsentDialog(RecordingType type) + { + var isScreen = type == RecordingType.Screen; + var headingKey = isScreen ? "RecordingConsent_ScreenTitle" : "RecordingConsent_CameraTitle"; + var descriptionKey = isScreen ? "RecordingConsent_ScreenDescription" : "RecordingConsent_CameraDescription"; + var emoji = isScreen ? "๐Ÿ–ฅ๏ธ" : "๐Ÿ“ท"; + + Title = LocalizationHelper.GetString("RecordingConsent_WindowTitle"); + this.SetWindowSize(460, 340); + this.CenterOnScreen(); + this.SetIcon("Assets\\openclaw.ico"); + + SystemBackdrop = new MicaBackdrop(); + ExtendsContentIntoTitleBar = true; + + // Custom title bar + var titleBar = new Grid + { + Height = 48, + Padding = new Thickness(16, 0, 140, 0) + }; + titleBar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + titleBar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var titleIcon = new TextBlock + { + Text = "๐Ÿฆž", + FontSize = 16, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0) + }; + Grid.SetColumn(titleIcon, 0); + titleBar.Children.Add(titleIcon); + + var titleText = new TextBlock + { + Text = LocalizationHelper.GetString("RecordingConsent_WindowTitle"), + FontSize = 13, + VerticalAlignment = VerticalAlignment.Center, + Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"] + }; + Grid.SetColumn(titleText, 1); + titleBar.Children.Add(titleText); + + SetTitleBar(titleBar); + + // Main layout + var outerGrid = new Grid(); + outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(48) }); + outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + + Grid.SetRow(titleBar, 0); + outerGrid.Children.Add(titleBar); + + var root = new Grid + { + Padding = new Thickness(32, 16, 32, 32), + RowSpacing = 16 + }; + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + // Header + var header = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 12 + }; + header.Children.Add(new TextBlock { Text = emoji, FontSize = 36 }); + header.Children.Add(new TextBlock + { + Text = LocalizationHelper.GetString(headingKey), + Style = (Style)Application.Current.Resources["SubtitleTextBlockStyle"], + VerticalAlignment = VerticalAlignment.Center + }); + Grid.SetRow(header, 0); + root.Children.Add(header); + + // Content + var content = new StackPanel { Spacing = 12 }; + content.Children.Add(new TextBlock + { + Text = LocalizationHelper.GetString(descriptionKey), + TextWrapping = TextWrapping.Wrap + }); + content.Children.Add(new TextBlock + { + Text = LocalizationHelper.GetString("RecordingConsent_Privacy"), + TextWrapping = TextWrapping.Wrap, + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"] + }); + Grid.SetRow(content, 1); + root.Children.Add(content); + + // Buttons + var buttonPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8 + }; + + var denyButton = new Button + { + Content = LocalizationHelper.GetString("RecordingConsent_Deny") + }; + denyButton.Click += (s, e) => + { + Logger.Info($"[RecordingConsent] User denied {type} recording consent"); + _consented = false; + Close(); + }; + buttonPanel.Children.Add(denyButton); + + var allowButton = new Button + { + Content = LocalizationHelper.GetString("RecordingConsent_Allow"), + Style = (Style)Application.Current.Resources["AccentButtonStyle"] + }; + allowButton.Click += (s, e) => + { + Logger.Info($"[RecordingConsent] User allowed {type} recording consent"); + _consented = true; + Close(); + }; + buttonPanel.Children.Add(allowButton); + + Grid.SetRow(buttonPanel, 2); + root.Children.Add(buttonPanel); + + Grid.SetRow(root, 1); + outerGrid.Children.Add(root); + + Content = outerGrid; + + Closed += (s, e) => _tcs.TrySetResult(_consented); + + Logger.Info($"[RecordingConsent] {type} recording consent dialog shown"); + } + + public new Task ShowAsync() + { + Activate(); + + // Force to foreground since this may be triggered from a background context + try + { + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + if (hwnd != IntPtr.Zero) + { + // Briefly set topmost to guarantee visibility, then remove topmost flag + SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE); + SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE); + SetForegroundWindow(hwnd); + } + } + catch { /* best-effort */ } + + return _tcs.Task; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/RecordingCountdownWindow.cs b/src/OpenClaw.Tray.WinUI/Dialogs/RecordingCountdownWindow.cs new file mode 100644 index 000000000..79917cdbc --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Dialogs/RecordingCountdownWindow.cs @@ -0,0 +1,133 @@ +using Microsoft.UI; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using System; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using WinUIEx; + +namespace OpenClawTray.Dialogs; + +/// +/// Compact chromeless countdown overlay (3-2-1) shown before recording starts. +/// Displays as a small floating dark pill with a white countdown number. +/// +public sealed class RecordingCountdownWindow : WindowEx +{ + [DllImport("user32.dll")] + private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); + + [DllImport("user32.dll")] + private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong); + + [DllImport("user32.dll")] + private static extern int GetWindowLong(IntPtr hWnd, int nIndex); + + private static readonly IntPtr HWND_TOPMOST = new(-1); + private const uint SWP_NOMOVE = 0x0002; + private const uint SWP_NOSIZE = 0x0001; + private const int GWL_STYLE = -16; + private const int GWL_EXSTYLE = -20; + private const int WS_POPUP = unchecked((int)0x80000000); + private const int WS_VISIBLE = 0x10000000; + private const int WS_EX_TOOLWINDOW = 0x00000080; + private const int WS_EX_NOACTIVATE = 0x08000000; + private const uint SWP_FRAMECHANGED = 0x0020; + + private readonly TaskCompletionSource _tcs = new(); + private readonly TextBlock _countdownText; + private readonly DispatcherQueueTimer _timer; + private int _remaining; + + public RecordingCountdownWindow(int seconds = 3) + { + _remaining = seconds; + + Title = ""; + this.SetWindowSize(120, 120); + this.CenterOnScreen(); + ExtendsContentIntoTitleBar = true; + IsMinimizable = false; + IsMaximizable = false; + IsResizable = false; + + _countdownText = new TextBlock + { + Text = _remaining.ToString(), + FontSize = 56, + FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Foreground = new SolidColorBrush(Colors.White), + // Nudge up slightly to compensate for font descender space + Padding = new Thickness(0, 0, 0, 6) + }; + + // Solid dark circle on a fully transparent window + var pill = new Border + { + Background = new SolidColorBrush(global::Windows.UI.Color.FromArgb(230, 30, 30, 30)), + CornerRadius = new CornerRadius(60), + Width = 100, + Height = 100, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Child = _countdownText + }; + + Content = new Grid + { + Background = new SolidColorBrush(Colors.Transparent), + Children = { pill } + }; + + _timer = DispatcherQueue.CreateTimer(); + _timer.Interval = TimeSpan.FromSeconds(1); + _timer.Tick += OnTick; + } + + private void OnTick(DispatcherQueueTimer sender, object args) + { + _remaining--; + + if (_remaining <= 0) + { + _timer.Stop(); + Close(); + return; + } + + _countdownText.Text = _remaining.ToString(); + } + + public Task ShowCountdownAsync() + { + Closed += (s, e) => _tcs.TrySetResult(); + + // Transparent window background so only the dark circle is visible + SystemBackdrop = new TransparentTintBackdrop(); + + Activate(); + + // Strip window chrome and make topmost + try + { + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + if (hwnd != IntPtr.Zero) + { + SetWindowLong(hwnd, GWL_STYLE, WS_POPUP | WS_VISIBLE); + var exStyle = GetWindowLong(hwnd, GWL_EXSTYLE); + SetWindowLong(hwnd, GWL_EXSTYLE, exStyle | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE); + SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_FRAMECHANGED); + } + } + catch { /* best-effort */ } + + _timer.Start(); + + return _tcs.Task; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Pages/ActivityPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ActivityPage.xaml index 0254552b7..fe4de731f 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ActivityPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/ActivityPage.xaml @@ -58,7 +58,12 @@ - + + + + diff --git a/src/OpenClaw.Tray.WinUI/Pages/ActivityPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ActivityPage.xaml.cs index 4cb8fe293..d5855bb3f 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ActivityPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ActivityPage.xaml.cs @@ -96,6 +96,8 @@ private static ActivityViewModel MapToViewModel(ActivityStreamItem item) return new ActivityViewModel { Title = item.Title, + Icon = item.Icon, + IconVisibility = string.IsNullOrWhiteSpace(item.Icon) ? Visibility.Collapsed : Visibility.Visible, Category = item.Category, TimeAgo = GetTimeAgo(item.Timestamp), DetailText = detailText, @@ -135,6 +137,8 @@ private void OnClearAll(object sender, RoutedEventArgs e) private class ActivityViewModel { public string Title { get; set; } = ""; + public string Icon { get; set; } = ""; + public Visibility IconVisibility { get; set; } public string Category { get; set; } = ""; public string TimeAgo { get; set; } = ""; public string DetailText { get; set; } = ""; diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml index 733d3ad93..018246f5b 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml @@ -73,6 +73,25 @@ + + + + + + + + + + + + @@ -82,9 +101,9 @@ BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" BorderThickness="0,1,0,0" Padding="24,16"> - - - - - - + + + + - - + + + + + + + + + + + - + diff --git a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs index 1c5f791b6..e65273751 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SkillsPage.xaml.cs @@ -4,17 +4,35 @@ using Microsoft.UI.Xaml.Media; using OpenClawTray.Windows; using System.Collections.Generic; +using System.Linq; using System.Text.Json; +using Windows.UI; namespace OpenClawTray.Pages; public sealed partial class SkillsPage : Page { private HubWindow? _hub; + private List _allSkills = new(); + + public string? CurrentAgentId => GetSelectedAgentId(); public SkillsPage() { InitializeComponent(); + EnabledHeaderBtn.Click += (s, e) => + { + _enabledExpanded = !_enabledExpanded; + EnabledChevron.Glyph = _enabledExpanded ? "\uE70E" : "\uE70D"; + EnabledPanel.Visibility = _enabledExpanded ? Visibility.Visible : Visibility.Collapsed; + if (!_enabledExpanded) SkillsScroller.ChangeView(null, 0, null, disableAnimation: false); + }; + DisabledHeaderBtn.Click += (s, e) => + { + _disabledExpanded = !_disabledExpanded; + DisabledChevron.Glyph = _disabledExpanded ? "\uE70E" : "\uE70D"; + DisabledPanel.Visibility = _disabledExpanded ? Visibility.Visible : Visibility.Collapsed; + }; } public void Initialize(HubWindow hub) @@ -55,20 +73,28 @@ private void OnAgentFilterChanged(object sender, SelectionChangedEventArgs e) _ = client.RequestSkillsStatusAsync(GetSelectedAgentId()); } - private void OnSkillActionClick(object sender, RoutedEventArgs e) + private async void OnToggleSkillClick(object sender, RoutedEventArgs e) { - var skillId = (sender as Button)?.Tag as string; - if (string.IsNullOrEmpty(skillId) || _hub?.GatewayClient == null) return; + if (sender is not Button btn || btn.Tag is not string skillKey) return; + if (_hub?.GatewayClient == null) return; - // Determine action based on button content - var label = (sender as Button)?.Content as string; - if (label == "Update") - { - _ = _hub.GatewayClient.UpdateSkillAsync(skillId); - } - else + var skill = _allSkills.FirstOrDefault(s => s.SkillKey == skillKey); + if (skill == null) return; + + bool newState = !skill.IsEnabled; + btn.IsEnabled = false; + var success = await _hub.GatewayClient.SetSkillEnabledAsync(skillKey, newState); + btn.IsEnabled = true; + + if (success) { - _ = _hub.GatewayClient.InstallSkillAsync(skillId); + // Re-lookup after await โ€” _allSkills may have been replaced by UpdateFromGateway + var current = _allSkills.FirstOrDefault(s => s.SkillKey == skillKey); + if (current != null) + { + current.IsEnabled = newState; + RebuildCards(); + } } } @@ -76,72 +102,196 @@ public void UpdateFromGateway(JsonElement data) { OpenClawTray.Services.Logger.Info("[SkillsPage] Received gateway skills data"); - if (!data.TryGetProperty("payload", out var payload)) - return; - - // payload may be { "skills": [...] } or directly an array JsonElement skillsArray; - if (payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("skills", out var inner)) + if (data.TryGetProperty("skills", out var inner)) skillsArray = inner; - else if (payload.ValueKind == JsonValueKind.Array) - skillsArray = payload; + else if (data.TryGetProperty("payload", out var payload)) + { + if (payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("skills", out var nested)) + skillsArray = nested; + else if (payload.ValueKind == JsonValueKind.Array) + skillsArray = payload; + else + return; + } + else if (data.ValueKind == JsonValueKind.Array) + skillsArray = data; else return; - var skills = new List(); + var skills = new List(); foreach (var item in skillsArray.EnumerateArray()) { - var vm = new SkillViewModel(); + var s = new SkillData(); + if (item.TryGetProperty("name", out var nameEl)) + { + s.Name = nameEl.GetString() ?? ""; + s.Id = s.Name; + } if (item.TryGetProperty("id", out var idEl)) - vm.Id = idEl.GetString() ?? ""; + s.Id = idEl.GetString() ?? s.Id; + if (item.TryGetProperty("emoji", out var emojiEl)) + { + var emoji = emojiEl.GetString() ?? ""; + if (!string.IsNullOrEmpty(emoji)) + s.Name = $"{emoji} {s.Name}"; + } + if (item.TryGetProperty("description", out var descEl)) + s.Description = descEl.GetString() ?? ""; + if (item.TryGetProperty("source", out var srcEl)) + s.Source = srcEl.GetString() ?? ""; + if (item.TryGetProperty("skillKey", out var keyEl)) + s.SkillKey = keyEl.GetString() ?? s.Id; + else + s.SkillKey = s.Id; - if (item.TryGetProperty("name", out var nameEl)) - vm.Name = nameEl.GetString() ?? ""; + if (item.TryGetProperty("disabled", out var disabledEl)) + s.IsEnabled = disabledEl.ValueKind != JsonValueKind.True; + else if (item.TryGetProperty("enabled", out var enabledEl)) + s.IsEnabled = enabledEl.ValueKind == JsonValueKind.True; + else + s.IsEnabled = item.TryGetProperty("eligible", out var eligibleEl) && eligibleEl.ValueKind == JsonValueKind.True; - if (item.TryGetProperty("version", out var verEl)) - vm.Version = verEl.GetString() ?? ""; + skills.Add(s); + } - if (item.TryGetProperty("description", out var descEl)) - vm.Description = descEl.GetString() ?? ""; + // Sort alphabetically + skills.Sort((a, b) => string.Compare(a.Name, b.Name, System.StringComparison.OrdinalIgnoreCase)); - if (item.TryGetProperty("enabled", out var enabledEl)) - { - bool enabled = enabledEl.ValueKind == JsonValueKind.True; - vm.StatusText = enabled ? "Active" : "Inactive"; - vm.StatusBackground = new SolidColorBrush(enabled ? Colors.Green : Colors.Gray); - vm.ActionLabel = enabled ? "Update" : "Install"; - } + DispatcherQueue?.TryEnqueue(() => + { + _allSkills = skills; + RebuildCards(); + }); + } - skills.Add(vm); + private bool _enabledExpanded = true; + private bool _disabledExpanded = true; + + private void RebuildCards() + { + var enabled = _allSkills.Where(s => s.IsEnabled).ToList(); + var disabled = _allSkills.Where(s => !s.IsEnabled).ToList(); + + EnabledPanel.Children.Clear(); + DisabledPanel.Children.Clear(); + + foreach (var s in enabled) + EnabledPanel.Children.Add(BuildCard(s)); + foreach (var s in disabled) + DisabledPanel.Children.Add(BuildCard(s)); + + EnabledHeaderText.Text = $"Enabled ({enabled.Count})"; + DisabledHeaderText.Text = $"Disabled ({disabled.Count})"; + DisabledHeaderBtn.Visibility = disabled.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + DisabledPanel.Visibility = _disabledExpanded ? Visibility.Visible : Visibility.Collapsed; + EnabledPanel.Visibility = _enabledExpanded ? Visibility.Visible : Visibility.Collapsed; + + var total = _allSkills.Count; + CountText.Text = total > 0 ? $"({enabled.Count}/{total} enabled)" : ""; + + if (total > 0) + { + SkillsGroups.Visibility = Visibility.Visible; + EmptyState.Visibility = Visibility.Collapsed; } + else + { + SkillsGroups.Visibility = Visibility.Collapsed; + EmptyState.Visibility = Visibility.Visible; + } + } - DispatcherQueue?.TryEnqueue(() => + private Grid BuildCard(SkillData s) + { + var card = new Grid { - if (skills.Count > 0) - { - SkillsList.ItemsSource = skills; - SkillsList.Visibility = Visibility.Visible; - EmptyState.Visibility = Visibility.Collapsed; - } - else + Padding = new Thickness(16, 10, 16, 12), + Margin = new Thickness(0, 2, 0, 0), + CornerRadius = new CornerRadius(6), + Background = (Brush)Application.Current.Resources["CardBackgroundFillColorDefaultBrush"] + }; + card.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + card.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + card.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + card.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + card.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + double contentOpacity = s.IsEnabled ? 1.0 : 0.5; + + // Row 0, Col 0: Name + badge + var nameRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Opacity = contentOpacity }; + nameRow.Children.Add(new TextBlock { Text = s.Name, FontSize = 13, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center }); + + var badgeBg = s.IsEnabled + ? new SolidColorBrush(Color.FromArgb(40, 76, 175, 80)) + : new SolidColorBrush(Color.FromArgb(40, 230, 168, 23)); + var badgeFg = s.IsEnabled + ? new SolidColorBrush(Colors.LimeGreen) + : new SolidColorBrush(Color.FromArgb(255, 230, 168, 23)); + var badge = new Border + { + CornerRadius = new CornerRadius(4), Padding = new Thickness(6, 2, 6, 2), + Background = badgeBg, VerticalAlignment = VerticalAlignment.Center + }; + badge.Child = new TextBlock { Text = s.IsEnabled ? "enabled" : "disabled", FontSize = 10, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, Foreground = badgeFg, VerticalAlignment = VerticalAlignment.Center }; + nameRow.Children.Add(badge); + Grid.SetRow(nameRow, 0); + Grid.SetColumn(nameRow, 0); + card.Children.Add(nameRow); + + // Row 0, Col 1: Source + var source = new TextBlock + { + Text = s.Source, FontSize = 11, FontFamily = new FontFamily("Consolas"), + Foreground = (Brush)Application.Current.Resources["TextFillColorTertiaryBrush"], + VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(8, 0, 8, 0), + Opacity = contentOpacity + }; + Grid.SetRow(source, 0); + Grid.SetColumn(source, 1); + card.Children.Add(source); + + // Row 0, Col 2: Toggle button + var toggleBtn = new Button + { + Tag = s.SkillKey, + Padding = new Thickness(6, 4, 6, 4), MinWidth = 0, MinHeight = 0 + }; + ToolTipService.SetToolTip(toggleBtn, s.IsEnabled ? "Disable" : "Enable"); + toggleBtn.Content = new FontIcon { Glyph = s.IsEnabled ? "\uE769" : "\uE768", FontSize = 12 }; + toggleBtn.Click += OnToggleSkillClick; + Grid.SetRow(toggleBtn, 0); + Grid.SetColumn(toggleBtn, 2); + card.Children.Add(toggleBtn); + + // Row 1: Description + if (!string.IsNullOrEmpty(s.Description)) + { + var desc = new TextBlock { - SkillsList.ItemsSource = null; - SkillsList.Visibility = Visibility.Collapsed; - EmptyState.Visibility = Visibility.Visible; - } - }); + Text = s.Description, FontSize = 12, TextWrapping = TextWrapping.Wrap, MaxLines = 2, + Margin = new Thickness(0, 4, 0, 0), + Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + Opacity = contentOpacity + }; + Grid.SetRow(desc, 1); + Grid.SetColumnSpan(desc, 3); + card.Children.Add(desc); + } + + return card; } - private class SkillViewModel + private class SkillData { public string Id { get; set; } = ""; + public string SkillKey { get; set; } = ""; public string Name { get; set; } = ""; - public string Version { get; set; } = ""; public string Description { get; set; } = ""; - public string StatusText { get; set; } = ""; - public SolidColorBrush StatusBackground { get; set; } = new(Colors.Gray); - public string ActionLabel { get; set; } = "Install"; + public string Source { get; set; } = ""; + public bool IsEnabled { get; set; } = true; } } diff --git a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml index 34c3bf201..a08348aee 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/VoiceSettingsPage.xaml @@ -5,7 +5,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> - + - + + + + { if (IsClosed) return; - if (ContentFrame?.Content is SkillsPage sp) sp.UpdateFromGateway(data); + if (ContentFrame?.Content is SkillsPage sp) + { + LastSkillsAgentId = sp.CurrentAgentId; + sp.UpdateFromGateway(snapshot); + } }); } catch { } @@ -439,10 +448,12 @@ public void UpdateAgentFilesList(System.Text.Json.JsonElement data) { try { + var snapshot = data.Clone(); + LastAgentFilesList = snapshot; DispatcherQueue?.TryEnqueue(() => { if (IsClosed) return; - if (ContentFrame?.Content is WorkspacePage wp) wp.UpdateAgentFilesList(data); + if (ContentFrame?.Content is WorkspacePage wp) wp.UpdateAgentFilesList(snapshot); }); } catch { } @@ -452,10 +463,11 @@ public void UpdateAgentFileContent(System.Text.Json.JsonElement data) { try { + var snapshot = data.Clone(); DispatcherQueue?.TryEnqueue(() => { if (IsClosed) return; - if (ContentFrame?.Content is WorkspacePage wp) wp.UpdateAgentFileContent(data); + if (ContentFrame?.Content is WorkspacePage wp) wp.UpdateAgentFileContent(snapshot); }); } catch { } @@ -635,7 +647,11 @@ private void InitializeCurrentPage() if (LastPresence != null) nodes.UpdatePresence(LastPresence); break; case CronPage cron: cron.Initialize(this); SeedCronData(cron); break; - case SkillsPage skills: skills.Initialize(this); break; + case SkillsPage skills: + skills.Initialize(this); + if (LastSkillsData.HasValue && LastSkillsAgentId == skills.CurrentAgentId) + skills.UpdateFromGateway(LastSkillsData.Value); + break; case ConfigPage config: try { @@ -670,7 +686,10 @@ private void InitializeCurrentPage() agentEvents.AddEvent(LastAgentEvents[i]); } break; - case WorkspacePage workspace: workspace.Initialize(this); break; + case WorkspacePage workspace: + workspace.Initialize(this); + if (LastAgentFilesList.HasValue) workspace.UpdateAgentFilesList(LastAgentFilesList.Value); + break; case BindingsPage bindings: bindings.Initialize(this); if (LastConfig.HasValue) bindings.UpdateConfig(LastConfig.Value); From e8a2072a6b0c296b519a6fd956f772ec194102ae Mon Sep 17 00:00:00 2001 From: AlexAlves87 Date: Wed, 13 May 2026 21:09:24 +0200 Subject: [PATCH 117/320] refactor: extract CopyDiagnostic helper for diagnostic copy methods Nine structurally identical Copy* methods (CopySupportContext, CopyDebugBundle, CopyBrowserSetupGuidance, CopyPortDiagnostics, CopyCapabilityDiagnostics, CopyNodeInventory, CopyChannelSummary, CopyActivitySummary, CopyExtensibilitySummary) each repeated the same DataPackage + Clipboard.SetContent boilerplate alongside identical try/catch logging. This replaces all nine bodies with a single private CopyDiagnostic(string label, Func) helper and reduces each method to an expression-bodied one-liner. No observable behavior change: log messages, clipboard content, error handling, and method signatures (exposed as Action delegates in DeepLinkActions) are all preserved exactly. Co-Authored-By: Claude Sonnet 4.6 --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 141 +++++----------------------- 1 file changed, 23 insertions(+), 118 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 863dcff5d..f18edbc0c 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -4031,140 +4031,45 @@ private static void OpenFolder(string? folderPath, string label) } } - private void CopySupportContext() + private void CopyDiagnostic(string label, Func format) { try { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildSupportContext(BuildCommandCenterState())); - Clipboard.SetContent(package); - Logger.Info("Copied support context from deep link"); + CopyTextToClipboard(format(BuildCommandCenterState())); + Logger.Info($"Copied {label} from deep link"); } catch (Exception ex) { - Logger.Warn($"Failed to copy support context from deep link: {ex.Message}"); + Logger.Warn($"Failed to copy {label} from deep link: {ex.Message}"); } } - private void CopyDebugBundle() - { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildDebugBundle(BuildCommandCenterState())); - Clipboard.SetContent(package); - Logger.Info("Copied debug bundle from deep link"); - } - catch (Exception ex) - { - Logger.Warn($"Failed to copy debug bundle from deep link: {ex.Message}"); - } - } + private void CopySupportContext() => + CopyDiagnostic("support context", CommandCenterTextHelper.BuildSupportContext); - private void CopyBrowserSetupGuidance() - { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildBrowserSetupGuidance(BuildCommandCenterState())); - Clipboard.SetContent(package); - Logger.Info("Copied browser setup guidance from deep link"); - } - catch (Exception ex) - { - Logger.Warn($"Failed to copy browser setup guidance from deep link: {ex.Message}"); - } - } + private void CopyDebugBundle() => + CopyDiagnostic("debug bundle", CommandCenterTextHelper.BuildDebugBundle); - private void CopyPortDiagnostics() - { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildPortDiagnosticsSummary(BuildCommandCenterState().PortDiagnostics)); - Clipboard.SetContent(package); - Logger.Info("Copied port diagnostics from deep link"); - } - catch (Exception ex) - { - Logger.Warn($"Failed to copy port diagnostics from deep link: {ex.Message}"); - } - } + private void CopyBrowserSetupGuidance() => + CopyDiagnostic("browser setup guidance", CommandCenterTextHelper.BuildBrowserSetupGuidance); - private void CopyCapabilityDiagnostics() - { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildCapabilityDiagnosticsSummary(BuildCommandCenterState())); - Clipboard.SetContent(package); - Logger.Info("Copied capability diagnostics from deep link"); - } - catch (Exception ex) - { - Logger.Warn($"Failed to copy capability diagnostics from deep link: {ex.Message}"); - } - } + private void CopyPortDiagnostics() => + CopyDiagnostic("port diagnostics", s => CommandCenterTextHelper.BuildPortDiagnosticsSummary(s.PortDiagnostics)); - private void CopyNodeInventory() - { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildNodeInventorySummary(BuildCommandCenterState().Nodes)); - Clipboard.SetContent(package); - Logger.Info("Copied node inventory from deep link"); - } - catch (Exception ex) - { - Logger.Warn($"Failed to copy node inventory from deep link: {ex.Message}"); - } - } + private void CopyCapabilityDiagnostics() => + CopyDiagnostic("capability diagnostics", CommandCenterTextHelper.BuildCapabilityDiagnosticsSummary); - private void CopyChannelSummary() - { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildChannelSummaryText(BuildCommandCenterState().Channels)); - Clipboard.SetContent(package); - Logger.Info("Copied channel summary from deep link"); - } - catch (Exception ex) - { - Logger.Warn($"Failed to copy channel summary from deep link: {ex.Message}"); - } - } + private void CopyNodeInventory() => + CopyDiagnostic("node inventory", s => CommandCenterTextHelper.BuildNodeInventorySummary(s.Nodes)); - private void CopyActivitySummary() - { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildActivitySummary(BuildCommandCenterState().RecentActivity)); - Clipboard.SetContent(package); - Logger.Info("Copied activity summary from deep link"); - } - catch (Exception ex) - { - Logger.Warn($"Failed to copy activity summary from deep link: {ex.Message}"); - } - } + private void CopyChannelSummary() => + CopyDiagnostic("channel summary", s => CommandCenterTextHelper.BuildChannelSummaryText(s.Channels)); - private void CopyExtensibilitySummary() - { - try - { - var package = new DataPackage(); - package.SetText(CommandCenterTextHelper.BuildExtensibilitySummary(BuildCommandCenterState().Channels)); - Clipboard.SetContent(package); - Logger.Info("Copied extensibility summary from deep link"); - } - catch (Exception ex) - { - Logger.Warn($"Failed to copy extensibility summary from deep link: {ex.Message}"); - } - } + private void CopyActivitySummary() => + CopyDiagnostic("activity summary", s => CommandCenterTextHelper.BuildActivitySummary(s.RecentActivity)); + + private void CopyExtensibilitySummary() => + CopyDiagnostic("extensibility summary", s => CommandCenterTextHelper.BuildExtensibilitySummary(s.Channels)); private void OnGlobalHotkeyPressed(object? sender, EventArgs e) { From 9724eea243b151233b0a2eeca1b771aa89527e16 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Wed, 13 May 2026 12:41:55 -0700 Subject: [PATCH 118/320] Preserve agent scope for cached UI data Keep skills refreshes scoped to the active agent filter and key workspace file-list cache replay by agent id so agent-specific pages do not show stale data. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Shared/OpenClawGatewayClient.cs | 11 ++++++---- .../Pages/WorkspacePage.xaml.cs | 11 +++++++--- .../Windows/HubWindow.xaml.cs | 22 +++++++++++++++++-- .../OpenClawGatewayClientTests.cs | 22 +++++++++++++++++++ 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index fc98d2471..52de2178e 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -68,6 +68,7 @@ public class OpenClawGatewayClient : WebSocketClientBase, IOperatorGatewayClient private bool _pairingRequiredAwaitingApproval; private string? _pairingRequiredRequestId; private bool _authFailed; + private string? _lastSkillsStatusAgentId; private readonly bool _tokenIsBootstrapToken; private readonly bool _bootstrapPairAsNode; @@ -473,8 +474,10 @@ public async Task RequestCronRunsAsync(string? id = null, int limit = 50, int of public async Task RequestSkillsStatusAsync(string? agentId = null) { - if (!string.IsNullOrEmpty(agentId)) - await SendTrackedRequestAsync("skills.status", new { agentId }); + _lastSkillsStatusAgentId = string.IsNullOrWhiteSpace(agentId) ? null : agentId; + + if (_lastSkillsStatusAgentId is not null) + await SendTrackedRequestAsync("skills.status", new { agentId = _lastSkillsStatusAgentId }); else await SendTrackedRequestAsync("skills.status"); } @@ -1164,8 +1167,8 @@ private bool HandleKnownResponse(string method, JsonElement payload) return true; case "skills.install": case "skills.update": - // Re-fetch skills status so the UI reflects the change - _ = RequestSkillsStatusAsync(); + // Re-fetch the same skills scope so filtered views do not jump back to all agents. + _ = RequestSkillsStatusAsync(_lastSkillsStatusAgentId); return true; case "config.get": ConfigUpdated?.Invoke(this, payload.Clone()); diff --git a/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs index 614270558..b29d8b966 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/WorkspacePage.xaml.cs @@ -15,6 +15,7 @@ public sealed partial class WorkspacePage : Page private bool _tabsPopulated; private string AgentId => _hub?.CurrentAgentId ?? "main"; + public string CurrentAgentId => AgentId; public WorkspacePage() { @@ -24,14 +25,17 @@ public WorkspacePage() public void Initialize(HubWindow hub) { _hub = hub; - // If HubWindow has cached file list data, it will replay via UpdateAgentFilesList after Initialize. - // Only request fresh data if no cache exists. - if (hub.GatewayClient != null && hub.CurrentStatus == ConnectionStatus.Connected && !hub.LastAgentFilesList.HasValue) + // If HubWindow has cached file list data for this agent, it will replay after Initialize. + // Only request fresh data when no matching cache exists. + var hasMatchingCache = hub.LastAgentFilesList.HasValue && + string.Equals(hub.LastAgentFilesListAgentId, AgentId, StringComparison.OrdinalIgnoreCase); + if (hub.GatewayClient != null && hub.CurrentStatus == ConnectionStatus.Connected && !hasMatchingCache) { FallbackInfoBar.IsOpen = false; LoadingRing.IsActive = true; LoadingPanel.Visibility = Visibility.Visible; ClearTabs(); + hub.RecordAgentFilesListRequest(AgentId); _ = hub.GatewayClient.RequestAgentFilesListAsync(AgentId); } else if (hub.GatewayClient == null || hub.CurrentStatus != ConnectionStatus.Connected) @@ -181,6 +185,7 @@ private void RefreshButton_Click(object sender, RoutedEventArgs e) LoadingPanel.Visibility = Visibility.Visible; FallbackInfoBar.IsOpen = false; ClearTabs(); + _hub.RecordAgentFilesListRequest(AgentId); _ = _hub.GatewayClient.RequestAgentFilesListAsync(AgentId); } } diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs index 30910566e..ac3f7a228 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs @@ -72,6 +72,8 @@ public SettingsManager? Settings public System.Text.Json.JsonElement? LastSkillsData { get; private set; } public string? LastSkillsAgentId { get; private set; } public System.Text.Json.JsonElement? LastAgentFilesList { get; private set; } + public string? LastAgentFilesListAgentId { get; private set; } + private string? _pendingAgentFilesListAgentId; // Event for settings saved (App.xaml.cs subscribes) public event EventHandler? SettingsSaved; @@ -444,16 +446,28 @@ public List GetAgentIds() return ids; } + public void RecordAgentFilesListRequest(string agentId) + { + _pendingAgentFilesListAgentId = string.IsNullOrWhiteSpace(agentId) ? "main" : agentId; + } + public void UpdateAgentFilesList(System.Text.Json.JsonElement data) { try { var snapshot = data.Clone(); + var responseAgentId = _pendingAgentFilesListAgentId ?? _currentAgentId; + _pendingAgentFilesListAgentId = null; + LastAgentFilesListAgentId = responseAgentId; LastAgentFilesList = snapshot; DispatcherQueue?.TryEnqueue(() => { if (IsClosed) return; - if (ContentFrame?.Content is WorkspacePage wp) wp.UpdateAgentFilesList(snapshot); + if (ContentFrame?.Content is WorkspacePage wp && + string.Equals(wp.CurrentAgentId, responseAgentId, StringComparison.OrdinalIgnoreCase)) + { + wp.UpdateAgentFilesList(snapshot); + } }); } catch { } @@ -688,7 +702,11 @@ private void InitializeCurrentPage() break; case WorkspacePage workspace: workspace.Initialize(this); - if (LastAgentFilesList.HasValue) workspace.UpdateAgentFilesList(LastAgentFilesList.Value); + if (LastAgentFilesList.HasValue && + string.Equals(LastAgentFilesListAgentId, workspace.CurrentAgentId, StringComparison.OrdinalIgnoreCase)) + { + workspace.UpdateAgentFilesList(LastAgentFilesList.Value); + } break; case BindingsPage bindings: bindings.Initialize(this); diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index e9cc0f590..bc63b8cfd 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -394,6 +394,14 @@ public List CaptureStatusChanges() public bool GetAuthFailedFlag() => GetPrivateField("_authFailed"); + public string? GetLastSkillsStatusAgentId() + { + var field = typeof(OpenClawGatewayClient).GetField( + "_lastSkillsStatusAgentId", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return field!.GetValue(_client) as string; + } + public List CaptureAuthenticationFailedEvents() { var events = new List(); @@ -421,6 +429,20 @@ public void OperatorConnect_FreshDevice_RequestsBootstrapHandoffScopes() Assert.False(auth.ContainsKey("deviceToken")); } + [Fact] + public async Task RequestSkillsStatusAsync_RemembersRequestedAgentScope() + { + var helper = new GatewayClientTestHelper(); + + await helper.Client.RequestSkillsStatusAsync("agent-alpha"); + + Assert.Equal("agent-alpha", helper.GetLastSkillsStatusAgentId()); + + await helper.Client.RequestSkillsStatusAsync(); + + Assert.Null(helper.GetLastSkillsStatusAgentId()); + } + [Fact] public void OperatorConnect_FreshStandardLocalLoopbackDevice_RequestsFullOperatorScopes() { From 20c351aec1078e80fb074f14e61a57d03f32a286 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Wed, 13 May 2026 12:52:24 -0700 Subject: [PATCH 119/320] Update fake gateway client for skills toggle API Implement SetSkillEnabledAsync on the onboarding test fake after merging current master so PR validation covers the new gateway client interface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs b/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs index 8e09af30b..d1b88d210 100644 --- a/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs +++ b/tests/OpenClaw.Tray.Tests/OnboardingChatBootstrapperTests.cs @@ -169,6 +169,7 @@ public void SetPreferStructuredCategories(bool value) { } public Task RequestCronRunsAsync(string? id = null, int limit = 20, int offset = 0) => Task.CompletedTask; public Task RequestSkillsStatusAsync(string? agentId = null) => Task.CompletedTask; public Task InstallSkillAsync(string skillId) => Task.FromResult(false); + public Task SetSkillEnabledAsync(string skillKey, bool enabled) => Task.FromResult(false); public Task UpdateSkillAsync(string skillId) => Task.FromResult(false); public Task RequestConfigAsync() => Task.CompletedTask; public Task RequestConfigSchemaAsync() => Task.CompletedTask; From 5cacfe934c6746ac169a82b9adabe31dddb7fcbf Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Wed, 13 May 2026 13:21:58 -0700 Subject: [PATCH 120/320] Refactor WinUI clipboard text copies Add a shared ClipboardHelper for text copy operations and route existing WinUI clipboard writes through it while preserving the chat timeline flush behavior and App.CopyTextToClipboard API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 13 +++---------- .../Chat/OpenClawChatTimeline.cs | 6 +----- .../Dialogs/QuickSendDialog.cs | 4 +--- .../Helpers/ClipboardHelper.cs | 16 ++++++++++++++++ .../Onboarding/Pages/WizardPage.cs | 4 +--- src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml.cs | 6 ++---- .../Pages/CapabilitiesPage.xaml.cs | 12 ++++-------- .../Pages/ConnectionPage.xaml.cs | 10 +++------- src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs | 14 ++++---------- src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs | 5 +---- .../Windows/ConnectionStatusWindow.xaml.cs | 6 ++---- .../Windows/SetupWizardWindow.cs | 4 +--- 12 files changed, 39 insertions(+), 61 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Helpers/ClipboardHelper.cs diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index f18edbc0c..3b74f28b3 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -23,7 +23,6 @@ using System.Threading; using System.Threading.Tasks; using Updatum; -using Windows.ApplicationModel.DataTransfer; using WinUIEx; namespace OpenClawTray; @@ -1052,9 +1051,7 @@ private void CopyDeviceIdToClipboard() try { - var dataPackage = new global::Windows.ApplicationModel.DataTransfer.DataPackage(); - dataPackage.SetText(_nodeService.FullDeviceId); - global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(dataPackage); + CopyTextToClipboard(_nodeService.FullDeviceId); // Show toast confirming copy ShowToast(new ToastContentBuilder() @@ -1081,9 +1078,7 @@ private void CopyNodeSummaryToClipboard() }); var summary = string.Join(Environment.NewLine, lines); - var dataPackage = new global::Windows.ApplicationModel.DataTransfer.DataPackage(); - dataPackage.SetText(summary); - global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(dataPackage); + CopyTextToClipboard(summary); ShowToast(new ToastContentBuilder() .AddText(LocalizationHelper.GetString("Toast_NodeSummaryCopied")) @@ -4394,9 +4389,7 @@ private void OnToastActivated(ToastNotificationActivatedEventArgsCompat args) public static void CopyTextToClipboard(string text) { - var dataPackage = new global::Windows.ApplicationModel.DataTransfer.DataPackage(); - dataPackage.SetText(text); - global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(dataPackage); + ClipboardHelper.CopyText(text); } #endregion diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs index 40e02a8e4..edafa43e8 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs @@ -11,7 +11,6 @@ using OpenClawTray.FunctionalUI; using OpenClawTray.FunctionalUI.Core; using OpenClawTray.Chat.Explorations; -using Windows.ApplicationModel.DataTransfer; using Windows.UI; using static OpenClawTray.FunctionalUI.Factories; using static OpenClawTray.FunctionalUI.Core.Theme; @@ -588,10 +587,7 @@ static void CopyToClipboard(string text) if (string.IsNullOrEmpty(text)) return; try { - var data = new DataPackage(); - data.SetText(text); - Clipboard.SetContent(data); - Clipboard.Flush(); + ClipboardHelper.CopyText(text, flush: true); } catch { /* clipboard contention โ€” silently ignore */ } } diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs b/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs index d4117c890..c2f0435ef 100644 --- a/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs +++ b/src/OpenClaw.Tray.WinUI/Dialogs/QuickSendDialog.cs @@ -343,9 +343,7 @@ private void ShowDetails(string details) private static void CopyTextToClipboard(string text) { - var data = new global::Windows.ApplicationModel.DataTransfer.DataPackage(); - data.SetText(text); - global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(data); + ClipboardHelper.CopyText(text); } private void QueueFocusMessageInput() diff --git a/src/OpenClaw.Tray.WinUI/Helpers/ClipboardHelper.cs b/src/OpenClaw.Tray.WinUI/Helpers/ClipboardHelper.cs new file mode 100644 index 000000000..05a737b9c --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/ClipboardHelper.cs @@ -0,0 +1,16 @@ +using Windows.ApplicationModel.DataTransfer; + +namespace OpenClawTray.Helpers; + +internal static class ClipboardHelper +{ + public static void CopyText(string text, bool flush = false) + { + var dataPackage = new DataPackage(); + dataPackage.SetText(text); + Clipboard.SetContent(dataPackage); + + if (flush) + Clipboard.Flush(); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WizardPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WizardPage.cs index 2748681f9..d2e4bb465 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WizardPage.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/WizardPage.cs @@ -588,9 +588,7 @@ async void SkipStep() { try { - var dp = new global::Windows.ApplicationModel.DataTransfer.DataPackage(); - dp.SetText(code); - global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(dp); + ClipboardHelper.CopyText(code); } catch { } }).VAlign(VerticalAlignment.Center) diff --git a/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml.cs index 13bbedfed..cba4d5aca 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml.cs @@ -1,9 +1,9 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using OpenClawTray.Helpers; using OpenClawTray.Windows; using System; using System.Diagnostics; -using WinDataTransfer = global::Windows.ApplicationModel.DataTransfer; namespace OpenClawTray.Pages; @@ -83,9 +83,7 @@ private async void OnCopySupportClick(object sender, RoutedEventArgs e) + $"Connection: {_hub?.CurrentStatus}\n" + $"Gateway: {_hub?.Settings?.GetEffectiveGatewayUrl() ?? "n/a"}\n"; - var dataPackage = new WinDataTransfer.DataPackage(); - dataPackage.SetText(context); - WinDataTransfer.Clipboard.SetContent(dataPackage); + ClipboardHelper.CopyText(context); } catch (Exception ex) { diff --git a/src/OpenClaw.Tray.WinUI/Pages/CapabilitiesPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/CapabilitiesPage.xaml.cs index 5ebec1b94..2988fa3fb 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/CapabilitiesPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/CapabilitiesPage.xaml.cs @@ -3,11 +3,11 @@ using Microsoft.UI.Xaml.Input; using OpenClaw.Shared; using OpenClaw.Shared.Capabilities; +using OpenClawTray.Helpers; using OpenClawTray.Services; using OpenClawTray.Windows; using System; using System.Collections.Generic; -using Windows.ApplicationModel.DataTransfer; using Windows.System; namespace OpenClawTray.Pages; @@ -310,9 +310,7 @@ private void OnCopyMcpToken(object sender, RoutedEventArgs e) if (System.IO.File.Exists(tokenPath)) { var token = System.IO.File.ReadAllText(tokenPath).Trim(); - var dp = new DataPackage(); - dp.SetText(token); - Clipboard.SetContent(dp); + ClipboardHelper.CopyText(token); McpStatusText.Text = "Token copied to clipboard"; } else @@ -328,9 +326,7 @@ private void OnCopyMcpToken(object sender, RoutedEventArgs e) private void OnCopyMcpUrl(object sender, RoutedEventArgs e) { - var dp = new DataPackage(); - dp.SetText(NodeService.McpServerUrl); - Clipboard.SetContent(dp); + ClipboardHelper.CopyText(NodeService.McpServerUrl); McpStatusText.Text = "URL copied to clipboard"; } -} \ No newline at end of file +} diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 469ff86b4..fff84278b 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -1,6 +1,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using OpenClaw.Shared; +using OpenClawTray.Helpers; using OpenClawTray.Onboarding.Services; using OpenClawTray.Services; using OpenClawTray.Services.Connection; @@ -8,7 +9,6 @@ using System; using System.Collections.Generic; using System.Linq; -using Windows.ApplicationModel.DataTransfer; namespace OpenClawTray.Pages; @@ -255,9 +255,7 @@ private void UpdatePairingGuidance(GatewayConnectionSnapshot snapshot) private void OnCopyApproveCommand(object sender, RoutedEventArgs e) { - var dp = new DataPackage(); - dp.SetText(PairingApproveCommandText.Text); - Clipboard.SetContent(dp); + ClipboardHelper.CopyText(PairingApproveCommandText.Text); } private void OnReconnectAfterApproval(object sender, RoutedEventArgs e) @@ -920,8 +918,6 @@ private void OnCopyApprovalCommand(object sender, RoutedEventArgs e) private static void CopyToClipboard(string text) { - var dp = new DataPackage(); - dp.SetText(text); - Clipboard.SetContent(dp); + ClipboardHelper.CopyText(text); } } diff --git a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs index aefe4c06f..3e1a58213 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/DebugPage.xaml.cs @@ -1,13 +1,13 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using OpenClaw.Shared; +using OpenClawTray.Helpers; using OpenClawTray.Windows; using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Text.Json; -using WinDataTransfer = global::Windows.ApplicationModel.DataTransfer; namespace OpenClawTray.Pages; @@ -115,9 +115,7 @@ private void LoadLog() private void OnCopyLog(object sender, RoutedEventArgs e) { - var dp = new WinDataTransfer.DataPackage(); - dp.SetText(LogText.Text ?? ""); - WinDataTransfer.Clipboard.SetContent(dp); + ClipboardHelper.CopyText(LogText.Text ?? ""); } // โ”€โ”€ Connection Status โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -183,9 +181,7 @@ private void LoadDeviceIdentity() private void OnCopyDeviceId(object sender, RoutedEventArgs e) { var fullId = DeviceIdText.Tag as string ?? DeviceIdText.Text; - var dp = new WinDataTransfer.DataPackage(); - dp.SetText(fullId); - WinDataTransfer.Clipboard.SetContent(dp); + ClipboardHelper.CopyText(fullId); } // โ”€โ”€ Debug Actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -238,9 +234,7 @@ private void OnCopySupportContext(object sender, RoutedEventArgs e) $"Time: {DateTime.UtcNow:u}" }; - var dp = new WinDataTransfer.DataPackage(); - dp.SetText(string.Join("\n", lines)); - WinDataTransfer.Clipboard.SetContent(dp); + ClipboardHelper.CopyText(string.Join("\n", lines)); if (sender is Button btn) { diff --git a/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs index 750826f75..9e7b0afa6 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/NodesPage.xaml.cs @@ -10,7 +10,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using WinDataTransfer = global::Windows.ApplicationModel.DataTransfer; using WinColor = global::Windows.UI.Color; namespace OpenClawTray.Pages; @@ -741,9 +740,7 @@ private void OnCopyDeviceId(object sender, RoutedEventArgs e) { if (sender is Button btn && btn.Tag is string deviceId) { - var dataPackage = new WinDataTransfer.DataPackage(); - dataPackage.SetText(deviceId); - WinDataTransfer.Clipboard.SetContent(dataPackage); + ClipboardHelper.CopyText(deviceId); btn.Content = "โœ“"; var timer = DispatcherQueue.CreateTimer(); timer.Interval = TimeSpan.FromSeconds(2); diff --git a/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml.cs index 033b9b49d..ce6ae29ca 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/ConnectionStatusWindow.xaml.cs @@ -6,13 +6,13 @@ using Microsoft.UI.Xaml.Documents; using Microsoft.UI.Xaml.Media; using OpenClaw.Shared; +using OpenClawTray.Helpers; using OpenClawTray.Onboarding.Services; using OpenClawTray.Services.Connection; using System; using System.IO; using System.Text; using WinUIEx; -using WinDataTransfer = global::Windows.ApplicationModel.DataTransfer; namespace OpenClawTray.Windows; @@ -529,9 +529,7 @@ private static string FormatPlain(ConnectionDiagnosticEvent evt) private void OnCopyTimeline(object sender, RoutedEventArgs e) { - var dp = new WinDataTransfer.DataPackage(); - dp.SetText(_plainBuffer.ToString()); - WinDataTransfer.Clipboard.SetContent(dp); + ClipboardHelper.CopyText(_plainBuffer.ToString()); } private void OnClearTimeline(object sender, RoutedEventArgs e) diff --git a/src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs b/src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs index bd175a7b3..6e35df974 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs @@ -919,9 +919,7 @@ private void OnCopyDeviceId(object sender, RoutedEventArgs e) var fullId = _copyDeviceIdButton.Tag?.ToString(); if (string.IsNullOrEmpty(fullId)) return; - var dataPackage = new global::Windows.ApplicationModel.DataTransfer.DataPackage(); - dataPackage.SetText(fullId); - global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(dataPackage); + ClipboardHelper.CopyText(fullId); _copyDeviceIdButton.Content = LocalizationHelper.GetString("Setup_DeviceIdCopied"); Logger.Info("[Setup] Device ID copied to clipboard"); From b3b5b2f46334d43e9711087ebdceaec5768b1e2e Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Wed, 13 May 2026 13:44:22 -0700 Subject: [PATCH 121/320] fix(onboarding): skip wizard for paired operators and make "Keep my setup" dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs reported by Scott Hanselman against master: 1. Tray app launched the onboarding wizard on every start even when the user already had a working remote-gateway operator configuration. StartupSetupState.RequiresSetup only short-circuited for node mode (EnableNodeMode + node device token) or MCP-only mode, so an operator with a non-default gateway URL + stored device token still got the wizard popped at OnLaunched. Fix: add an operator-mode short-circuit that requires BOTH a stored operator device token AND a non-default GatewayUrl (guards against orphan tokens after uninstall and against half-finished setups that never picked a gateway target). 2. On the SetupWarning page warn-and-confirm UI, clicking "Keep my setup" only toggled in-page state. Because OnboardingWindow defaulted SetupPath = Advanced when existing config was detected, the global nav-bar Next button stayed enabled, so the user was one click from advancing into ConnectionPage anyway. Fix: add OnboardingState.Dismiss() that raises a new Dismissed event; OnboardingWindow handles it by setting a _dismissedWithoutCompletion guard, then Close()ing the window. OnClosed now skips TryCompleteOnboarding when that guard is set so OnboardingCompleted is NOT fired and existing settings / gateway connection are preserved. SetupWarningPage.CancelReplace calls Props.Dismiss(). Belt-and-suspenders: drop the auto-default of SetupPath = Advanced for existing-config users in OnboardingWindow. With SetupPath left null, the nav-bar Next button is disabled on SetupWarning so the user MUST pick "Replace my setup", "Keep my setup", or "Advanced setup" explicitly โ€” no accidental Next-into-setup path remains. Tests: - StartupSetupStateTests: operator paired with remote gateway returns false; operator token + default URL still returns true (stale-token guard); non-default URL alone (no token) still returns true. - OnboardingStateTests: Dismiss fires Dismissed but NOT Finished; safe without subscribers. Validation: - ./build.ps1 succeeded - Shared.Tests: 1548 passed, 28 skipped - Tray.Tests: 1175 passed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Onboarding/OnboardingWindow.cs | 53 +++++++++++++++---- .../Onboarding/Pages/SetupWarningPage.cs | 5 ++ .../Onboarding/Services/OnboardingState.cs | 19 +++++++ .../Services/StartupSetupState.cs | 41 +++++++++++++- .../OnboardingStateTests.cs | 39 ++++++++++++++ .../StartupSetupStateTests.cs | 45 ++++++++++++++++ 6 files changed, 191 insertions(+), 11 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs index 0df225a4d..ae0fe3925 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs @@ -86,17 +86,21 @@ public OnboardingWindow(SettingsManager settings, string? identityDataPath = nul _state = new OnboardingState(settings); _state.Finished += OnOnboardingFinished; _state.RouteChanged += OnRouteChanged; - - // Construct the existing-config guard and apply returning-user defaults. - // When existing config is detected, default SetupPath to Advanced so the - // user lands on the SetupWarning page with Next enabled (โ†’ Connection page) - // rather than the local setup path. The warn-and-confirm gate on - // SetupWarningPage protects the "Set up locally" button. + _state.Dismissed += OnOnboardingDismissed; + + // Construct the existing-config guard for returning users. The + // uses this to render the warn-and-confirm + // ("Replace my setup" / "Keep my setup") section when prior credentials, + // a non-default gateway URL, or a completed setup-state file is present. + // + // We intentionally do NOT preselect SetupPath here. Leaving it null keeps + // the global nav-bar Next button disabled on SetupWarning so the user MUST + // pick one of the three explicit choices ("Replace my setup", "Keep my + // setup", or "Advanced setup") โ€” eliminating the accidental Next-into-setup + // path Scott Hanselman hit when clicking "Keep my setup". if (identityDataPath != null) { _state.ExistingConfigGuard = new OnboardingExistingConfigGuard(settings, identityDataPath); - if (_state.ExistingConfigGuard.HasExistingConfiguration()) - _state.SetupPath = SetupPath.Advanced; } // Optional override for visual tests / engineering: jump straight to a route. @@ -572,17 +576,48 @@ private void OnOnboardingFinished(object? sender, EventArgs e) _ = ShowIncompleteSetupDialogAsync(); } + /// + /// Set when the user explicitly dismisses the wizard via + /// (e.g., "Keep my setup" on the + /// SetupWarning page). consults this to skip the + /// completion pipeline so existing settings and gateway connection are + /// preserved untouched. + /// + private bool _dismissedWithoutCompletion; + + private void OnOnboardingDismissed(object? sender, EventArgs e) + { + Logger.Info("[OnboardingWindow] Dismissed by user (keep-existing-setup) โ€” closing without completing"); + _dismissedWithoutCompletion = true; + try + { + Close(); + } + catch (Exception ex) + { + Logger.Warn($"[OnboardingWindow] Close after dismiss threw: {ex.Message}"); + } + } + private void OnClosed(object sender, WindowEventArgs args) { // X button path: also runs TryCompleteOnboarding (idempotent via _completionDispatched) // so a user who clicks the title-bar X on the Ready page still gets the chat-window // launch when a model has been configured, matching the Finish-button behavior. - _ = TryCompleteOnboarding(); + // + // Skipped entirely when the user explicitly dismissed via "Keep my setup" โ€” that + // path must NOT mark onboarding complete, must NOT fire OnboardingCompleted, and + // must NOT touch settings/AutoStart so the prior gateway connection is preserved. + if (!_dismissedWithoutCompletion) + { + _ = TryCompleteOnboarding(); + } if (_stateDisposed) return; _stateDisposed = true; _state.Finished -= OnOnboardingFinished; _state.RouteChanged -= OnRouteChanged; + _state.Dismissed -= OnOnboardingDismissed; if (Completed) { _state.GatewayClient = null; diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs index c8a3cad41..29e8d3709 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs @@ -68,7 +68,12 @@ void ConfirmReplace() void CancelReplace() { + // "Keep my setup" โ€” the user has existing configuration and wants to + // keep it. Dismiss the wizard entirely rather than just hiding the + // warning section (which previously left the user one nav-bar Next + // click away from entering setup anyway โ€” Scott Hanselman repro). setConfirmingReplace(false); + Props.Dismiss(); } void ChooseAdvanced() diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs index b73c7256b..c5652d5a8 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs @@ -13,6 +13,25 @@ public sealed class OnboardingState : IDisposable public event EventHandler? Finished; public event EventHandler? PageChanged; + /// + /// Raised when the user explicitly dismisses the wizard without completing it + /// (e.g., clicks "Keep my setup" on the SetupWarning page when existing config + /// is present). handles this by closing the + /// window without firing or running the "complete + /// onboarding" pipeline โ€” existing settings and gateway connection are preserved. + /// + public event EventHandler? Dismissed; + + /// + /// Raises . Called by pages that offer a "keep my existing + /// setup and exit the wizard" option. + /// + public void Dismiss() + { + OpenClawTray.Services.Logger.Info("[OnboardingState] Dismiss invoked (user chose to keep existing setup)"); + Dismissed?.Invoke(this, EventArgs.Empty); + } + /// /// The currently displayed route. Updated by OnboardingApp on navigation. /// diff --git a/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs b/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs index 933f59f2e..cf8c79c96 100644 --- a/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs +++ b/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs @@ -4,9 +4,30 @@ namespace OpenClawTray.Services; internal static class StartupSetupState { + /// + /// Default loopback gateway URL. Matches OnboardingExistingConfigGuard.DefaultGatewayUrl; + /// kept in sync intentionally so both startup and onboarding decisions use the same signal + /// for "user has a real (non-default) gateway target configured". + /// + private const string DefaultGatewayUrl = "ws://localhost:18789"; + public static bool HasStoredNodeDeviceToken(string dataPath) => DeviceIdentity.HasStoredDeviceTokenForRole(dataPath, "node", NullLogger.Instance); + /// + /// True if the user has previously paired this device as an operator + /// (root device token present) AND has a non-default gateway URL configured. + /// Both signals together indicate a working operator config โ€” guards against + /// orphan tokens or default-URL-only configs that wouldn't actually connect. + /// + public static bool HasUsableOperatorConfiguration(SettingsManager settings, string dataPath) => + DeviceIdentity.HasStoredDeviceToken(dataPath, NullLogger.Instance) + && HasNonDefaultGatewayUrl(settings); + + private static bool HasNonDefaultGatewayUrl(SettingsManager settings) => + !string.IsNullOrWhiteSpace(settings.GatewayUrl) + && !string.Equals(settings.GatewayUrl, DefaultGatewayUrl, StringComparison.OrdinalIgnoreCase); + public static bool CanStartNodeGateway(SettingsManager settings, string dataPath) { if (!settings.EnableNodeMode) @@ -19,11 +40,27 @@ public static bool CanStartNodeGateway(SettingsManager settings, string dataPath public static bool RequiresSetup(SettingsManager settings, string dataPath) { - if (settings.EnableNodeMode && HasStoredNodeDeviceToken(dataPath)) + // Node mode: needs a paired node device token to operate. + if (settings.EnableNodeMode) + { + return !HasStoredNodeDeviceToken(dataPath); + } + + // MCP-only mode doesn't require an authenticated gateway. + if (settings.EnableMcpServer) + { + return false; + } + + // Operator mode: returning users with a stored operator device token AND + // a non-default gateway URL already have a working configuration โ€” don't + // auto-launch the wizard (Scott Hanselman repro: remote-gateway operator + // saw the wizard pop on every launch). + if (HasUsableOperatorConfiguration(settings, dataPath)) { return false; } - return !settings.EnableMcpServer; + return true; } } diff --git a/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs b/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs index 43df9ec6f..67ef89492 100644 --- a/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs +++ b/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs @@ -324,6 +324,45 @@ public void Complete_SavesSettings() #endregion + #region Dismiss + + [Fact] + public void Dismiss_FiresDismissedEvent() + { + var state = CreateState(); + var fired = false; + state.Dismissed += (_, _) => fired = true; + + state.Dismiss(); + + Assert.True(fired); + } + + [Fact] + public void Dismiss_DoesNotFireFinishedEvent() + { + // "Keep my setup" must NOT route through the completion pipeline โ€” + // OnboardingWindow relies on Finished being unraised so it skips + // TryCompleteOnboarding and leaves prior settings/connection untouched. + var state = CreateState(); + var finished = false; + state.Finished += (_, _) => finished = true; + + state.Dismiss(); + + Assert.False(finished); + } + + [Fact] + public void Dismiss_DoesNotThrow_WithoutHandler() + { + var state = CreateState(); + var ex = Record.Exception(() => state.Dismiss()); + Assert.Null(ex); + } + + #endregion + #region NotifyRouteChanged [Fact] diff --git a/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs b/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs index b1167f8e2..8601689ce 100644 --- a/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs +++ b/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs @@ -46,6 +46,51 @@ public void RequiresSetup_ReturnsTrue_WhenNoAuthOrLocalServerModeExists() Assert.False(StartupSetupState.CanStartNodeGateway(settings, temp.Path)); } + [Fact] + public void RequiresSetup_ReturnsFalse_WhenOperatorPairedWithRemoteGateway() + { + // Scott Hanselman repro: operator mode with a non-default (remote) gateway URL + // and a stored operator device token โ€” wizard must NOT auto-launch on next start. + using var temp = TempSettings.Create(); + StoreDeviceToken(temp.Path); + var settings = new SettingsManager(temp.Path) { GatewayUrl = "wss://remote.example.com:443" }; + + Assert.False(StartupSetupState.RequiresSetup(settings, temp.Path)); + } + + [Fact] + public void RequiresSetup_ReturnsTrue_WhenOperatorTokenExistsButGatewayUrlIsDefault() + { + // Stale-token guard: a stored operator token alone is not enough. Without a + // configured non-default gateway URL the app has no target to connect to, + // so first-run setup should still be offered. + using var temp = TempSettings.Create(); + StoreDeviceToken(temp.Path); + var settings = new SettingsManager(temp.Path) { GatewayUrl = "ws://localhost:18789" }; + + Assert.True(StartupSetupState.RequiresSetup(settings, temp.Path)); + } + + [Fact] + public void RequiresSetup_ReturnsTrue_WhenNonDefaultGatewayUrlButNoOperatorToken() + { + // Inverse guard: a non-default URL alone (no pairing yet) still needs setup. + using var temp = TempSettings.Create(); + var settings = new SettingsManager(temp.Path) { GatewayUrl = "wss://remote.example.com:443" }; + + Assert.True(StartupSetupState.RequiresSetup(settings, temp.Path)); + } + + [Fact] + public void HasUsableOperatorConfiguration_ReturnsFalse_WhenGatewayUrlIsNullOrWhitespace() + { + using var temp = TempSettings.Create(); + StoreDeviceToken(temp.Path); + var settings = new SettingsManager(temp.Path) { GatewayUrl = " " }; + + Assert.False(StartupSetupState.HasUsableOperatorConfiguration(settings, temp.Path)); + } + private static void StoreDeviceToken(string dataPath) { var identity = new DeviceIdentity(dataPath); From 8338c5da85cab5bf2c3d5115aad3f2226e8a80da Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Wed, 13 May 2026 14:18:20 -0700 Subject: [PATCH 122/320] fix(onboarding): address dual-model code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from a Hanselman adversarial code review (Opus + Codex parallel): 1. Per-gateway tokens (Codex HIGH) โ€” RequiresSetup only scanned the legacy root identity (device-key-ed25519.json at the dataPath root). Modern pairings via DeviceIdentityStore write tokens at /gateways//device-key-ed25519.json (see GatewayConnectionManager._activeIdentityPath = perGatewayIdentityDir). Operators paired post-GatewayRegistry would still see the wizard pop on every launch. Fix: HasAnyOperatorDeviceToken now scans the legacy root AND every gateways/* subdir. 2. SSH-tunnel false positive (Codex HIGH) โ€” SSH topology routes via ws://127.0.0.1:LocalPort and the user typically leaves GatewayUrl at default. HasNonDefaultGatewayUrl alone returned false. Fix: HasAnyConfiguredGatewayTarget treats (UseSshTunnel + non-empty SshTunnelHost) as a configured target. 3. NodeMode + MCP precedence regression (Codex MEDIUM) โ€” original code was 'if (NodeMode && nodeToken) false; return !MCP;' which let MCP-only mode bypass setup even when NodeMode was accidentally true without a node token. The first patch made NodeMode short-circuit first, breaking that precedence. Fix: check EnableMcpServer BEFORE EnableNodeMode so MCP wins, matching original semantics. 4. _dismissedWithoutCompletion stuck on Close exception (Opus MEDIUM) โ€” the flag was set BEFORE Close(); if Close() threw, the flag stayed true and TryCompleteOnboarding was permanently suppressed for the window's lifetime, wedging the user. Fix: reset the flag in the catch block so the X-button / Finish path still works. 5. DefaultGatewayUrl duplication (Opus HIGH) โ€” the constant existed in both StartupSetupState and OnboardingExistingConfigGuard with only a comment promising sync. Fix: promote OnboardingExistingConfigGuard.DefaultGatewayUrl to public const (single source of truth) and reference it from StartupSetupState. Added DefaultGatewayUrl_MatchesGuardConstant invariant test. 6. CancelReplace UI flash (Opus MEDIUM) โ€” setConfirmingReplace(false) was called immediately before Props.Dismiss(), causing a brief re-render of the 'Set up locally' button before the window closed. Fix: drop the dead state change. Tests added (5): - RequiresSetup_ReturnsFalse_WhenSshTunnelConfiguredWithStoredToken - RequiresSetup_ReturnsTrue_WhenSshTunnelEnabledButNoHostConfigured - RequiresSetup_ReturnsFalse_WhenOperatorTokenStoredOnlyInPerGatewayDir - RequiresSetup_ReturnsFalse_WhenMcpEnabledEvenWithNodeModeAndNoNodeToken - DefaultGatewayUrl_MatchesGuardConstant Validation: - ./build.ps1 succeeded - Shared.Tests: 1548 passed, 28 skipped - Tray.Tests: 1180 passed (5 new); all 16 onboarding-fix tests green Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Onboarding/OnboardingWindow.cs | 5 + .../Onboarding/Pages/SetupWarningPage.cs | 4 +- .../Services/OnboardingExistingConfigGuard.cs | 8 +- .../Services/StartupSetupState.cs | 101 +++++++++++++----- .../StartupSetupStateTests.cs | 73 +++++++++++++ 5 files changed, 165 insertions(+), 26 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs index ae0fe3925..a22f45d7a 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs @@ -595,6 +595,11 @@ private void OnOnboardingDismissed(object? sender, EventArgs e) } catch (Exception ex) { + // If Close() fails the window is still alive. Reset the guard so a + // subsequent X-button or normal Finish path is NOT permanently + // suppressed (otherwise TryCompleteOnboarding becomes unreachable + // for the lifetime of this window). + _dismissedWithoutCompletion = false; Logger.Warn($"[OnboardingWindow] Close after dismiss threw: {ex.Message}"); } } diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs index 29e8d3709..98f7cfd3e 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Pages/SetupWarningPage.cs @@ -72,7 +72,9 @@ void CancelReplace() // keep it. Dismiss the wizard entirely rather than just hiding the // warning section (which previously left the user one nav-bar Next // click away from entering setup anyway โ€” Scott Hanselman repro). - setConfirmingReplace(false); + // We deliberately do NOT call setConfirmingReplace(false) here: the + // window is about to close, and the redundant state change would + // briefly re-render the "Set up locally" button before teardown. Props.Dismiss(); } diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs index eb568c3f4..3a37e8383 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs @@ -13,7 +13,13 @@ namespace OpenClawTray.Onboarding.Services; /// public sealed class OnboardingExistingConfigGuard { - private const string DefaultGatewayUrl = "ws://localhost:18789"; + /// + /// Default loopback gateway URL. Single source of truth โ€” also referenced by + /// StartupSetupState.HasNonDefaultGatewayUrl. If you change this, the + /// invariant test StartupSetupStateTests.DefaultGatewayUrl_MatchesGuardConstant + /// will catch drift. + /// + public const string DefaultGatewayUrl = "ws://localhost:18789"; private readonly SettingsManager _settings; private readonly string _identityDataPath; private readonly string _setupStatePath; diff --git a/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs b/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs index cf8c79c96..9060c780a 100644 --- a/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs +++ b/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs @@ -1,32 +1,82 @@ using OpenClaw.Shared; +using OpenClawTray.Onboarding.Services; namespace OpenClawTray.Services; internal static class StartupSetupState { - /// - /// Default loopback gateway URL. Matches OnboardingExistingConfigGuard.DefaultGatewayUrl; - /// kept in sync intentionally so both startup and onboarding decisions use the same signal - /// for "user has a real (non-default) gateway target configured". - /// - private const string DefaultGatewayUrl = "ws://localhost:18789"; - public static bool HasStoredNodeDeviceToken(string dataPath) => DeviceIdentity.HasStoredDeviceTokenForRole(dataPath, "node", NullLogger.Instance); /// - /// True if the user has previously paired this device as an operator - /// (root device token present) AND has a non-default gateway URL configured. - /// Both signals together indicate a working operator config โ€” guards against - /// orphan tokens or default-URL-only configs that wouldn't actually connect. + /// True if the user has an operator device token (root or any per-gateway dir) + /// AND a configured gateway target (non-default GatewayUrl or an SSH tunnel + /// host). Both signals together indicate a working operator config โ€” guards + /// against orphan tokens and against tokens-without-target stale state. /// public static bool HasUsableOperatorConfiguration(SettingsManager settings, string dataPath) => - DeviceIdentity.HasStoredDeviceToken(dataPath, NullLogger.Instance) - && HasNonDefaultGatewayUrl(settings); + HasAnyOperatorDeviceToken(dataPath) && HasAnyConfiguredGatewayTarget(settings); + + /// + /// Scans the legacy root identity AND per-gateway identity directories for an + /// operator device token. Modern pairings (post-GatewayRegistry) write tokens + /// to <dataPath>/gateways/<gatewayId>/device-key-ed25519.json + /// via DeviceIdentityStore; the legacy root file is kept by migration + /// but is NOT created by fresh pairings. + /// + internal static bool HasAnyOperatorDeviceToken(string dataPath) + { + if (DeviceIdentity.HasStoredDeviceToken(dataPath, NullLogger.Instance)) + { + return true; + } + + var gatewaysDir = Path.Combine(dataPath, "gateways"); + if (!Directory.Exists(gatewaysDir)) + { + return false; + } + + try + { + foreach (var dir in Directory.EnumerateDirectories(gatewaysDir)) + { + if (DeviceIdentity.HasStoredDeviceToken(dir, NullLogger.Instance)) + { + return true; + } + } + } + catch (Exception) + { + // Best-effort scan โ€” IO/permission failure should not silently allow + // the wizard to be skipped, so fall through to "no usable token". + } + + return false; + } + + /// + /// True when the user has configured an actual gateway target โ€” either a + /// non-default GatewayUrl or an SSH tunnel host (which routes via + /// ws://127.0.0.1:LocalPort and would otherwise look "default"). + /// + internal static bool HasAnyConfiguredGatewayTarget(SettingsManager settings) + { + if (settings.UseSshTunnel && !string.IsNullOrWhiteSpace(settings.SshTunnelHost)) + { + return true; + } + + return HasNonDefaultGatewayUrl(settings); + } private static bool HasNonDefaultGatewayUrl(SettingsManager settings) => !string.IsNullOrWhiteSpace(settings.GatewayUrl) - && !string.Equals(settings.GatewayUrl, DefaultGatewayUrl, StringComparison.OrdinalIgnoreCase); + && !string.Equals( + settings.GatewayUrl, + OnboardingExistingConfigGuard.DefaultGatewayUrl, + StringComparison.OrdinalIgnoreCase); public static bool CanStartNodeGateway(SettingsManager settings, string dataPath) { @@ -40,22 +90,25 @@ public static bool CanStartNodeGateway(SettingsManager settings, string dataPath public static bool RequiresSetup(SettingsManager settings, string dataPath) { - // Node mode: needs a paired node device token to operate. - if (settings.EnableNodeMode) + // MCP-only mode doesn't require an authenticated gateway. Checked first + // so that an MCP-server user with EnableNodeMode accidentally left on + // (but no node token) still bypasses the wizard โ€” preserves the + // original "MCP wins" precedence. + if (settings.EnableMcpServer) { - return !HasStoredNodeDeviceToken(dataPath); + return false; } - // MCP-only mode doesn't require an authenticated gateway. - if (settings.EnableMcpServer) + // Node mode: needs a paired node device token to operate as a node. + if (settings.EnableNodeMode) { - return false; + return !HasStoredNodeDeviceToken(dataPath); } - // Operator mode: returning users with a stored operator device token AND - // a non-default gateway URL already have a working configuration โ€” don't - // auto-launch the wizard (Scott Hanselman repro: remote-gateway operator - // saw the wizard pop on every launch). + // Operator mode: returning users with any operator device token AND a + // configured gateway target already have a working configuration โ€” + // don't auto-launch the wizard (Scott Hanselman repro: remote-gateway + // operator saw the wizard pop on every launch). if (HasUsableOperatorConfiguration(settings, dataPath)) { return false; diff --git a/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs b/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs index 8601689ce..945208922 100644 --- a/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs +++ b/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs @@ -91,6 +91,79 @@ public void HasUsableOperatorConfiguration_ReturnsFalse_WhenGatewayUrlIsNullOrWh Assert.False(StartupSetupState.HasUsableOperatorConfiguration(settings, temp.Path)); } + [Fact] + public void RequiresSetup_ReturnsFalse_WhenSshTunnelConfiguredWithStoredToken() + { + // SSH topology routes via ws://127.0.0.1:LocalPort so the user keeps + // GatewayUrl at default. Detection must treat (UseSshTunnel + host) as + // a configured target so SSH operators are not re-prompted at launch. + using var temp = TempSettings.Create(); + StoreDeviceToken(temp.Path); + var settings = new SettingsManager(temp.Path) + { + UseSshTunnel = true, + SshTunnelHost = "ssh.example.com", + SshTunnelUser = "ops", + }; + + Assert.False(StartupSetupState.RequiresSetup(settings, temp.Path)); + } + + [Fact] + public void RequiresSetup_ReturnsTrue_WhenSshTunnelEnabledButNoHostConfigured() + { + using var temp = TempSettings.Create(); + StoreDeviceToken(temp.Path); + var settings = new SettingsManager(temp.Path) { UseSshTunnel = true }; + + Assert.True(StartupSetupState.RequiresSetup(settings, temp.Path)); + } + + [Fact] + public void RequiresSetup_ReturnsFalse_WhenOperatorTokenStoredOnlyInPerGatewayDir() + { + // Modern pairings (post-GatewayRegistry) store device tokens in + // /gateways//device-key-ed25519.json via + // DeviceIdentityStore. The legacy root file is NOT created for fresh + // pairings, so RequiresSetup must scan the per-gateway directories. + using var temp = TempSettings.Create(); + var perGatewayDir = Path.Combine(temp.Path, "gateways", "gw-abc"); + Directory.CreateDirectory(perGatewayDir); + StoreDeviceToken(perGatewayDir); + var settings = new SettingsManager(temp.Path) { GatewayUrl = "wss://remote.example.com:443" }; + + Assert.False(StartupSetupState.RequiresSetup(settings, temp.Path)); + } + + [Fact] + public void RequiresSetup_ReturnsFalse_WhenMcpEnabledEvenWithNodeModeAndNoNodeToken() + { + // Regression guard: the original code returned !EnableMcpServer as the + // fallback so MCP-only mode bypassed onboarding even when EnableNodeMode + // was accidentally true with no node token. The new ordering must + // preserve "MCP wins" precedence. + using var temp = TempSettings.Create(); + var settings = new SettingsManager(temp.Path) + { + EnableNodeMode = true, + EnableMcpServer = true, + }; + + Assert.False(StartupSetupState.RequiresSetup(settings, temp.Path)); + } + + [Fact] + public void DefaultGatewayUrl_MatchesGuardConstant() + { + // OnboardingExistingConfigGuard.DefaultGatewayUrl is the single source + // of truth referenced by StartupSetupState.HasNonDefaultGatewayUrl. + // This test exists so a future change to the constant (or a refactor + // that re-introduces a duplicate) is caught immediately. + Assert.Equal( + "ws://localhost:18789", + OpenClawTray.Onboarding.Services.OnboardingExistingConfigGuard.DefaultGatewayUrl); + } + private static void StoreDeviceToken(string dataPath) { var identity = new DeviceIdentity(dataPath); From 216afe67c6fa00a70c88067a3737bad7636a709a Mon Sep 17 00:00:00 2001 From: Samantha Song Date: Wed, 13 May 2026 16:53:58 -0700 Subject: [PATCH 123/320] feat: unify Sessions and Conversations into single Sessions page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the duplicate Conversations page with an enhanced Sessions page: - Remove 'Conversations' nav item (was showing identical data to Sessions) - Add SelectorBar with channel filter tabs (All + auto-populated per-channel) - Show per-session context usage as a progress bar (TotalTokens/ContextTokens) - Display input/output token counts per session (โ†“in / โ†‘out) - 3-row card layout: name+status, providerยทmodelยทchannel, progress+tokens - Keep Reset/Compact/Delete action buttons from original SessionsPage - Redirect legacy 'conversations' nav tag to SessionsPage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Pages/SessionsPage.xaml | 183 +++++++----- .../Pages/SessionsPage.xaml.cs | 262 +++++++++++------- .../Windows/HubWindow.xaml | 3 - .../Windows/HubWindow.xaml.cs | 4 +- 4 files changed, 271 insertions(+), 181 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml index 4713f416a..33b36d7b4 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SessionsPage.xaml @@ -2,89 +2,122 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + NavigationCacheMode="Enabled"> - - + + + + + + + - + + + + + + + + + - + + + + - - - - - - - - - - - - - - - + + - - - - - - - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConversationsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConversationsPage.xaml.cs deleted file mode 100644 index fd4499119..000000000 --- a/src/OpenClaw.Tray.WinUI/Pages/ConversationsPage.xaml.cs +++ /dev/null @@ -1,133 +0,0 @@ -using Microsoft.UI; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media; -using OpenClaw.Shared; -using OpenClawTray.Windows; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace OpenClawTray.Pages; - -public sealed partial class ConversationsPage : Page -{ - private HubWindow? _hub; - private SessionInfo[]? _allSessions; - - public ConversationsPage() - { - InitializeComponent(); - } - - public void Initialize(HubWindow hub) - { - _hub = hub; - - if (hub.CurrentStatus != ConnectionStatus.Connected || hub.GatewayClient == null) - { - ConnectionWarning.IsOpen = true; - EmptyState.Visibility = Visibility.Collapsed; - SessionListView.ItemsSource = null; - return; - } - - ConnectionWarning.IsOpen = false; - - // Use cached data immediately, then request fresh - if (hub.LastSessions != null) - UpdateSessions(hub.LastSessions); - - _ = hub.GatewayClient.RequestSessionsAsync(); - } - - public void UpdateSessions(SessionInfo[] sessions) - { - _allSessions = sessions; - DispatcherQueue?.TryEnqueue(() => ApplyFilter()); - } - - private void ApplyFilter() - { - if (_allSessions == null || _allSessions.Length == 0) - { - if (SessionListView != null) SessionListView.ItemsSource = null; - if (EmptyState != null) EmptyState.Visibility = Visibility.Visible; - return; - } - - EmptyState.Visibility = Visibility.Collapsed; - - var filterTag = "all"; - if (FilterCombo.SelectedItem is ComboBoxItem combo) - filterTag = combo.Tag as string ?? "all"; - - IEnumerable ordered = _allSessions.OrderByDescending(s => s.UpdatedAt ?? s.LastSeen); - - List viewModels; - - switch (filterTag) - { - case "channel": - // Group by channel, flatten with group headers in details - viewModels = ordered.Select(s => ToViewModel(s, groupLabel: s.Channel ?? "unknown")).ToList(); - break; - case "status": - viewModels = ordered.Select(s => ToViewModel(s, groupLabel: s.Status)).ToList(); - break; - default: - viewModels = ordered.Select(s => ToViewModel(s)).ToList(); - break; - } - - SessionListView.ItemsSource = viewModels; - } - - private static ConversationViewModel ToViewModel(SessionInfo s, string? groupLabel = null) - { - var parts = new List(); - if (!string.IsNullOrWhiteSpace(s.Channel)) parts.Add(s.Channel!); - if (!string.IsNullOrWhiteSpace(s.Model)) parts.Add(s.Model!); - if (!string.IsNullOrWhiteSpace(s.DisplayName)) parts.Add(s.DisplayName!); - if (s.TotalTokens > 0) parts.Add($"{FormatTokenCount(s.TotalTokens)} tokens"); - if (groupLabel != null) parts.Insert(0, $"[{groupLabel}]"); - - var isActive = s.Status == "active" || s.Status == "running"; - - return new ConversationViewModel - { - Key = s.Key, - AgeText = s.AgeText, - Details = string.Join(" ยท ", parts), - StatusColor = new SolidColorBrush(isActive ? Colors.LimeGreen : Colors.Gray), - }; - } - - private void FilterCombo_SelectionChanged(object sender, SelectionChangedEventArgs e) - { - // Guard against firing during InitializeComponent before page is fully initialized - if (_hub == null) return; - ApplyFilter(); - } - - private async void OnRefresh(object sender, RoutedEventArgs e) - { - if (_hub?.GatewayClient != null) - await _hub.GatewayClient.RequestSessionsAsync(); - } - - private static string FormatTokenCount(long n) - { - if (n >= 1_000_000) return $"{n / 1_000_000.0:0.#}M"; - if (n >= 1_000) return $"{n / 1_000.0:0.#}K"; - return n.ToString(); - } -} - -public class ConversationViewModel -{ - public string Key { get; set; } = ""; - public string AgeText { get; set; } = ""; - public string Details { get; set; } = ""; - public SolidColorBrush StatusColor { get; set; } = new(Colors.Gray); -} From ab75d4466c156b064a5767a8b591cd8f1f7b108c Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Wed, 13 May 2026 18:56:39 -0700 Subject: [PATCH 126/320] Update canvas jsonlPath security tests Assert sanitized jsonlPath error responses now that internal exception details stay local to logs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/OpenClaw.Shared.Tests/A2UICapabilitySecurityTests.cs | 4 ++-- tests/OpenClaw.Shared.Tests/CapabilityTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/OpenClaw.Shared.Tests/A2UICapabilitySecurityTests.cs b/tests/OpenClaw.Shared.Tests/A2UICapabilitySecurityTests.cs index a435677b8..4438f41e2 100644 --- a/tests/OpenClaw.Shared.Tests/A2UICapabilitySecurityTests.cs +++ b/tests/OpenClaw.Shared.Tests/A2UICapabilitySecurityTests.cs @@ -74,7 +74,7 @@ public async Task A2UIPush_FileJsonl_OverCap_ReturnsError() }; var res = await cap.ExecuteAsync(req); Assert.False(res.Ok); - Assert.Contains("maximum size", res.Error); + Assert.Equal("Failed to read jsonlPath", res.Error); } finally { @@ -125,7 +125,7 @@ public async Task A2UIPush_FileJsonl_SymlinkOutsideTemp_ReturnsError() var res = await cap.ExecuteAsync(req); Assert.False(res.Ok); - Assert.Contains("temp", res.Error, StringComparison.OrdinalIgnoreCase); + Assert.Equal("Failed to read jsonlPath", res.Error); } finally { diff --git a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs index 8b75c1fb5..f5fb14925 100644 --- a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs @@ -1489,7 +1489,7 @@ public async Task A2UIPush_WithJsonlPathOutsideTempDir_ReturnsError() }; var res = await cap.ExecuteAsync(req); Assert.False(res.Ok); - Assert.Contains("temp directory", res.Error); + Assert.Equal("Failed to read jsonlPath", res.Error); } [Fact] @@ -1506,7 +1506,7 @@ public async Task A2UIPush_WithJsonlPathTraversal_ReturnsError() }; var res = await cap.ExecuteAsync(req); Assert.False(res.Ok); - Assert.Contains("temp directory", res.Error); + Assert.Equal("Failed to read jsonlPath", res.Error); } [Fact] From c6b669fc43818761fdf3171657aad70eb78ca7d4 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Wed, 13 May 2026 18:56:41 -0700 Subject: [PATCH 127/320] Update device status sanitized error test Assert the battery failure payload keeps internal exception details out of the response. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/OpenClaw.Shared.Tests/CapabilityTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs index 8b75c1fb5..93cb488c0 100644 --- a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs @@ -1813,7 +1813,7 @@ public async Task Status_BatterySection_GracefulOnFailure() Assert.True(res.Ok); var battery = GetPayload(res).GetProperty("battery"); Assert.NotEqual(JsonValueKind.Undefined, battery.GetProperty("error").ValueKind); - Assert.Contains("No battery hardware", battery.GetProperty("error").GetString()); + Assert.Equal("collection failed", battery.GetProperty("error").GetString()); // Legacy fields must still be present for backward compat Assert.True(battery.TryGetProperty("level", out _), "battery.level missing on failure path"); Assert.Equal("unknown", battery.GetProperty("state").GetString()); From f65b742c830987107350ac23c7456ed0b377d051 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Wed, 13 May 2026 19:07:26 -0700 Subject: [PATCH 128/320] fix(onboarding): per-gateway token detection in guard + idempotent Dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Scott Hanselman's review on PR #340: Blocking fix: - OnboardingExistingConfigGuard.GetSummary().HasOperatorDeviceToken only checked DeviceIdentity.HasStoredDeviceToken on the legacy root path. Modern pairings store the operator token at /gateways//device-key-ed25519.json via DeviceIdentityStore, so a fresh-paired user opening Setup/Reconfigure could overwrite a working gateway without seeing the "Replace my setup / Keep my setup" warning. - Extracted the per-gateway scan (previously private to StartupSetupState) to OnboardingExistingConfigGuard.HasAnyOperatorDeviceToken as the single source of truth. StartupSetupState.HasUsableOperatorConfiguration and GetSummary() both call it now, so the startup auto-launch decision and the in-wizard guard always agree on what counts as paired. Hardening (Scott's lower-confidence suggestion): - OnboardingState.Dismiss() is now idempotent. A double-click or repeated handler invocation no longer fires the lifecycle signal twice. Tests added: - OnboardingExistingConfigGuardTests.HasExistingConfiguration_ReturnsTrue_ WhenOperatorTokenStoredOnlyInPerGatewayDir โ€” Scott's exact test shape. - OnboardingStateTests.Dismiss_IsIdempotent_FiresDismissedAtMostOnce. Follow-up tracked separately (per Scott's note): - Make the startup token scan registry-aware (prefer the active GatewayRegistry record's identity dir over arbitrary gateways/* dirs) to avoid orphan dirs from suppressing onboarding for a different active gateway. Validation: - ./build.ps1 succeeded - Shared.Tests: 1548 passed, 28 skipped - Tray.Tests: 1182 passed (+2 new) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/OnboardingExistingConfigGuard.cs | 43 +++++++++++++++++- .../Onboarding/Services/OnboardingState.cs | 8 +++- .../Services/StartupSetupState.cs | 45 +++---------------- .../OnboardingExistingConfigGuardTests.cs | 25 +++++++++++ .../OnboardingStateTests.cs | 16 +++++++ 5 files changed, 95 insertions(+), 42 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs index 3a37e8383..1b27e8277 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs @@ -56,12 +56,53 @@ public ExistingConfigurationSummary GetSummary() HasBootstrapToken: false, HasNonDefaultGatewayUrl: !string.IsNullOrWhiteSpace(_settings.GatewayUrl) && !string.Equals(_settings.GatewayUrl, DefaultGatewayUrl, StringComparison.OrdinalIgnoreCase), - HasOperatorDeviceToken: DeviceIdentity.HasStoredDeviceToken(_identityDataPath), + HasOperatorDeviceToken: HasAnyOperatorDeviceToken(_identityDataPath), HasNodeDeviceToken: DeviceIdentity.HasStoredDeviceTokenForRole(_identityDataPath, "node"), HasCompletedOrRunningSetupState: ReadSetupStateIsActive(_setupStatePath), HasWslDistro: false); } + /// + /// Scans both the legacy root identity and per-gateway identity directories + /// for an operator device token. Modern pairings (post-GatewayRegistry) + /// write tokens to <dataPath>/gateways/<gatewayId>/device-key-ed25519.json + /// via DeviceIdentityStore; the legacy root file is kept by migration + /// but is NOT created by fresh pairings. Single source of truth shared with + /// StartupSetupState so the startup auto-launch decision and the + /// in-wizard "existing configuration" warning agree. + /// + public static bool HasAnyOperatorDeviceToken(string dataPath) + { + if (DeviceIdentity.HasStoredDeviceToken(dataPath, NullLogger.Instance)) + { + return true; + } + + var gatewaysDir = Path.Combine(dataPath, "gateways"); + if (!Directory.Exists(gatewaysDir)) + { + return false; + } + + try + { + foreach (var dir in Directory.EnumerateDirectories(gatewaysDir)) + { + if (DeviceIdentity.HasStoredDeviceToken(dir, NullLogger.Instance)) + { + return true; + } + } + } + catch (Exception) + { + // Best-effort scan โ€” IO/permission failure should not silently allow + // the wizard to be skipped, so fall through to "no usable token". + } + + return false; + } + /// /// Async-enriched summary that also probes WSL for the OpenClawGateway distro. /// diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs index c5652d5a8..116d5c333 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingState.cs @@ -24,14 +24,20 @@ public sealed class OnboardingState : IDisposable /// /// Raises . Called by pages that offer a "keep my existing - /// setup and exit the wizard" option. + /// setup and exit the wizard" option. Idempotent โ€” subsequent calls are no-ops + /// so a double-click or repeated handler invocation cannot fire the lifecycle + /// signal twice. /// public void Dismiss() { + if (_dismissed) return; + _dismissed = true; OpenClawTray.Services.Logger.Info("[OnboardingState] Dismiss invoked (user chose to keep existing setup)"); Dismissed?.Invoke(this, EventArgs.Empty); } + private bool _dismissed; + /// /// The currently displayed route. Updated by OnboardingApp on navigation. /// diff --git a/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs b/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs index 9060c780a..2588e33a3 100644 --- a/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs +++ b/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs @@ -13,48 +13,13 @@ public static bool HasStoredNodeDeviceToken(string dataPath) => /// AND a configured gateway target (non-default GatewayUrl or an SSH tunnel /// host). Both signals together indicate a working operator config โ€” guards /// against orphan tokens and against tokens-without-target stale state. + /// Token detection delegates to + /// so the + /// startup decision and the in-wizard guard agree on what counts as paired. /// public static bool HasUsableOperatorConfiguration(SettingsManager settings, string dataPath) => - HasAnyOperatorDeviceToken(dataPath) && HasAnyConfiguredGatewayTarget(settings); - - /// - /// Scans the legacy root identity AND per-gateway identity directories for an - /// operator device token. Modern pairings (post-GatewayRegistry) write tokens - /// to <dataPath>/gateways/<gatewayId>/device-key-ed25519.json - /// via DeviceIdentityStore; the legacy root file is kept by migration - /// but is NOT created by fresh pairings. - /// - internal static bool HasAnyOperatorDeviceToken(string dataPath) - { - if (DeviceIdentity.HasStoredDeviceToken(dataPath, NullLogger.Instance)) - { - return true; - } - - var gatewaysDir = Path.Combine(dataPath, "gateways"); - if (!Directory.Exists(gatewaysDir)) - { - return false; - } - - try - { - foreach (var dir in Directory.EnumerateDirectories(gatewaysDir)) - { - if (DeviceIdentity.HasStoredDeviceToken(dir, NullLogger.Instance)) - { - return true; - } - } - } - catch (Exception) - { - // Best-effort scan โ€” IO/permission failure should not silently allow - // the wizard to be skipped, so fall through to "no usable token". - } - - return false; - } + OnboardingExistingConfigGuard.HasAnyOperatorDeviceToken(dataPath) + && HasAnyConfiguredGatewayTarget(settings); /// /// True when the user has configured an actual gateway target โ€” either a diff --git a/tests/OpenClaw.Tray.Tests/OnboardingExistingConfigGuardTests.cs b/tests/OpenClaw.Tray.Tests/OnboardingExistingConfigGuardTests.cs index b297bb520..605e52164 100644 --- a/tests/OpenClaw.Tray.Tests/OnboardingExistingConfigGuardTests.cs +++ b/tests/OpenClaw.Tray.Tests/OnboardingExistingConfigGuardTests.cs @@ -30,6 +30,31 @@ public void HasExistingConfiguration_ReturnsTrue_WhenOperatorDeviceTokenExists() Assert.True(guard.HasExistingConfiguration()); } + [Fact] + public void HasExistingConfiguration_ReturnsTrue_WhenOperatorTokenStoredOnlyInPerGatewayDir() + { + // Modern pairings (post-GatewayRegistry) store the operator device token + // at /gateways//device-key-ed25519.json via + // DeviceIdentityStore. The legacy root file is NOT written for fresh + // pairings, so the guard MUST scan per-gateway directories โ€” otherwise + // a returning user opening Setup/Reconfigure would not see the + // "Replace my setup / Keep my setup" warning and could overwrite a + // working config (Hanselman PR #340 review feedback). + using var temp = new TempDir(); + var perGatewayDir = Path.Combine(temp.Path, "gateways", "gw-abc"); + Directory.CreateDirectory(perGatewayDir); + var identity = new DeviceIdentity(perGatewayDir); + identity.Initialize(); + identity.StoreDeviceToken("per-gateway-operator-token"); + var settings = new SettingsManager(temp.Path); + var guard = new OnboardingExistingConfigGuard(settings, temp.Path, temp.StatePath); + + var summary = guard.GetSummary(); + Assert.True(summary.HasOperatorDeviceToken); + Assert.True(summary.HasAny); + Assert.True(guard.HasExistingConfiguration()); + } + [Fact] public void HasExistingConfiguration_ReturnsTrue_WhenNodeDeviceTokenExists() { diff --git a/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs b/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs index 67ef89492..a7f9208a2 100644 --- a/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs +++ b/tests/OpenClaw.Tray.Tests/OnboardingStateTests.cs @@ -338,6 +338,22 @@ public void Dismiss_FiresDismissedEvent() Assert.True(fired); } + [Fact] + public void Dismiss_IsIdempotent_FiresDismissedAtMostOnce() + { + // Hardening: lifecycle signal must not fire twice if a page accidentally + // calls Dismiss again (e.g., double-click or repeated handler invocation). + var state = CreateState(); + var count = 0; + state.Dismissed += (_, _) => count++; + + state.Dismiss(); + state.Dismiss(); + state.Dismiss(); + + Assert.Equal(1, count); + } + [Fact] public void Dismiss_DoesNotFireFinishedEvent() { From 825405ecb1f086ccfc6f20675f37c7c859958096 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Wed, 13 May 2026 22:09:41 -0700 Subject: [PATCH 129/320] Fix startup setup check for per-gateway node tokens Node-mode startup was still checking only the legacy root identity file for node device tokens. Modern local setup can persist the node token under gateways//device-key-ed25519.json, so startup kept reopening onboarding after Keep my setup. Reuse the per-gateway identity scan for all token roles and add regression coverage for per-gateway node tokens in both the startup gate and existing-config guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/OnboardingExistingConfigGuard.cs | 15 +++++++++++---- .../Services/StartupSetupState.cs | 2 +- .../OnboardingExistingConfigGuardTests.cs | 18 ++++++++++++++++++ .../StartupSetupStateTests.cs | 13 +++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs index 1b27e8277..2d20ec119 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/OnboardingExistingConfigGuard.cs @@ -57,7 +57,7 @@ public ExistingConfigurationSummary GetSummary() HasNonDefaultGatewayUrl: !string.IsNullOrWhiteSpace(_settings.GatewayUrl) && !string.Equals(_settings.GatewayUrl, DefaultGatewayUrl, StringComparison.OrdinalIgnoreCase), HasOperatorDeviceToken: HasAnyOperatorDeviceToken(_identityDataPath), - HasNodeDeviceToken: DeviceIdentity.HasStoredDeviceTokenForRole(_identityDataPath, "node"), + HasNodeDeviceToken: HasAnyDeviceTokenForRole(_identityDataPath, "node"), HasCompletedOrRunningSetupState: ReadSetupStateIsActive(_setupStatePath), HasWslDistro: false); } @@ -71,9 +71,16 @@ public ExistingConfigurationSummary GetSummary() /// StartupSetupState so the startup auto-launch decision and the /// in-wizard "existing configuration" warning agree. /// - public static bool HasAnyOperatorDeviceToken(string dataPath) + public static bool HasAnyOperatorDeviceToken(string dataPath) => + HasAnyDeviceTokenForRole(dataPath, "operator"); + + /// + /// Scans both the legacy root identity and per-gateway identity directories + /// for a device token for the specified role. + /// + public static bool HasAnyDeviceTokenForRole(string dataPath, string role) { - if (DeviceIdentity.HasStoredDeviceToken(dataPath, NullLogger.Instance)) + if (DeviceIdentity.HasStoredDeviceTokenForRole(dataPath, role, NullLogger.Instance)) { return true; } @@ -88,7 +95,7 @@ public static bool HasAnyOperatorDeviceToken(string dataPath) { foreach (var dir in Directory.EnumerateDirectories(gatewaysDir)) { - if (DeviceIdentity.HasStoredDeviceToken(dir, NullLogger.Instance)) + if (DeviceIdentity.HasStoredDeviceTokenForRole(dir, role, NullLogger.Instance)) { return true; } diff --git a/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs b/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs index 2588e33a3..7e6e07313 100644 --- a/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs +++ b/src/OpenClaw.Tray.WinUI/Services/StartupSetupState.cs @@ -6,7 +6,7 @@ namespace OpenClawTray.Services; internal static class StartupSetupState { public static bool HasStoredNodeDeviceToken(string dataPath) => - DeviceIdentity.HasStoredDeviceTokenForRole(dataPath, "node", NullLogger.Instance); + OnboardingExistingConfigGuard.HasAnyDeviceTokenForRole(dataPath, "node"); /// /// True if the user has an operator device token (root or any per-gateway dir) diff --git a/tests/OpenClaw.Tray.Tests/OnboardingExistingConfigGuardTests.cs b/tests/OpenClaw.Tray.Tests/OnboardingExistingConfigGuardTests.cs index 605e52164..3953c2400 100644 --- a/tests/OpenClaw.Tray.Tests/OnboardingExistingConfigGuardTests.cs +++ b/tests/OpenClaw.Tray.Tests/OnboardingExistingConfigGuardTests.cs @@ -68,6 +68,24 @@ public void HasExistingConfiguration_ReturnsTrue_WhenNodeDeviceTokenExists() Assert.True(guard.HasExistingConfiguration()); } + [Fact] + public void HasExistingConfiguration_ReturnsTrue_WhenNodeTokenStoredOnlyInPerGatewayDir() + { + using var temp = new TempDir(); + var perGatewayDir = Path.Combine(temp.Path, "gateways", "gw-node"); + Directory.CreateDirectory(perGatewayDir); + var identity = new DeviceIdentity(perGatewayDir); + identity.Initialize(); + identity.StoreDeviceTokenForRole("node", "per-gateway-node-token"); + var settings = new SettingsManager(temp.Path); + var guard = new OnboardingExistingConfigGuard(settings, temp.Path, temp.StatePath); + + var summary = guard.GetSummary(); + Assert.True(summary.HasNodeDeviceToken); + Assert.True(summary.HasAny); + Assert.True(guard.HasExistingConfiguration()); + } + [Fact] public void HasExistingConfiguration_ReturnsTrue_WhenGatewayUrlIsNonDefault() { diff --git a/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs b/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs index 945208922..3fe118c53 100644 --- a/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs +++ b/tests/OpenClaw.Tray.Tests/StartupSetupStateTests.cs @@ -16,6 +16,19 @@ public void RequiresSetup_ReturnsFalse_WhenNodeHasStoredDeviceToken() Assert.True(StartupSetupState.CanStartNodeGateway(settings, temp.Path)); } + [Fact] + public void RequiresSetup_ReturnsFalse_WhenNodeTokenStoredOnlyInPerGatewayDir() + { + using var temp = TempSettings.Create(); + var perGatewayDir = Path.Combine(temp.Path, "gateways", "gw-node"); + Directory.CreateDirectory(perGatewayDir); + StoreNodeDeviceToken(perGatewayDir); + var settings = new SettingsManager(temp.Path) { EnableNodeMode = true }; + + Assert.False(StartupSetupState.RequiresSetup(settings, temp.Path)); + Assert.True(StartupSetupState.CanStartNodeGateway(settings, temp.Path)); + } + [Fact] public void RequiresSetup_ReturnsTrue_WhenOnlyOperatorTokenExistsForNodeMode() { From 24c2853d396facf6947a042b0d4b20058d4ae426 Mon Sep 17 00:00:00 2001 From: AlexAlves87 Date: Thu, 14 May 2026 08:21:41 +0200 Subject: [PATCH 130/320] refactor(tray): extract TrayTooltipBuilder from App.xaml.cs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuildTrayTooltip read eight App fields to assemble the tray icon tooltip string. This moves that logic into a dedicated TrayTooltipBuilder, following the same snapshot pattern established by CommandCenterStateBuilder. - Add TrayStateSnapshot (8 fields: status, activity, channels, nodes, node service, auth failure, last check time, settings) - Add TrayTooltipBuilder โ€” receives a snapshot, delegates to TrayTooltipFormatter.FitShellTooltip for the 127-char shell limit - Replace BuildTrayTooltip body in App with a two-line delegation; add CaptureTraySnapshot alongside it No observable behaviour change: tooltip content, truncation, and all three call sites (InitializeTrayIcon ร—2, UpdateTrayIcon) are unchanged. Co-Authored-By: Claude Sonnet 4.6 --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 51 +--- .../Services/TrayStateSnapshot.cs | 16 ++ .../Services/TrayTooltipBuilder.cs | 53 ++++ .../OpenClaw.Tray.Tests.csproj | 2 + .../Services/TrayTooltipBuilderTests.cs | 264 ++++++++++++++++++ 5 files changed, 348 insertions(+), 38 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Services/TrayStateSnapshot.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/TrayTooltipBuilder.cs create mode 100644 tests/OpenClaw.Tray.Tests/Services/TrayTooltipBuilderTests.cs diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 863dcff5d..018f9129f 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3294,45 +3294,20 @@ private void ApplyTrayTooltip(string tooltip) _trayIcon.Tooltip = tooltip; } - private string BuildTrayTooltip() - { - var topology = GatewayTopologyClassifier.Classify( - _settings?.GatewayUrl, - _settings?.UseSshTunnel == true, - _settings?.SshTunnelHost, - _settings?.SshTunnelLocalPort ?? 0, - _settings?.SshTunnelRemotePort ?? 0); - var channelReady = _lastChannels.Count(c => ChannelHealth.IsHealthyStatus(c.Status)); - var nodeOnline = _lastNodes.Count(n => n.IsOnline); - var nodeTotal = _lastNodes.Length; - if (nodeTotal == 0 && _nodeService?.GetLocalNodeInfo() is { } localNode) - { - nodeTotal = 1; - nodeOnline = localNode.IsOnline ? 1 : 0; - } - - var warningCount = 0; - if (_currentStatus != ConnectionStatus.Connected) - warningCount++; - if (_authFailureMessage != null) - warningCount++; - if (_lastChannels.Length == 0 && _currentStatus == ConnectionStatus.Connected) - warningCount++; - - var tooltip = $"OpenClaw Tray - {_currentStatus}; " + - $"{topology.DisplayName}; " + - $"Channels {channelReady}/{_lastChannels.Length}; " + - $"Nodes {nodeOnline}/{nodeTotal}; " + - $"Warnings {warningCount}; " + - $"Last {_lastCheckTime:HH:mm:ss}"; - - if (_currentActivity != null && !string.IsNullOrEmpty(_currentActivity.DisplayText)) - { - tooltip = $"OpenClaw Tray - {_currentActivity.DisplayText}; {_currentStatus}"; - } + private string BuildTrayTooltip() => + new TrayTooltipBuilder(CaptureTraySnapshot()).Build(); - return TrayTooltipFormatter.FitShellTooltip(tooltip); - } + private TrayStateSnapshot CaptureTraySnapshot() => new TrayStateSnapshot + { + Status = _currentStatus, + CurrentActivity = _currentActivity, + Channels = _lastChannels, + Nodes = _lastNodes, + LocalNodeFallback = _nodeService?.GetLocalNodeInfo(), + AuthFailureMessage = _authFailureMessage, + LastCheckTime = _lastCheckTime, + Settings = _settings + }; #endregion diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayStateSnapshot.cs b/src/OpenClaw.Tray.WinUI/Services/TrayStateSnapshot.cs new file mode 100644 index 000000000..458c39cb8 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/TrayStateSnapshot.cs @@ -0,0 +1,16 @@ +using OpenClaw.Shared; +using System; + +namespace OpenClawTray.Services; + +internal sealed record TrayStateSnapshot +{ + public ConnectionStatus Status { get; init; } + public AgentActivity? CurrentActivity { get; init; } + public ChannelHealth[] Channels { get; init; } = []; + public GatewayNodeInfo[] Nodes { get; init; } = []; + public GatewayNodeInfo? LocalNodeFallback { get; init; } + public string? AuthFailureMessage { get; init; } + public DateTime LastCheckTime { get; init; } + public SettingsManager? Settings { get; init; } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayTooltipBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayTooltipBuilder.cs new file mode 100644 index 000000000..5eec68029 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/TrayTooltipBuilder.cs @@ -0,0 +1,53 @@ +using OpenClaw.Shared; +using OpenClawTray.Helpers; +using System.Linq; + +namespace OpenClawTray.Services; + +internal sealed class TrayTooltipBuilder +{ + private readonly TrayStateSnapshot _snapshot; + + internal TrayTooltipBuilder(TrayStateSnapshot snapshot) + { + _snapshot = snapshot; + } + + internal string Build() + { + var topology = GatewayTopologyClassifier.Classify( + _snapshot.Settings?.GatewayUrl, + _snapshot.Settings?.UseSshTunnel == true, + _snapshot.Settings?.SshTunnelHost, + _snapshot.Settings?.SshTunnelLocalPort ?? 0, + _snapshot.Settings?.SshTunnelRemotePort ?? 0); + + var channelReady = _snapshot.Channels.Count(c => ChannelHealth.IsHealthyStatus(c.Status)); + var nodeOnline = _snapshot.Nodes.Count(n => n.IsOnline); + var nodeTotal = _snapshot.Nodes.Length; + if (nodeTotal == 0 && _snapshot.LocalNodeFallback is { } localNode) + { + nodeTotal = 1; + nodeOnline = localNode.IsOnline ? 1 : 0; + } + + var warningCount = 0; + if (_snapshot.Status != ConnectionStatus.Connected) warningCount++; + if (_snapshot.AuthFailureMessage != null) warningCount++; + if (_snapshot.Channels.Length == 0 && _snapshot.Status == ConnectionStatus.Connected) warningCount++; + + var tooltip = $"OpenClaw Tray - {_snapshot.Status}; " + + $"{topology.DisplayName}; " + + $"Channels {channelReady}/{_snapshot.Channels.Length}; " + + $"Nodes {nodeOnline}/{nodeTotal}; " + + $"Warnings {warningCount}; " + + $"Last {_snapshot.LastCheckTime:HH:mm:ss}"; + + if (_snapshot.CurrentActivity != null && !string.IsNullOrEmpty(_snapshot.CurrentActivity.DisplayText)) + { + tooltip = $"OpenClaw Tray - {_snapshot.CurrentActivity.DisplayText}; {_snapshot.Status}"; + } + + return TrayTooltipFormatter.FitShellTooltip(tooltip); + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 8ffc6e4fb..46db71e61 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -43,6 +43,8 @@ + + diff --git a/tests/OpenClaw.Tray.Tests/Services/TrayTooltipBuilderTests.cs b/tests/OpenClaw.Tray.Tests/Services/TrayTooltipBuilderTests.cs new file mode 100644 index 000000000..4c21527c3 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/Services/TrayTooltipBuilderTests.cs @@ -0,0 +1,264 @@ +using OpenClaw.Shared; +using OpenClawTray.Helpers; +using OpenClawTray.Services; +using System; +using System.IO; +using Xunit; + +namespace OpenClaw.Tray.Tests.Services; + +public sealed class TrayTooltipBuilderTests : IDisposable +{ + private static readonly DateTime FixedTime = new(2024, 1, 15, 10, 30, 45); + + private readonly string _tempDir; + + public TrayTooltipBuilderTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "OpenClawTray.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } catch { /* ignore */ } + } + + private static TrayStateSnapshot BaseConnected( + ChannelHealth[]? channels = null, + GatewayNodeInfo[]? nodes = null, + string? authFailure = null, + AgentActivity? activity = null) => new TrayStateSnapshot + { + Status = ConnectionStatus.Connected, + Channels = channels ?? [], + Nodes = nodes ?? [], + AuthFailureMessage = authFailure, + CurrentActivity = activity, + LastCheckTime = FixedTime + }; + + [Fact] + public void Build_ConnectedWithChannelsAndNodes_ContainsExpectedSegments() + { + var snapshot = BaseConnected( + channels: [new ChannelHealth { Status = "ok" }, new ChannelHealth { Status = "stopped" }], + nodes: [new GatewayNodeInfo { IsOnline = true }]); + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("OpenClaw Tray - Connected", result); + Assert.Contains("Channels 1/2", result); + Assert.Contains("Nodes 1/1", result); + Assert.Contains("Warnings 0", result); + Assert.Contains("Last 10:30:45", result); + } + + [Fact] + public void Build_ActivityWithDisplayText_OverridesStandardFormat() + { + var activity = new AgentActivity { Kind = ActivityKind.Exec, Label = "running tests", IsMain = true }; + var snapshot = BaseConnected(activity: activity); + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("๐Ÿ’ป running tests", result); + Assert.Contains("Connected", result); + Assert.DoesNotContain("Channels", result); + } + + [Fact] + public void Build_ActivityIdle_DoesNotOverrideStandardFormat() + { + var activity = new AgentActivity { Kind = ActivityKind.Idle }; + var snapshot = BaseConnected( + channels: [new ChannelHealth { Status = "ok" }], + activity: activity); + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("Channels", result); + } + + [Fact] + public void Build_StatusNotConnected_CountsOneWarning() + { + var snapshot = new TrayStateSnapshot + { + Status = ConnectionStatus.Disconnected, + Channels = [new ChannelHealth { Status = "ok" }], + Nodes = [], + LastCheckTime = FixedTime + }; + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("Warnings 1", result); + } + + [Fact] + public void Build_AuthFailureMessage_CountsOneWarning() + { + var snapshot = BaseConnected( + channels: [new ChannelHealth { Status = "ok" }], + authFailure: "token expired"); + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("Warnings 1", result); + } + + [Fact] + public void Build_ConnectedWithNoChannels_CountsOneWarning() + { + var snapshot = BaseConnected(channels: []); + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("Warnings 1", result); + } + + [Fact] + public void Build_MultipleWarningConditions_SumsCorrectly() + { + // Disconnected (+1) + auth failure (+1); no-channels warning only fires when Connected + var snapshot = new TrayStateSnapshot + { + Status = ConnectionStatus.Disconnected, + AuthFailureMessage = "expired", + Channels = [], + Nodes = [], + LastCheckTime = FixedTime + }; + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("Warnings 2", result); + } + + [Fact] + public void Build_NoNodesNoFallback_ShowsZeroNodes() + { + var snapshot = BaseConnected( + channels: [new ChannelHealth { Status = "ok" }], + nodes: []); + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("Nodes 0/0", result); + } + + [Fact] + public void Build_NoGatewayNodesButLocalFallback_UsesLocalNode() + { + var snapshot = new TrayStateSnapshot + { + Status = ConnectionStatus.Connected, + Channels = [new ChannelHealth { Status = "ok" }], + Nodes = [], + LocalNodeFallback = new GatewayNodeInfo { IsOnline = true }, + LastCheckTime = FixedTime + }; + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("Nodes 1/1", result); + } + + [Fact] + public void Build_LocalFallbackOffline_CountsAsOffline() + { + var snapshot = new TrayStateSnapshot + { + Status = ConnectionStatus.Connected, + Channels = [new ChannelHealth { Status = "ok" }], + Nodes = [], + LocalNodeFallback = new GatewayNodeInfo { IsOnline = false }, + LastCheckTime = FixedTime + }; + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("Nodes 0/1", result); + } + + [Fact] + public void Build_MultipleNodes_CountsOnlineCorrectly() + { + var snapshot = BaseConnected(nodes: + [ + new GatewayNodeInfo { IsOnline = true }, + new GatewayNodeInfo { IsOnline = false }, + new GatewayNodeInfo { IsOnline = true } + ]); + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("Nodes 2/3", result); + } + + [Fact] + public void Build_ChannelCounting_OnlyHealthyStatusesCountAsReady() + { + var snapshot = BaseConnected(channels: + [ + new ChannelHealth { Status = "ok" }, + new ChannelHealth { Status = "running" }, + new ChannelHealth { Status = "stopped" }, + new ChannelHealth { Status = "error" } + ]); + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("Channels 2/4", result); + } + + [Fact] + public void Build_LongTooltip_IsTruncatedToShellLimit() + { + var activity = new AgentActivity + { + Kind = ActivityKind.Exec, + Label = new string('x', 200), + IsMain = true + }; + var snapshot = BaseConnected(activity: activity); + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.True(result.Length <= TrayTooltipFormatter.MaxShellTooltipLength); + Assert.EndsWith("...", result); + } + + [Fact] + public void Build_NullSettings_ShowsUnknownGatewayTopology() + { + var snapshot = BaseConnected(); + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("Unknown gateway", result); + } + + [Fact] + public void Build_SshTunnelSettings_ReflectsMacOverSshTopology() + { + var settings = new SettingsManager(_tempDir) + { + UseSshTunnel = true, + SshTunnelHost = "myhost.com" + }; + var snapshot = new TrayStateSnapshot + { + Status = ConnectionStatus.Connected, + Settings = settings, + Channels = [], + Nodes = [], + LastCheckTime = FixedTime + }; + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("Mac over SSH", result); + } +} From 58c9b35697a3fd0c5229c2966f972431367ae0fd Mon Sep 17 00:00:00 2001 From: AlexAlves87 Date: Thu, 14 May 2026 09:05:22 +0200 Subject: [PATCH 131/320] fix(exec-shell): add zsh/dash/ash/ksh/fish to V1 POSIX shell recognizer V2 (ExecShellWrapperNormalizer) already recognised these shells; V1 (ExecShellWrapperParser), which is the live exec-approval gate, did not. Policy rules like `Allow: bash *` would not match a zsh invocation. Fixes #366. Co-Authored-By: Claude Sonnet 4.6 --- src/OpenClaw.Shared/ExecShellWrapperParser.cs | 7 ++++++- tests/OpenClaw.Shared.Tests/ExecShellWrapperParserTests.cs | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Shared/ExecShellWrapperParser.cs b/src/OpenClaw.Shared/ExecShellWrapperParser.cs index 10568eaaa..e63569498 100644 --- a/src/OpenClaw.Shared/ExecShellWrapperParser.cs +++ b/src/OpenClaw.Shared/ExecShellWrapperParser.cs @@ -113,7 +113,12 @@ private static (string? Payload, string? Shell, string? Error) TryExtractWrapped if (executable.Equals("bash", StringComparison.OrdinalIgnoreCase) || executable.Equals("bash.exe", StringComparison.OrdinalIgnoreCase) || executable.Equals("sh", StringComparison.OrdinalIgnoreCase) || - executable.Equals("sh.exe", StringComparison.OrdinalIgnoreCase)) + executable.Equals("sh.exe", StringComparison.OrdinalIgnoreCase) || + executable.Equals("zsh", StringComparison.OrdinalIgnoreCase) || + executable.Equals("dash", StringComparison.OrdinalIgnoreCase) || + executable.Equals("ash", StringComparison.OrdinalIgnoreCase) || + executable.Equals("ksh", StringComparison.OrdinalIgnoreCase) || + executable.Equals("fish", StringComparison.OrdinalIgnoreCase)) { for (var i = 1; i < tokens.Length; i++) { diff --git a/tests/OpenClaw.Shared.Tests/ExecShellWrapperParserTests.cs b/tests/OpenClaw.Shared.Tests/ExecShellWrapperParserTests.cs index af3724cc9..d3b6d1bce 100644 --- a/tests/OpenClaw.Shared.Tests/ExecShellWrapperParserTests.cs +++ b/tests/OpenClaw.Shared.Tests/ExecShellWrapperParserTests.cs @@ -267,6 +267,11 @@ public void Expand_Pwsh_SetsShell_ToPwsh() [InlineData("bash.exe -c echo hello")] [InlineData("sh -c echo hello")] [InlineData("sh.exe -c echo hello")] + [InlineData("zsh -c echo hello")] + [InlineData("dash -c echo hello")] + [InlineData("ash -c echo hello")] + [InlineData("ksh -c echo hello")] + [InlineData("fish -c echo hello")] public void Expand_Bash_C_ExtractsPayload(string command) { var result = Expand(command); From 0f618cfda5db8f96ca5877a62340729db1c88dbd Mon Sep 17 00:00:00 2001 From: AlexAlves87 Date: Thu, 14 May 2026 09:08:24 +0200 Subject: [PATCH 132/320] fix(node): route capture/record toasts through App.ShowToast NodeService.ShowToast called ToastContentBuilder.Show() directly, bypassing the user's sound preference (None/Subtle/Default) and the 30-second deduplication window implemented in App.ShowToast. Replace the private helper with a ToastRequested event; App subscribes and delegates to its own ShowToast, so sound and dedup are honoured for all screen-capture, screen-record, and camera toasts. Fixes #342. Co-Authored-By: Claude Sonnet 4.6 --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 4 ++ .../Services/NodeService.cs | 63 ++++++++----------- 2 files changed, 30 insertions(+), 37 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 3b74f28b3..447cb10d4 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -2316,6 +2316,7 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna identityDataPath: IdentityDataPath); _nodeService.StatusChanged += OnNodeStatusChanged; _nodeService.NotificationRequested += OnNodeNotificationRequested; + _nodeService.ToastRequested += OnNodeToastRequested; _nodeService.PairingStatusChanged += OnPairingStatusChanged; _nodeService.ChannelHealthUpdated += OnChannelHealthUpdated; _nodeService.InvokeCompleted += OnNodeInvokeCompleted; @@ -2678,6 +2679,9 @@ private void OnNodeNotificationRequested(object? sender, OpenClaw.Shared.Capabil } } + private void OnNodeToastRequested(object? sender, Microsoft.Toolkit.Uwp.Notifications.ToastContentBuilder builder) + => ShowToast(builder); + private void OnNodeInvokeCompleted(object? sender, NodeInvokeCompletedEventArgs args) { var status = args.Ok ? "completed" : "failed"; diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index 7e1c43337..828051c72 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -149,6 +149,7 @@ public sealed class NodeService : IDisposable public event EventHandler? InvokeCompleted; public event EventHandler? GatewaySelfUpdated; public event EventHandler? RecordingStateChanged; + public event EventHandler? ToastRequested; public bool IsScreenRecording { get; private set; } public bool IsCameraRecording { get; private set; } @@ -1367,7 +1368,9 @@ private async Task OnScreenCapture(ScreenCaptureArgs args) if ((now - _lastScreenCaptureNotification).TotalSeconds > 10) { _lastScreenCaptureNotification = now; - ShowToast("Toast_ScreenCaptured", "Toast_ScreenCapturedDetail"); + ToastRequested?.Invoke(this, new ToastContentBuilder() + .AddText(LocalizationHelper.GetString("Toast_ScreenCaptured")) + .AddText(LocalizationHelper.GetString("Toast_ScreenCapturedDetail"))); } return await _screenCaptureService.CaptureAsync(args); @@ -1386,14 +1389,20 @@ private async Task OnScreenRecord(ScreenRecordArgs args) SetRecordingState(RecordingType.Screen, true, args.DurationMs); try { - ShowToast("Toast_ScreenRecordingStarted", "Toast_ScreenRecordingStartedDetail"); + ToastRequested?.Invoke(this, new ToastContentBuilder() + .AddText(LocalizationHelper.GetString("Toast_ScreenRecordingStarted")) + .AddText(LocalizationHelper.GetString("Toast_ScreenRecordingStartedDetail"))); var result = await _screenRecordingService.RecordAsync(args); - ShowToast("Toast_ScreenRecordingComplete", "Toast_ScreenRecordingCompleteDetail"); + ToastRequested?.Invoke(this, new ToastContentBuilder() + .AddText(LocalizationHelper.GetString("Toast_ScreenRecordingComplete")) + .AddText(LocalizationHelper.GetString("Toast_ScreenRecordingCompleteDetail"))); return result; } catch (Exception ex) when (ex is not InvalidOperationException) { - ShowToast("Toast_ScreenRecordingFailed", "Toast_ScreenRecordingFailedDetail"); + ToastRequested?.Invoke(this, new ToastContentBuilder() + .AddText(LocalizationHelper.GetString("Toast_ScreenRecordingFailed")) + .AddText(LocalizationHelper.GetString("Toast_ScreenRecordingFailedDetail"))); throw; } finally @@ -1429,21 +1438,15 @@ private async Task OnCameraSnap(CameraSnapArgs args) } catch (UnauthorizedAccessException ex) { - try - { - new ToastContentBuilder() - .AddText(LocalizationHelper.GetString("Toast_CameraBlocked")) - .AddText(LocalizationHelper.GetString("Toast_CameraBlockedDetail")) - .Show(); - } - catch { } - + ToastRequested?.Invoke(this, new ToastContentBuilder() + .AddText(LocalizationHelper.GetString("Toast_CameraBlocked")) + .AddText(LocalizationHelper.GetString("Toast_CameraBlockedDetail"))); throw new InvalidOperationException( "Camera access blocked. Enable camera access for desktop apps in Windows Privacy settings.", ex); } } - + private async Task OnCameraClip(CameraClipArgs args) { if (_cameraCaptureService == null) @@ -1457,22 +1460,20 @@ private async Task OnCameraClip(CameraClipArgs args) SetRecordingState(RecordingType.Camera, true, args.DurationMs); try { - ShowToast("Toast_CameraRecordingStarted", "Toast_CameraRecordingStartedDetail"); + ToastRequested?.Invoke(this, new ToastContentBuilder() + .AddText(LocalizationHelper.GetString("Toast_CameraRecordingStarted")) + .AddText(LocalizationHelper.GetString("Toast_CameraRecordingStartedDetail"))); var result = await _cameraCaptureService.ClipAsync(args); - ShowToast("Toast_CameraRecordingComplete", "Toast_CameraRecordingCompleteDetail"); + ToastRequested?.Invoke(this, new ToastContentBuilder() + .AddText(LocalizationHelper.GetString("Toast_CameraRecordingComplete")) + .AddText(LocalizationHelper.GetString("Toast_CameraRecordingCompleteDetail"))); return result; } catch (UnauthorizedAccessException ex) { - try - { - new ToastContentBuilder() - .AddText(LocalizationHelper.GetString("Toast_CameraBlocked")) - .AddText(LocalizationHelper.GetString("Toast_CameraBlockedDetail")) - .Show(); - } - catch { } - + ToastRequested?.Invoke(this, new ToastContentBuilder() + .AddText(LocalizationHelper.GetString("Toast_CameraBlocked")) + .AddText(LocalizationHelper.GetString("Toast_CameraBlockedDetail"))); throw new InvalidOperationException( "Camera access blocked. Enable camera access for desktop apps in Windows Privacy settings.", ex); @@ -1783,18 +1784,6 @@ private async Task ShowRecordingCountdownAsync() await tcs.Task; } - private static void ShowToast(string titleKey, string detailKey) - { - try - { - new ToastContentBuilder() - .AddText(LocalizationHelper.GetString(titleKey)) - .AddText(LocalizationHelper.GetString(detailKey)) - .Show(); - } - catch { /* ignore notification errors */ } - } - #endregion public void Dispose() From 89a7ea77b82cd744037fabceca397217273d1940 Mon Sep 17 00:00:00 2001 From: AlexAlves87 Date: Thu, 14 May 2026 09:10:16 +0200 Subject: [PATCH 133/320] fix(exec-approvals): reject Allow rules with absolute file paths ValidateExecApprovalRules accepted patterns like C:\evil.exe as Allow rules. A local process with the MCP bearer token could use this to whitelist an attacker-controlled binary, then invoke it via system.run (two-step local EoP). Add a check: Allow rules whose pattern starts with a drive root (X:\, X:/) or a UNC/long-path prefix (\) are rejected. Legitimate rules name commands, not paths. Fixes #347. Co-Authored-By: Claude Sonnet 4.6 --- .../Capabilities/SystemCapability.cs | 11 ++++++ .../OpenClaw.Shared.Tests/CapabilityTests.cs | 35 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs index 3526bd8f0..fff2f0827 100644 --- a/src/OpenClaw.Shared/Capabilities/SystemCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/SystemCapability.cs @@ -643,6 +643,17 @@ private NodeInvokeResponse HandleExecApprovalsSet(NodeInvokeRequest request) if (normalized is "powershell *" or "pwsh *" or "cmd *" or "cmd.exe *") return $"Broad allow rule is not permitted: {pattern}"; + // Reject Allow rules whose pattern looks like an absolute file path. + // A remote .set call should never be able to whitelist a specific binary + // by path โ€” that would be a two-step EoP (compromise MCP token โ†’ whitelist + // attacker binary โ†’ invoke it). Legitimate rules name commands, not paths. + // Covers: drive-rooted paths (C:\, D:/), UNC paths (\\server\), and the + // Windows long-path namespace prefix (\\?\). + if (normalized.Length >= 3 && + ((char.IsLetter(normalized[0]) && normalized[1] == ':' && (normalized[2] == '\\' || normalized[2] == '/')) || + normalized.StartsWith(@"\\", StringComparison.Ordinal))) + return $"Absolute path allow rule is not permitted: {pattern}"; + foreach (var dangerous in DangerousAllowPatternFragments) { if (normalized.Contains(dangerous, StringComparison.Ordinal)) diff --git a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs index 1c0d40021..6aeff9652 100644 --- a/tests/OpenClaw.Shared.Tests/CapabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/CapabilityTests.cs @@ -591,6 +591,41 @@ public async Task ExecApprovalsSet_RejectsUnsafeAllowRules(string pattern) } } + [Theory] + [InlineData(@"C:\Users\Public\evil.exe")] + [InlineData(@"C:\Windows\System32\cmd.exe")] + [InlineData(@"D:/tools/run.exe")] + [InlineData(@"\\server\share\tool.exe")] + [InlineData(@"\\?\C:\evil.exe")] + public async Task ExecApprovalsSet_RejectsAbsolutePathAllowRules(string pattern) + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + var cap = new SystemCapability(NullLogger.Instance); + var policy = new ExecApprovalPolicy(tempDir, NullLogger.Instance); + cap.SetApprovalPolicy(policy); + + var escapedPattern = pattern.Replace(@"\", @"\\"); + var req = new NodeInvokeRequest + { + Id = "ea-path-allow", + Command = "system.execApprovals.set", + Args = Parse($$"""{"baseHash":"{{policy.GetPolicyHash()}}","rules":[{"pattern":"{{escapedPattern}}","action":"allow","enabled":true}],"defaultAction":"deny"}""") + }; + + var res = await cap.ExecuteAsync(req); + + Assert.False(res.Ok); + Assert.Contains("allow rule", res.Error, StringComparison.OrdinalIgnoreCase); + } + finally + { + Directory.Delete(tempDir, true); + } + } + [Fact] public async Task Run_BlockedEnvVar_ReturnsError() { From 73bd71af2135b357d74fbf226a35e306c38ac521 Mon Sep 17 00:00:00 2001 From: AlexAlves87 Date: Thu, 14 May 2026 09:31:47 +0200 Subject: [PATCH 134/320] ci: retrigger CI From f3ee4510b925794fb3f25d60d9f99a1e1f3787c1 Mon Sep 17 00:00:00 2001 From: kenehong <22486640+kenehong@users.noreply.github.com> Date: Thu, 14 May 2026 08:40:51 -0700 Subject: [PATCH 135/320] tray: shared base for v2 redesign (Fluent icons, theme brushes, perf, permissions submenu, a11y) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 334 ++++++++++-------- .../Helpers/FluentIconCatalog.cs | 85 +++++ .../Windows/TrayMenuWindow.xaml.cs | 286 +++++++++++++-- .../FluentIconCatalogTests.cs | 225 ++++++++++++ 4 files changed, 754 insertions(+), 176 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs create mode 100644 tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 3b74f28b3..f03404e47 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -1,5 +1,6 @@ using Microsoft.Toolkit.Uwp.Notifications; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; using OpenClaw.Shared; @@ -1011,8 +1012,14 @@ private void OnTrayMenuItemClicked(object? sender, string action) case "copydeviceid": CopyDeviceIdToClipboard(); break; case "copynodesummary": CopyNodeSummaryToClipboard(); break; case "exit": ExitApplication(); break; + case "about": ShowHub("about"); break; default: - if (action.StartsWith("session-reset|", StringComparison.Ordinal)) + if (action.StartsWith("perm-toggle|", StringComparison.Ordinal) + && _permToggleActions.TryGetValue(action, out var permAction)) + { + permAction(); + } + else if (action.StartsWith("session-reset|", StringComparison.Ordinal)) _ = ExecuteSessionActionAsync("reset", action["session-reset|".Length..]); else if (action.StartsWith("session-compact|", StringComparison.Ordinal)) _ = ExecuteSessionActionAsync("compact", action["session-compact|".Length..]); @@ -1210,9 +1217,41 @@ private List GetRecentActivity(int maxItems) private void BuildTrayMenuPopup(TrayMenuWindow menu) { + // Render the whole menu inside a single update batch so layout + // measures only once instead of once-per-row. Pair with EndUpdate + // in finally so an exception mid-build doesn't wedge layout. + menu.BeginUpdate(); + try + { + BuildTrayMenuPopupCore(menu); + } + finally + { + menu.EndUpdate(); + } + } + + private void BuildTrayMenuPopupCore(TrayMenuWindow menu) + { + // Stale closures from the previous build hold references to old + // ToggleAction delegates; recreate the lookup each rebuild. + _permToggleActions.Clear(); + var isConnected = _currentStatus == ConnectionStatus.Connected; var statusText = LocalizationHelper.GetConnectionStatusText(_currentStatus); + // Cache theme brushes once per build so cells don't each do a + // resource lookup. The previous implementation looked up + // SystemFill/Text brushes per row, which contributed to the + // visible right-click hitch. + var resources = Application.Current.Resources; + var successBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorSuccessBrush"]; + var cautionBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorCautionBrush"]; + var neutralBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorNeutralBrush"]; + var criticalBrush = (Microsoft.UI.Xaml.Media.Brush)resources["SystemFillColorCriticalBrush"]; + var secondaryText = (Microsoft.UI.Xaml.Media.Brush)resources["TextFillColorSecondaryBrush"]; + var captionStyle = (Style)resources["CaptionTextBlockStyle"]; + // โ”€โ”€ Brand Header (non-interactive) โ”€โ”€ menu.AddCustomElement(new StackPanel { @@ -1221,7 +1260,7 @@ private void BuildTrayMenuPopup(TrayMenuWindow menu) { new TextBlock { - Text = "๐Ÿฆž OpenClaw", + Text = $"{FluentIconCatalog.Brand} OpenClaw", FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, FontSize = 14 } @@ -1248,15 +1287,14 @@ private void BuildTrayMenuPopup(TrayMenuWindow menu) { Width = 8, Height = 8, VerticalAlignment = VerticalAlignment.Center, - Fill = new Microsoft.UI.Xaml.Media.SolidColorBrush( - isConnected ? Microsoft.UI.Colors.LimeGreen - : _currentStatus == ConnectionStatus.Connecting ? Microsoft.UI.Colors.Orange - : Microsoft.UI.Colors.Gray) + Fill = isConnected ? successBrush + : _currentStatus == ConnectionStatus.Connecting ? cautionBrush + : neutralBrush }); gwStatusRow.Children.Add(new TextBlock { Text = $"Gateway ยท {statusText}", - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], + Style = captionStyle, FontSize = 12, VerticalAlignment = VerticalAlignment.Center }); @@ -1278,8 +1316,8 @@ private void BuildTrayMenuPopup(TrayMenuWindow menu) gwInfo.Children.Add(new TextBlock { Text = string.Join(" ยท ", detailParts), - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], - Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + Style = captionStyle, + Foreground = secondaryText, FontSize = 11 }); } @@ -1297,8 +1335,8 @@ private void BuildTrayMenuPopup(TrayMenuWindow menu) gwInfo.Children.Add(new TextBlock { Text = nodeText, - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], - Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + Style = captionStyle, + Foreground = secondaryText, FontSize = 11 }); } @@ -1310,8 +1348,8 @@ private void BuildTrayMenuPopup(TrayMenuWindow menu) gwInfo.Children.Add(new TextBlock { Text = $"โš ๏ธ {_authFailureMessage}", - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], - Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.OrangeRed), + Style = captionStyle, + Foreground = criticalBrush, FontSize = 11, TextWrapping = TextWrapping.Wrap, MaxWidth = 240 @@ -1333,6 +1371,7 @@ private void BuildTrayMenuPopup(TrayMenuWindow menu) MinWidth = 0, FontSize = 11 }; + AutomationProperties.SetName(connectBtn, isConnected ? "Disconnect from gateway" : "Connect to gateway"); ToolTipService.SetToolTip(connectBtn, isConnected ? "Click to disconnect from gateway" : "Click to connect to gateway"); connectBtn.Click += (s, ev) => { @@ -1366,42 +1405,10 @@ private void BuildTrayMenuPopup(TrayMenuWindow menu) { ShowHub("connection"); }; + AutomationProperties.SetName(gwInfo, $"Gateway {statusText}. Activate to open connection settings."); menu.AddCustomElement(gwGrid); - // โ”€โ”€ Sessions โ”€โ”€ - if (_lastSessions.Length > 0) - { - menu.AddSeparator(); - - // Section header: "Sessions 3 active ยท 45K tokens" - var sessionCount = _lastSessions.Length; - var activeCount = _lastSessions.Count(s => string.Equals(s.Status, "active", StringComparison.OrdinalIgnoreCase)); - var totalTokensAll = _lastSessions.Sum(s => s.InputTokens + s.OutputTokens); - var sessionSummaryRight = $"{activeCount} active ยท {FormatTokenCount(totalTokensAll)} tokens"; - menu.AddCustomElement(BuildSectionHeader("Sessions", sessionSummaryRight)); - - // Individual session cards - foreach (var session in _lastSessions.Take(5)) - { - var card = BuildSessionCard(session); - var flyoutItems = BuildSessionFlyoutItems(session); - menu.AddFlyoutCustomItem(card, flyoutItems, action: "sessions"); - } - } - - // โ”€โ”€ Pairing Pending โ”€โ”€ - var nodePendingCount = _lastNodePairList?.Pending.Count ?? 0; - var devicePendingCount = _lastDevicePairList?.Pending.Count ?? 0; - if (nodePendingCount + devicePendingCount > 0) - { - var total = nodePendingCount + devicePendingCount; - menu.AddMenuItem($"โš ๏ธ Pairing approval pending ({total})", "๐Ÿ”—", "hub"); - } - - // โ”€โ”€ Connected Devices with inline permission toggles โ”€โ”€ - // Only show currently-connected nodes; offline/stale paired nodes - // remain visible on the full Nodes page where they can be renamed - // or forgotten. + // โ”€โ”€ Connected Devices (moved above Sessions) โ”€โ”€ var connectedNodes = _lastNodes.Where(n => n.IsOnline).ToArray(); if (connectedNodes.Length > 0) { @@ -1415,110 +1422,146 @@ private void BuildTrayMenuPopup(TrayMenuWindow menu) foreach (var node in connectedNodes.Take(5)) { - var card = BuildDeviceCard(node); + var card = BuildDeviceCard(node, successBrush, neutralBrush, secondaryText); var flyoutItems = BuildDeviceFlyoutItems(node); menu.AddFlyoutCustomItem(card, flyoutItems, action: "nodes"); - // If this node is the local machine, show capability toggles underneath + // If this node is the local machine, surface a Permissions + // submenu directly beneath it. The 3-column inline toggle + // grid that used to live here was too dense and shipped with + // emoji icons; the flyout collapses to a single row and + // routes through FluentIconCatalog. bool isLocal = node.DisplayName?.Contains(currentHost, StringComparison.OrdinalIgnoreCase) == true || node.NodeId?.Contains(currentHost, StringComparison.OrdinalIgnoreCase) == true; if (isLocal && _settings != null) { - // Build compact toggle button grid (3 columns) - var capToggles = new Dictionary Get, Action Set)>(StringComparer.OrdinalIgnoreCase) - { - ["browser"] = (() => _settings.NodeBrowserProxyEnabled, v => _settings.NodeBrowserProxyEnabled = v), - ["camera"] = (() => _settings.NodeCameraEnabled, v => _settings.NodeCameraEnabled = v), - ["canvas"] = (() => _settings.NodeCanvasEnabled, v => _settings.NodeCanvasEnabled = v), - ["screen"] = (() => _settings.NodeScreenEnabled, v => _settings.NodeScreenEnabled = v), - ["location"] = (() => _settings.NodeLocationEnabled, v => _settings.NodeLocationEnabled = v), - ["tts"] = (() => _settings.NodeTtsEnabled, v => _settings.NodeTtsEnabled = v), - ["system"] = (() => _settings.EnableNodeMode, v => _settings.EnableNodeMode = v), - }; - - // Show ALL possible capability toggles (not just gateway-reported ones) - // so disabled capabilities like TTS appear as "off" buttons - var allCaps = capToggles.Keys.ToList(); - - if (allCaps.Count > 0) - { - var columns = 3; - var grid = new Grid - { - Margin = new Thickness(28, 4, 14, 4), - ColumnSpacing = 4, - RowSpacing = 4, - HorizontalAlignment = HorizontalAlignment.Stretch - }; - for (int c = 0; c < columns; c++) - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - var rowCount = (allCaps.Count + columns - 1) / columns; - for (int r = 0; r < rowCount; r++) - grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - - for (int i = 0; i < allCaps.Count; i++) - { - var cap = allCaps[i]; - var capToggle = capToggles[cap]; - var icon = CapabilityIcons.TryGetValue(cap, out var emoji) ? emoji : "โ–ช"; - var label = char.ToUpper(cap[0]) + cap[1..]; - var isOn = capToggle.Get(); - - var btn = new ToggleButton - { - IsChecked = isOn, - HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Center, - Padding = new Thickness(6, 5, 6, 5), - MinHeight = 0, - MinWidth = 0, - Content = new TextBlock - { - Text = $"{icon} {label}", - FontSize = 11, - TextTrimming = TextTrimming.CharacterEllipsis - } - }; - var capRef = capToggle; // capture for lambda - btn.Click += (s, ev) => - { - var on = ((ToggleButton)s!).IsChecked == true; - capRef.Set(on); _settings.Save(); _ = _connectionManager?.ReconnectAsync(); - }; - Grid.SetRow(btn, i / columns); - Grid.SetColumn(btn, i % columns); - grid.Children.Add(btn); - } - menu.AddCustomElement(grid); - } + var permIcon = FluentIconCatalog.Build(FluentIconCatalog.Permissions); + menu.AddFlyoutMenuItem( + "Permissions", + permIcon, + BuildPermissionsFlyoutItems(_settings), + indent: true); } } } + // โ”€โ”€ Sessions (now below Devices) โ”€โ”€ + if (_lastSessions.Length > 0) + { + menu.AddSeparator(); + + var sessionCount = _lastSessions.Length; + var activeCount = _lastSessions.Count(s => string.Equals(s.Status, "active", StringComparison.OrdinalIgnoreCase)); + var totalTokensAll = _lastSessions.Sum(s => s.InputTokens + s.OutputTokens); + var sessionSummaryRight = $"{activeCount} active ยท {FormatTokenCount(totalTokensAll)} tokens"; + menu.AddCustomElement(BuildSectionHeader("Sessions", sessionSummaryRight)); + + foreach (var session in _lastSessions.Take(5)) + { + var card = BuildSessionCard(session, successBrush, cautionBrush, neutralBrush, criticalBrush, secondaryText); + var flyoutItems = BuildSessionFlyoutItems(session); + menu.AddFlyoutCustomItem(card, flyoutItems, action: "sessions"); + } + } + + // โ”€โ”€ Pairing approval pending (closest analogue to the spec's + // "exec approvals" row in current code) โ”€โ”€ + var nodePendingCount = _lastNodePairList?.Pending.Count ?? 0; + var devicePendingCount = _lastDevicePairList?.Pending.Count ?? 0; + if (nodePendingCount + devicePendingCount > 0) + { + var total = nodePendingCount + devicePendingCount; + menu.AddMenuItem( + $"Pairing approval pending ({total})", + FluentIconCatalog.Build(FluentIconCatalog.Approvals), + "hub"); + } + // โ”€โ”€ Actions โ”€โ”€ menu.AddSeparator(); - menu.AddMenuItem("Dashboard", "๐ŸŒ", "dashboard"); - menu.AddMenuItem("Chat", "๐Ÿ’ฌ", "openchat"); - menu.AddMenuItem("Canvas", "๐ŸŽจ", "canvas"); - menu.AddMenuItem("Voice", "๐ŸŽ™๏ธ", "voice"); - menu.AddMenuItem("Companion Settings...", "๐Ÿฆž", "companion"); - menu.AddMenuItem(LocalizationHelper.GetString("Menu_QuickSend"), "๐Ÿ“ค", "quicksend"); + menu.AddMenuItem("Dashboard", FluentIconCatalog.Build(FluentIconCatalog.Dashboard), "dashboard"); + menu.AddMenuItem("Chat", FluentIconCatalog.Build(FluentIconCatalog.Chat), "openchat"); + menu.AddMenuItem("Canvas", FluentIconCatalog.Build(FluentIconCatalog.CanvasAct), "canvas"); + menu.AddMenuItem("Voice", FluentIconCatalog.Build(FluentIconCatalog.VoiceAct), "voice"); + menu.AddMenuItem("Companion Settings...", FluentIconCatalog.Build(FluentIconCatalog.Settings), "companion"); + menu.AddMenuItem(LocalizationHelper.GetString("Menu_QuickSend"), FluentIconCatalog.Build(FluentIconCatalog.QuickSend), "quicksend"); // Setup Guide / Reconfigure entry (PR #274 must-fix #6) โ€” label flips - // based on whether prior config exists. Click dispatches "setup" which - // invokes the existing ShowOnboardingAsync handler (case in OnTrayMenuAction). + // based on whether prior config exists. var setupMenuLabel = _settings != null && new OpenClawTray.Onboarding.Services.OnboardingExistingConfigGuard(_settings, IdentityDataPath) .HasExistingConfiguration() ? LocalizationHelper.GetString("Menu_Reconfigure") : LocalizationHelper.GetString("Menu_SetupGuide"); - menu.AddMenuItem(setupMenuLabel, "๐Ÿงญ", "setup"); + menu.AddMenuItem(setupMenuLabel, FluentIconCatalog.Build(FluentIconCatalog.Setup), "setup"); - // โ”€โ”€ Exit โ”€โ”€ + // โ”€โ”€ Footer โ”€โ”€ menu.AddSeparator(); - menu.AddMenuItem(LocalizationHelper.GetString("Menu_Exit"), "โŒ", "exit"); + menu.AddMenuItem("About", FluentIconCatalog.Build(FluentIconCatalog.About), "about"); + menu.AddMenuItem(LocalizationHelper.GetString("Menu_Exit"), FluentIconCatalog.Build(FluentIconCatalog.Exit), "exit"); } + /// + /// Flyout items for the local-node Permissions row: one check-toggle per + /// capability flag in . Toggling saves the + /// setting and reconnects so the gateway picks up the new capability set. + /// + private List BuildPermissionsFlyoutItems(SettingsManager settings) + { + var items = new List + { + new() { Text = "Permissions", IsHeader = true }, + }; + + AddPermToggle(items, "Enable Windows node", FluentIconCatalog.System, + () => settings.EnableNodeMode, v => settings.EnableNodeMode = v); + AddPermToggle(items, "Allow browser control", FluentIconCatalog.Browser, + () => settings.NodeBrowserProxyEnabled, v => settings.NodeBrowserProxyEnabled = v); + AddPermToggle(items, "Allow camera", FluentIconCatalog.Camera, + () => settings.NodeCameraEnabled, v => settings.NodeCameraEnabled = v); + AddPermToggle(items, "Allow canvas", FluentIconCatalog.Canvas, + () => settings.NodeCanvasEnabled, v => settings.NodeCanvasEnabled = v); + AddPermToggle(items, "Allow screen capture", FluentIconCatalog.Screen, + () => settings.NodeScreenEnabled, v => settings.NodeScreenEnabled = v); + AddPermToggle(items, "Allow location", FluentIconCatalog.Location, + () => settings.NodeLocationEnabled, v => settings.NodeLocationEnabled = v); + AddPermToggle(items, "Allow voice (TTS)", FluentIconCatalog.Voice, + () => settings.NodeTtsEnabled, v => settings.NodeTtsEnabled = v); + + return items; + } + + private void AddPermToggle(List items, string label, string iconGlyph, Func get, Action set) + { + // Encode current state in the visible glyph: a check prefix when on, + // the capability glyph alone when off. The action id round-trips + // through OnTrayMenuItemClicked which calls back into the toggler. + var on = get(); + var prefix = on ? FluentIconCatalog.Check : iconGlyph; + var actionId = $"perm-toggle|{label}"; + items.Add(new TrayMenuFlyoutItem + { + Text = (on ? "โœ“ " : " ") + label, + Icon = prefix, + Action = actionId, + }); + _permToggleActions[actionId] = () => + { + set(!get()); + _settings?.Save(); + _ = _connectionManager?.ReconnectAsync(); + // Repaint the menu so the check mark reflects new state. + if (_trayMenuWindow != null && _trayMenuWindow.IsShown) + { + _trayMenuWindow.ClearItems(); + BuildTrayMenuPopup(_trayMenuWindow); + } + }; + } + + private readonly Dictionary _permToggleActions = new(StringComparer.Ordinal); + + private static string FormatTokenCount(long n) { if (n >= 1_000_000) return $"{n / 1_000_000.0:F1}M"; @@ -1567,7 +1610,13 @@ private static Grid BuildSectionHeader(string title, string summary) return grid; } - private static UIElement BuildSessionCard(SessionInfo session) + private static UIElement BuildSessionCard( + SessionInfo session, + Microsoft.UI.Xaml.Media.Brush successBrush, + Microsoft.UI.Xaml.Media.Brush cautionBrush, + Microsoft.UI.Xaml.Media.Brush neutralBrush, + Microsoft.UI.Xaml.Media.Brush criticalBrush, + Microsoft.UI.Xaml.Media.Brush secondaryText) { var usedTokens = session.InputTokens + session.OutputTokens; var contextTokens = session.ContextTokens > 0 ? session.ContextTokens : 200_000; @@ -1595,10 +1644,9 @@ private static UIElement BuildSessionCard(SessionInfo session) { Width = 8, Height = 8, VerticalAlignment = VerticalAlignment.Center, - Fill = new Microsoft.UI.Xaml.Media.SolidColorBrush( - isActive ? Microsoft.UI.Colors.LimeGreen - : isIdle ? Microsoft.UI.Colors.Orange - : Microsoft.UI.Colors.Gray) + Fill = isActive ? successBrush + : isIdle ? cautionBrush + : neutralBrush }; Grid.SetRow(dot, 0); Grid.SetColumn(dot, 0); @@ -1656,7 +1704,7 @@ private static UIElement BuildSessionCard(SessionInfo session) { Text = tokenText, FontSize = 11, - Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, IsTextSelectionEnabled = false }); @@ -1674,7 +1722,7 @@ private static UIElement BuildSessionCard(SessionInfo session) { Text = statusText, FontSize = 11, - Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, IsTextSelectionEnabled = false }); @@ -1694,10 +1742,9 @@ private static UIElement BuildSessionCard(SessionInfo session) Height = 3, HorizontalAlignment = HorizontalAlignment.Stretch, CornerRadius = new CornerRadius(1.5), - Foreground = new Microsoft.UI.Xaml.Media.SolidColorBrush( - pct > 80 ? Microsoft.UI.Colors.Red - : pct > 50 ? Microsoft.UI.Colors.Orange - : Microsoft.UI.Colors.LimeGreen) + Foreground = pct > 80 ? criticalBrush + : pct > 50 ? cautionBrush + : successBrush }; Grid.SetRow(bar, 2); Grid.SetColumn(bar, 0); @@ -1770,7 +1817,11 @@ private static List BuildSessionFlyoutItems(SessionInfo sess return items; } - private static UIElement BuildDeviceCard(GatewayNodeInfo node) + private static UIElement BuildDeviceCard( + GatewayNodeInfo node, + Microsoft.UI.Xaml.Media.Brush successBrush, + Microsoft.UI.Xaml.Media.Brush neutralBrush, + Microsoft.UI.Xaml.Media.Brush secondaryText) { var nodeName = !string.IsNullOrWhiteSpace(node.DisplayName) ? node.DisplayName : node.ShortId; @@ -1793,8 +1844,7 @@ private static UIElement BuildDeviceCard(GatewayNodeInfo node) { Width = 8, Height = 8, VerticalAlignment = VerticalAlignment.Center, - Fill = new Microsoft.UI.Xaml.Media.SolidColorBrush( - node.IsOnline ? Microsoft.UI.Colors.LimeGreen : Microsoft.UI.Colors.Gray) + Fill = node.IsOnline ? successBrush : neutralBrush }; Grid.SetRow(dot, 0); Grid.SetColumn(dot, 0); @@ -1864,7 +1914,7 @@ private static UIElement BuildDeviceCard(GatewayNodeInfo node) { Text = row1Text, FontSize = 11, - Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["TextFillColorSecondaryBrush"], + Foreground = secondaryText, VerticalAlignment = VerticalAlignment.Center, IsTextSelectionEnabled = false }); diff --git a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs new file mode 100644 index 000000000..416e226ce --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs @@ -0,0 +1,85 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; + +namespace OpenClawTray.Helpers; + +/// +/// Central catalog of Segoe Fluent Icons (PUA) glyphs used by the tray UI. +/// Each entry is a single-character string in the Private Use Area +/// (U+E000-U+F8FF) so call sites avoid magic literals and tests can verify +/// the catalog is well-formed. +/// +/// Codepoints are taken from the published Segoe Fluent Icons list. Where +/// a semantic match was ambiguous the closest available glyph is used and +/// noted in a comment. +/// +public static class FluentIconCatalog +{ + // โ”€โ”€ Status / state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + public const string StatusOk = "\uE73E"; // CheckMark + public const string StatusWarn = "\uE7BA"; // Warning + public const string StatusErr = "\uEA39"; // ErrorBadge + + // โ”€โ”€ Sections / categories โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + public const string Sessions = "\uE8BD"; // Message + public const string Approvals = "\uE7BA"; // Warning (re-use) + public const string Devices = "\uE772"; // Devices + public const string Permissions = "\uE72E"; // Permissions + + // โ”€โ”€ Capabilities (per-permission glyphs) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + public const string Browser = "\uE774"; // Globe + public const string Camera = "\uE722"; // Camera + public const string Canvas = "\uE70F"; // Edit + public const string Screen = "\uE7F4"; // TVMonitor + public const string Location = "\uE707"; // MapPin (Globe2 alt) + public const string Voice = "\uE720"; // Microphone + public const string System = "\uE713"; // Settings (Enable node toggle) + + // โ”€โ”€ Actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + public const string Dashboard = "\uE774"; // Globe + public const string Chat = "\uE8BD"; // Message + public const string CanvasAct = "\uE70F"; // Edit + public const string VoiceAct = "\uE720"; // Microphone + public const string Settings = "\uE713"; // Settings + public const string QuickSend = "\uE724"; // Send (Mail variant) โ€” closest universal Send glyph + public const string Setup = "\uE825"; // MapPin (compass glyph U+E1D3 isn't reliably in Segoe Fluent; MapPin is safer) + public const string About = "\uE946"; // Info + public const string Exit = "\uE7E8"; // PowerButton + + // โ”€โ”€ Affordances โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + public const string ChevronR = "\uE76C"; // ChevronRight + public const string Check = "\uE73E"; // CheckMark + + // โ”€โ”€ Brand placeholder (lobster emoji currently retained) โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + public const string Brand = "๐Ÿฆž"; + + /// + /// Builds a for the given PUA glyph using the + /// system-resolved SymbolThemeFontFamily so the icon honors + /// the user's selected icon font (Segoe Fluent Icons on Win11, Segoe + /// MDL2 Assets fallback on Win10). + /// + public static FontIcon Build(string glyph, double size = 16) + { + return new FontIcon + { + Glyph = glyph, + FontFamily = (FontFamily)Application.Current.Resources["SymbolThemeFontFamily"], + FontSize = size, + }; + } + + /// + /// True when is a single character in the + /// Unicode Private Use Area (U+E000-U+F8FF) โ€” i.e. a Segoe Fluent + /// Icons glyph rather than an emoji. + /// + public static bool IsPuaGlyph(string? value) + { + if (string.IsNullOrEmpty(value) || value.Length != 1) + return false; + var c = value[0]; + return c >= '\uE000' && c <= '\uF8FF'; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs index 83a2422f6..f03432a09 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs @@ -1,13 +1,16 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Automation.Peers; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; using OpenClaw.Shared; using OpenClawTray.Helpers; using System; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; +using Windows.System; using WinUIEx; namespace OpenClawTray.Windows; @@ -112,9 +115,31 @@ private enum MonitorDpiType private Button? _activeFlyoutOwner; private string? _activeFlyoutKey; private bool _isShown; + /// True while the menu window is visible. App can use this to + /// trigger an in-place rebuild when backing state changes mid-display. + public bool IsShown => _isShown; private global::Windows.Graphics.RectInt32? _lastMoveAndResizeRect; private uint _lastMeasureDpi; private double _lastMeasureRasterizationScale; + private int _updateDepth; + + // Cached theme brushes resolved lazily on first use, then reused for the + // lifetime of the window. The previous implementation looked up every + // brush via Application.Current.Resources for every row in the menu, + // which showed up as a visible hitch on right-click. The cache also + // collapses Microsoft.UI.Colors.Transparent allocations. + private Brush? _subtleHoverBrush; + private Brush? _dividerBrush; + private Brush? _secondaryTextBrush; + private static readonly Brush s_transparentBrush = + new SolidColorBrush(Microsoft.UI.Colors.Transparent); + + private Brush SubtleHoverBrush => + _subtleHoverBrush ??= (Brush)Application.Current.Resources["SubtleFillColorSecondaryBrush"]; + private Brush DividerBrush => + _dividerBrush ??= (Brush)Application.Current.Resources["DividerStrokeColorDefaultBrush"]; + private Brush SecondaryTextBrush => + _secondaryTextBrush ??= (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"]; public TrayMenuWindow() : this(ownerMenu: null) { @@ -141,6 +166,15 @@ private TrayMenuWindow(TrayMenuWindow? ownerMenu) // Hide when focus lost Activated += OnActivated; + + // Keyboard navigation across menu items. We intentionally do NOT + // attach per-Button KeyboardAccelerator instances โ€” those crash + // inside this WindowEx popup because their scope falls outside + // the XamlRoot (see commit 08bce3a on the abandoned settings + // branch). A single KeyDown handler on MenuPanel sidesteps the + // accelerator scope entirely. + MenuPanel.KeyDown += OnMenuPanelKeyDown; + MenuPanel.IsTabStop = false; } private void OnActivated(object sender, WindowActivatedEventArgs args) @@ -281,47 +315,52 @@ private void ShowAdjacentTo(FrameworkElement parentElement) public void AddMenuItem(string text, string? icon, string action, bool isEnabled = true, bool indent = false) { - var content = new TextBlock - { - Text = string.IsNullOrEmpty(icon) ? text : $"{icon} {text}", - TextTrimming = TextTrimming.CharacterEllipsis, - IsTextSelectionEnabled = false - }; + var iconElement = ResolveIcon(icon); + AddMenuItem(text, iconElement, action, isEnabled, indent); + } + + /// + /// Overload that takes a prebuilt (preferred for + /// PUA Segoe Fluent glyphs). Lays the row out as a 3-column grid: + /// [24-px icon] [stretching label] [auto chevron/accelerator hint]. + /// + public void AddMenuItem(string text, IconElement? icon, string action, bool isEnabled = true, bool indent = false, IconElement? trailing = null) + { + var row = BuildItemRow(text, icon, trailing, indent, out var label); - var leftPadding = indent ? 28 : 12; var button = new Button { - Content = content, + Content = row, HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Left, - Padding = new Thickness(leftPadding, 8, 12, 8), - Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Transparent), + HorizontalContentAlignment = HorizontalAlignment.Stretch, + Padding = new Thickness(0, 8, 0, 8), + Background = s_transparentBrush, BorderThickness = new Thickness(0), IsEnabled = isEnabled, Tag = action, CornerRadius = new CornerRadius(4) }; AutomationProperties.SetAutomationId(button, BuildMenuItemAutomationId(action, text)); + AutomationProperties.SetName(button, text); if (!isEnabled) - content.Opacity = 0.5; + label.Opacity = 0.5; button.Click += (s, e) => { MenuItemClicked?.Invoke(this, action); - HideCascade(); // Hide instead of close - window is reused + HideCascade(); }; - // Hover effect button.PointerEntered += (s, e) => { HideActiveFlyout(); if (button.IsEnabled) - button.Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["SubtleFillColorSecondaryBrush"]; + button.Background = SubtleHoverBrush; }; button.PointerExited += (s, e) => { - button.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Transparent); + button.Background = s_transparentBrush; }; MenuPanel.Children.Add(button); @@ -342,35 +381,40 @@ private static string BuildMenuItemAutomationId(string action, string text) public void AddFlyoutMenuItem(string text, string? icon, IEnumerable items, bool indent = false, string? action = null) { - var content = new TextBlock - { - Text = string.IsNullOrEmpty(icon) ? $"{text} โ€บ" : $"{icon} {text} โ€บ", - TextTrimming = TextTrimming.CharacterEllipsis, - IsTextSelectionEnabled = false - }; + AddFlyoutMenuItem(text, ResolveIcon(icon), items, indent, action); + } + public void AddFlyoutMenuItem(string text, IconElement? icon, IEnumerable items, bool indent = false, string? action = null) + { var flyoutItems = items.ToArray(); - var leftPadding = indent ? 28 : 12; + var chevron = FluentIconCatalog.Build(FluentIconCatalog.ChevronR, 12); + chevron.Opacity = 0.6; + AutomationProperties.SetAccessibilityView(chevron, AccessibilityView.Raw); + + var row = BuildItemRow(text, icon, chevron, indent, out _); + var button = new Button { - Content = content, + Content = row, HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Left, - Padding = new Thickness(leftPadding, 8, 12, 8), - Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Transparent), + HorizontalContentAlignment = HorizontalAlignment.Stretch, + Padding = new Thickness(0, 8, 0, 8), + Background = s_transparentBrush, BorderThickness = new Thickness(0), CornerRadius = new CornerRadius(4) }; + AutomationProperties.SetName(button, text + " submenu"); + AutomationProperties.SetAutomationId(button, BuildMenuItemAutomationId(action ?? text, text)); button.PointerEntered += (s, e) => { - button.Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["SubtleFillColorSecondaryBrush"]; + button.Background = SubtleHoverBrush; ShowCascadingFlyout(button, flyoutItems); }; button.PointerExited += (s, e) => { - button.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Transparent); + button.Background = s_transparentBrush; }; button.Click += (s, e) => { @@ -395,8 +439,9 @@ public void AddSeparator() { Height = 1, Margin = new Thickness(8, 6, 8, 6), - Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["DividerStrokeColorDefaultBrush"] + Background = DividerBrush }; + AutomationProperties.SetAccessibilityView(sep, AccessibilityView.Raw); sep.PointerEntered += (s, e) => HideActiveFlyout(); MenuPanel.Children.Add(sep); _separatorCount++; @@ -425,6 +470,8 @@ public void AddBrandHeader(string emoji, string text) VerticalAlignment = VerticalAlignment.Center }); + AutomationProperties.SetName(panel, text); + AutomationProperties.SetHeadingLevel(panel, AutomationHeadingLevel.Level1); MenuPanel.Children.Add(panel); _headerCount += 2; // Counts as larger } @@ -438,6 +485,8 @@ public void AddHeader(string text) Padding = new Thickness(12, 10, 12, 4), Opacity = 0.7 }; + AutomationProperties.SetHeadingLevel(tb, AutomationHeadingLevel.Level2); + AutomationProperties.SetName(tb, text); tb.PointerEntered += (s, e) => HideActiveFlyout(); MenuPanel.Children.Add(tb); _headerCount++; @@ -458,25 +507,45 @@ public void AddFlyoutCustomItem(UIElement content, IEnumerable { - button.Background = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["SubtleFillColorSecondaryBrush"]; + button.Background = SubtleHoverBrush; ShowCascadingFlyout(button, flyoutItems); }; button.PointerExited += (s, e) => { - button.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Transparent); + button.Background = s_transparentBrush; }; button.Click += (s, e) => { @@ -496,6 +565,107 @@ public void AddFlyoutCustomItem(UIElement content, IEnumerable + /// Builds the standard 3-column row layout used by AddMenuItem and + /// AddFlyoutMenuItem: [24-px icon] [label] [trailing]. + /// + private static Grid BuildItemRow(string text, IconElement? icon, IconElement? trailing, bool indent, out TextBlock label) + { + var grid = new Grid + { + HorizontalAlignment = HorizontalAlignment.Stretch + }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(24) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var leftPad = indent ? 28 : 12; + var iconSlot = new Border + { + Width = 24, + Margin = new Thickness(leftPad, 0, 0, 0), + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center + }; + if (icon != null) + { + AutomationProperties.SetAccessibilityView(icon, AccessibilityView.Raw); + if (icon is FrameworkElement fe) + { + fe.HorizontalAlignment = HorizontalAlignment.Center; + fe.VerticalAlignment = VerticalAlignment.Center; + } + iconSlot.Child = icon; + } + Grid.SetColumn(iconSlot, 0); + grid.Children.Add(iconSlot); + + label = new TextBlock + { + Text = text, + TextTrimming = TextTrimming.CharacterEllipsis, + IsTextSelectionEnabled = false, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(8, 0, 8, 0) + }; + Grid.SetColumn(label, 1); + grid.Children.Add(label); + + if (trailing != null) + { + if (trailing is FrameworkElement tfe) + { + tfe.Margin = new Thickness(0, 0, 12, 0); + tfe.VerticalAlignment = VerticalAlignment.Center; + } + Grid.SetColumn(trailing, 2); + grid.Children.Add(trailing); + } + + return grid; + } + + /// + /// Resolves a legacy string icon argument: a single PUA character maps to + /// a FontIcon via ; anything else (emoji, + /// multi-char) renders as a plain TextBlock so existing call sites keep + /// working while the rest of the UI migrates. + /// + private static IconElement? ResolveIcon(string? icon) + { + if (string.IsNullOrEmpty(icon)) + return null; + if (FluentIconCatalog.IsPuaGlyph(icon)) + return FluentIconCatalog.Build(icon!); + // Wrap emoji in a PathIcon-shaped surrogate via FontIcon so the row + // layout is consistent. FontIcon happily renders non-PUA characters + // using the default font family fallback. + return new FontIcon + { + Glyph = icon!, + FontSize = 14 + }; + } + + /// + /// Suppresses layout passes while a batch of Add* calls runs. Pair every + /// call with . Nested begin/end pairs are honored. + /// + public void BeginUpdate() + { + _updateDepth++; + } + + public void EndUpdate() + { + if (_updateDepth > 0) + _updateDepth--; + if (_updateDepth == 0) + { + MenuPanel.InvalidateMeasure(); + } + } + /// /// Adds a custom UIElement as a flyout-enabled menu item whose flyout content is a raw UIElement. /// @@ -786,6 +956,54 @@ private static int ConvertViewUnitsToPixels(int viewUnits, uint dpi) return Math.Max(1, (int)Math.Ceiling(viewUnits * dpi / 96.0)); } + private void OnMenuPanelKeyDown(object sender, KeyRoutedEventArgs e) + { + var buttons = MenuPanel.Children + .OfType