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] =?UTF-8?q?feat:=20video=20capture=20frontend=20=E2=80=94?= =?UTF-8?q?=20consent,=20notifications,=20activity=20stream=20&=20settings?= =?UTF-8?q?=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 a8096c0..f0685f5 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 fc4f49a..dfdf274 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -760,6 +760,7 @@ public partial class App : Application private void AddRecentActivity( string line, string category = "general", + string? icon = null, string? dashboardPath = null, string? details = null, string? sessionKey = null, @@ -768,6 +769,7 @@ public partial class App : Application ActivityStreamService.Add( category: category, title: line, + icon: icon, details: details, dashboardPath: dashboardPath, sessionKey: sessionKey, @@ -1670,6 +1672,7 @@ public partial class App : Application _nodeService.ChannelHealthUpdated += OnChannelHealthUpdated; _nodeService.InvokeCompleted += OnNodeInvokeCompleted; _nodeService.GatewaySelfUpdated += OnGatewaySelfUpdated; + _nodeService.RecordingStateChanged += OnRecordingStateChanged; if (canRunGateway) { @@ -1866,6 +1869,30 @@ public partial class App : Application } } + 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 0000000..5eee5db --- /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 0000000..79917cd --- /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 0254552..fe4de73 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 4cb8fe2..d5855bb 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ActivityPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ActivityPage.xaml.cs @@ -96,6 +96,8 @@ public sealed partial class ActivityPage : Page 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 @@ public sealed partial class ActivityPage : Page 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 733d3ad..018246f 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"> -