diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs
index a8096c0c..f0685f5b 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 fc4f49af..dfdf2748 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 00000000..5eee5db4
--- /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 00000000..79917cdb
--- /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 0254552b..fe4de731 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 4cb8fe29..d5855bb3 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 733d3ad9..018246f5 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/SettingsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
index 25062b4a..3d18b02e 100644
--- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
+++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs
@@ -14,6 +14,8 @@ public sealed partial class SettingsPage : Page
{
private HubWindow? _hub;
private bool _initialized;
+ private bool _saving;
+ private bool _isDirty;
public SettingsPage()
@@ -27,8 +29,52 @@ public void Initialize(HubWindow hub)
if (!_initialized && hub.Settings != null)
{
LoadSettings(hub.Settings);
+ hub.Settings.Saved += OnExternalSettingsChanged;
+ RegisterDirtyHandlers();
_initialized = true;
}
+ else if (_initialized && hub.Settings != null)
+ {
+ ScreenRecordingToggle.IsOn = hub.Settings.ScreenRecordingConsentGiven;
+ CameraRecordingToggle.IsOn = hub.Settings.CameraRecordingConsentGiven;
+ }
+ }
+
+ private void RegisterDirtyHandlers()
+ {
+ void MarkDirty(object s, RoutedEventArgs e) { if (_initialized) _isDirty = true; }
+
+ AutoStartToggle.Toggled += MarkDirty;
+ GlobalHotkeyToggle.Toggled += MarkDirty;
+ NotificationsToggle.Toggled += MarkDirty;
+ ScreenRecordingToggle.Toggled += MarkDirty;
+ CameraRecordingToggle.Toggled += MarkDirty;
+ NotificationSoundComboBox.SelectionChanged += (s, e) => { if (_initialized) _isDirty = true; };
+ NotifyHealthCb.Checked += MarkDirty; NotifyHealthCb.Unchecked += MarkDirty;
+ NotifyUrgentCb.Checked += MarkDirty; NotifyUrgentCb.Unchecked += MarkDirty;
+ NotifyReminderCb.Checked += MarkDirty; NotifyReminderCb.Unchecked += MarkDirty;
+ NotifyEmailCb.Checked += MarkDirty; NotifyEmailCb.Unchecked += MarkDirty;
+ NotifyCalendarCb.Checked += MarkDirty; NotifyCalendarCb.Unchecked += MarkDirty;
+ NotifyBuildCb.Checked += MarkDirty; NotifyBuildCb.Unchecked += MarkDirty;
+ NotifyStockCb.Checked += MarkDirty; NotifyStockCb.Unchecked += MarkDirty;
+ NotifyInfoCb.Checked += MarkDirty; NotifyInfoCb.Unchecked += MarkDirty;
+ }
+
+ private void OnExternalSettingsChanged(object? sender, EventArgs e)
+ {
+ if (_hub?.Settings == null || _saving || _isDirty) return;
+ DispatcherQueue.TryEnqueue(() =>
+ {
+ ScreenRecordingToggle.IsOn = _hub.Settings.ScreenRecordingConsentGiven;
+ CameraRecordingToggle.IsOn = _hub.Settings.CameraRecordingConsentGiven;
+
+ // Show that the change is already persisted
+ SaveButton.Content = "✓ Saved";
+ var timer = DispatcherQueue.CreateTimer();
+ timer.Interval = TimeSpan.FromSeconds(2);
+ timer.Tick += (t, a) => { SaveButton.Content = "Save"; timer.Stop(); };
+ timer.Start();
+ });
}
private void LoadSettings(SettingsManager settings)
@@ -57,6 +103,9 @@ private void LoadSettings(SettingsManager settings)
NotifyBuildCb.IsChecked = settings.NotifyBuild;
NotifyStockCb.IsChecked = settings.NotifyStock;
NotifyInfoCb.IsChecked = settings.NotifyInfo;
+
+ ScreenRecordingToggle.IsOn = settings.ScreenRecordingConsentGiven;
+ CameraRecordingToggle.IsOn = settings.CameraRecordingConsentGiven;
}
private void OnSave(object sender, RoutedEventArgs e)
@@ -80,7 +129,13 @@ private void OnSave(object sender, RoutedEventArgs e)
s.NotifyStock = NotifyStockCb.IsChecked ?? true;
s.NotifyInfo = NotifyInfoCb.IsChecked ?? true;
+ s.ScreenRecordingConsentGiven = ScreenRecordingToggle.IsOn;
+ s.CameraRecordingConsentGiven = CameraRecordingToggle.IsOn;
+
+ _saving = true;
s.Save();
+ _saving = false;
+ _isDirty = false;
AutoStartManager.SetAutoStart(s.AutoStart);
_hub.RaiseSettingsSaved();
@@ -98,6 +153,7 @@ private void OnCancel(object sender, RoutedEventArgs e)
_initialized = false;
LoadSettings(_hub.Settings);
_initialized = true;
+ _isDirty = false;
}
}
diff --git a/src/OpenClaw.Tray.WinUI/Services/ActivityStreamService.cs b/src/OpenClaw.Tray.WinUI/Services/ActivityStreamService.cs
index 409f6d43..d2027e4b 100644
--- a/src/OpenClaw.Tray.WinUI/Services/ActivityStreamService.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/ActivityStreamService.cs
@@ -19,6 +19,7 @@ public static void Add(
string category,
string title,
string? details = null,
+ string? icon = null,
string? dashboardPath = null,
string? sessionKey = null,
string? nodeId = null)
@@ -32,6 +33,7 @@ public static void Add(
Timestamp = DateTime.Now,
Category = string.IsNullOrWhiteSpace(category) ? "general" : category,
Title = title,
+ Icon = icon ?? "",
Details = details ?? "",
DashboardPath = dashboardPath,
SessionKey = sessionKey,
@@ -125,6 +127,7 @@ public class ActivityStreamItem
public DateTime Timestamp { get; set; } = DateTime.Now;
public string Category { get; set; } = "general";
public string Title { get; set; } = "";
+ public string Icon { get; set; } = "";
public string Details { get; set; } = "";
public string? DashboardPath { get; set; }
public string? SessionKey { get; set; }
diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
index ac78cade..3c5de6f4 100644
--- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs
@@ -25,6 +25,9 @@ public sealed class NodeService : IDisposable
private readonly DispatcherQueue _dispatcherQueue;
private readonly Func _rootProvider;
private readonly SettingsManager? _settings;
+ private readonly SemaphoreSlim _consentLock = new(1, 1);
+ private TaskCompletionSource? _screenConsentInFlight;
+ private TaskCompletionSource? _cameraConsentInFlight;
private WindowsNodeClient? _nodeClient;
private CanvasWindow? _canvasWindow;
// Invariant: _a2uiCanvasWindow is only read/written from the UI dispatcher
@@ -137,7 +140,12 @@ public sealed class NodeService : IDisposable
public event EventHandler? ChannelHealthUpdated;
public event EventHandler? InvokeCompleted;
public event EventHandler? GatewaySelfUpdated;
+ public event EventHandler? RecordingStateChanged;
+ public bool IsScreenRecording { get; private set; }
+ public bool IsCameraRecording { get; private set; }
+ public bool IsAnyRecording => IsScreenRecording || IsCameraRecording;
+
public bool IsConnected => _nodeClient?.IsConnected ?? false;
public string? NodeId => _nodeClient?.NodeId;
public bool IsPendingApproval => _nodeClient?.IsPendingApproval ?? false;
@@ -1210,27 +1218,39 @@ private async Task OnScreenCapture(ScreenCaptureArgs args)
if ((now - _lastScreenCaptureNotification).TotalSeconds > 10)
{
_lastScreenCaptureNotification = now;
- try
- {
- new ToastContentBuilder()
- .AddText(LocalizationHelper.GetString("Toast_ScreenCaptured"))
- .AddText(LocalizationHelper.GetString("Toast_ScreenCapturedDetail"))
- .Show();
- }
- catch { /* ignore notification errors */ }
+ ShowToast("Toast_ScreenCaptured", "Toast_ScreenCapturedDetail");
}
return await _screenCaptureService.CaptureAsync(args);
}
- private Task OnScreenRecord(ScreenRecordArgs args)
+ private async Task OnScreenRecord(ScreenRecordArgs args)
{
if (_screenRecordingService == null)
{
throw new InvalidOperationException("Screen recording service not available");
}
- return _screenRecordingService.RecordAsync(args);
+ await EnsureRecordingConsentAsync(RecordingType.Screen);
+ await ShowRecordingCountdownAsync();
+
+ SetRecordingState(RecordingType.Screen, true, args.DurationMs);
+ try
+ {
+ ShowToast("Toast_ScreenRecordingStarted", "Toast_ScreenRecordingStartedDetail");
+ var result = await _screenRecordingService.RecordAsync(args);
+ ShowToast("Toast_ScreenRecordingComplete", "Toast_ScreenRecordingCompleteDetail");
+ return result;
+ }
+ catch (Exception ex) when (ex is not InvalidOperationException)
+ {
+ ShowToast("Toast_ScreenRecordingFailed", "Toast_ScreenRecordingFailedDetail");
+ throw;
+ }
+ finally
+ {
+ SetRecordingState(RecordingType.Screen, false);
+ }
}
#endregion
@@ -1281,10 +1301,17 @@ private async Task OnCameraClip(CameraClipArgs args)
{
throw new InvalidOperationException("Camera capture service not available");
}
-
+
+ await EnsureRecordingConsentAsync(RecordingType.Camera);
+ await ShowRecordingCountdownAsync();
+
+ SetRecordingState(RecordingType.Camera, true, args.DurationMs);
try
{
- return await _cameraCaptureService.ClipAsync(args);
+ ShowToast("Toast_CameraRecordingStarted", "Toast_CameraRecordingStartedDetail");
+ var result = await _cameraCaptureService.ClipAsync(args);
+ ShowToast("Toast_CameraRecordingComplete", "Toast_CameraRecordingCompleteDetail");
+ return result;
}
catch (UnauthorizedAccessException ex)
{
@@ -1301,6 +1328,10 @@ private async Task OnCameraClip(CameraClipArgs args)
"Camera access blocked. Enable camera access for desktop apps in Windows Privacy settings.",
ex);
}
+ finally
+ {
+ SetRecordingState(RecordingType.Camera, false);
+ }
}
private async Task GetLocationAsync(LocationGetArgs args)
@@ -1436,6 +1467,185 @@ private Task OnSttStatusAsync(CancellationToken cancellationTok
});
}
+ #endregion
+
+ #region Recording State
+
+ private void SetRecordingState(RecordingType type, bool isActive, int durationMs = 0)
+ {
+ switch (type)
+ {
+ case RecordingType.Screen: IsScreenRecording = isActive; break;
+ case RecordingType.Camera: IsCameraRecording = isActive; break;
+ }
+
+ RecordingStateChanged?.Invoke(this, new RecordingStateEventArgs
+ {
+ Type = type,
+ IsActive = isActive,
+ DurationMs = durationMs
+ });
+ }
+
+ private async Task EnsureRecordingConsentAsync(RecordingType type)
+ {
+ if (HasRecordingConsent(type)) return;
+
+ Task? existingConsentPrompt = null;
+ TaskCompletionSource? ownedConsentPrompt = null;
+
+ await _consentLock.WaitAsync();
+ try
+ {
+ // Re-check after acquiring lock: a prior caller may have resolved consent.
+ if (HasRecordingConsent(type)) return;
+
+ var inFlight = GetConsentPrompt(type);
+ if (inFlight != null)
+ {
+ existingConsentPrompt = inFlight.Task;
+ }
+ else
+ {
+ ownedConsentPrompt = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ SetConsentPrompt(type, ownedConsentPrompt);
+ }
+ }
+ finally
+ {
+ _consentLock.Release();
+ }
+
+ if (existingConsentPrompt != null)
+ {
+ if (!await existingConsentPrompt)
+ throw new InvalidOperationException("Recording denied: user has not given consent");
+ return;
+ }
+
+ try
+ {
+ var consented = await ShowRecordingConsentDialogAsync(type);
+ ownedConsentPrompt!.TrySetResult(consented);
+
+ if (!consented)
+ throw new InvalidOperationException("Recording denied: user has not given consent");
+ }
+ catch
+ {
+ ownedConsentPrompt!.TrySetResult(false);
+ throw;
+ }
+ finally
+ {
+ await _consentLock.WaitAsync();
+ try
+ {
+ if (ReferenceEquals(GetConsentPrompt(type), ownedConsentPrompt))
+ SetConsentPrompt(type, null);
+ }
+ finally
+ {
+ _consentLock.Release();
+ }
+ }
+ }
+
+ private bool HasRecordingConsent(RecordingType type)
+ {
+ return type == RecordingType.Screen
+ ? _settings?.ScreenRecordingConsentGiven == true
+ : _settings?.CameraRecordingConsentGiven == true;
+ }
+
+ private TaskCompletionSource? GetConsentPrompt(RecordingType type)
+ {
+ return type == RecordingType.Screen
+ ? _screenConsentInFlight
+ : _cameraConsentInFlight;
+ }
+
+ private void SetConsentPrompt(RecordingType type, TaskCompletionSource? prompt)
+ {
+ if (type == RecordingType.Screen)
+ _screenConsentInFlight = prompt;
+ else
+ _cameraConsentInFlight = prompt;
+ }
+
+ private Task ShowRecordingConsentDialogAsync(RecordingType type)
+ {
+ var dialogTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ if (!_dispatcherQueue.TryEnqueue(async () =>
+ {
+ try
+ {
+ var dialog = new Dialogs.RecordingConsentDialog(type);
+ var consented = await dialog.ShowAsync();
+
+ if (consented && _settings != null)
+ {
+ if (type == RecordingType.Screen)
+ _settings.ScreenRecordingConsentGiven = true;
+ else
+ _settings.CameraRecordingConsentGiven = true;
+ _settings.Save();
+ }
+
+ dialogTcs.TrySetResult(consented);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($"[RecordingConsent] Dialog error: {ex.Message}");
+ dialogTcs.TrySetResult(false);
+ }
+ }))
+ {
+ throw new InvalidOperationException("Recording denied: unable to show consent prompt");
+ }
+
+ return dialogTcs.Task;
+ }
+
+ private async Task ShowRecordingCountdownAsync()
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ if (!_dispatcherQueue.TryEnqueue(async () =>
+ {
+ try
+ {
+ var countdown = new Dialogs.RecordingCountdownWindow(3);
+ await countdown.ShowCountdownAsync();
+ tcs.TrySetResult();
+ }
+ catch (Exception ex)
+ {
+ _logger.Error($"[RecordingCountdown] Error: {ex.Message}");
+ tcs.TrySetResult(); // Don't block recording if countdown fails
+ }
+ }))
+ {
+ // If we can't show the countdown, proceed anyway
+ return;
+ }
+
+ 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()
@@ -1478,3 +1688,16 @@ public void Dispose()
}
}
}
+
+public enum RecordingType
+{
+ Screen,
+ Camera
+}
+
+public sealed class RecordingStateEventArgs : EventArgs
+{
+ public RecordingType Type { get; init; }
+ public bool IsActive { get; init; }
+ public int DurationMs { get; init; }
+}
diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
index 733c27ea..83354622 100644
--- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs
@@ -26,6 +26,11 @@ public class SettingsManager
public static string SettingsDirectoryPath => DefaultSettingsDirectory;
public static string SettingsPath => DefaultSettingsFilePath;
+ /// Raised after settings are persisted to disk.
+ public event EventHandler? Saved;
+
+ private readonly object _saveLock = new();
+
// Connection
public string GatewayUrl { get; set; } = "ws://localhost:18789";
public string Token { get; set; } = "";
@@ -64,6 +69,8 @@ public class SettingsManager
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;
@@ -157,6 +164,8 @@ public void Load()
NodeCanvasEnabled = loaded.NodeCanvasEnabled;
NodeScreenEnabled = loaded.NodeScreenEnabled;
NodeCameraEnabled = loaded.NodeCameraEnabled;
+ ScreenRecordingConsentGiven = loaded.ScreenRecordingConsentGiven;
+ CameraRecordingConsentGiven = loaded.CameraRecordingConsentGiven;
NodeLocationEnabled = loaded.NodeLocationEnabled;
NodeBrowserProxyEnabled = loaded.NodeBrowserProxyEnabled;
NodeSttEnabled = loaded.NodeSttEnabled;
@@ -205,76 +214,82 @@ public void Load()
public void Save()
{
- try
+ lock (_saveLock)
{
- Directory.CreateDirectory(_settingsDirectory);
- // Lock the tray data dir to current user + SYSTEM + Administrators —
- // it co-locates the MCP bearer token, settings.json (which embeds
- // gateway/bootstrap credentials), and diagnostics jsonl. Other apps
- // running as the same user could otherwise read these freely.
- OpenClaw.Shared.Mcp.McpAuthToken.TryRestrictDataDirectoryAcl(_settingsDirectory);
-
- var data = new SettingsData
+ try
{
- GatewayUrl = GatewayUrl,
- Token = Token,
- BootstrapToken = string.IsNullOrWhiteSpace(BootstrapToken) ? null : BootstrapToken,
- UseSshTunnel = UseSshTunnel,
- SshTunnelUser = SshTunnelUser,
- SshTunnelHost = SshTunnelHost,
- SshTunnelRemotePort = SshTunnelRemotePort,
- SshTunnelLocalPort = SshTunnelLocalPort,
- AutoStart = AutoStart,
- GlobalHotkeyEnabled = GlobalHotkeyEnabled,
- ShowNotifications = ShowNotifications,
- NotificationSound = NotificationSound,
- NotifyHealth = NotifyHealth,
- NotifyUrgent = NotifyUrgent,
- NotifyReminder = NotifyReminder,
- NotifyEmail = NotifyEmail,
- NotifyCalendar = NotifyCalendar,
- NotifyBuild = NotifyBuild,
- NotifyStock = NotifyStock,
- NotifyInfo = NotifyInfo,
- EnableNodeMode = EnableNodeMode,
- NodeCanvasEnabled = NodeCanvasEnabled,
- NodeScreenEnabled = NodeScreenEnabled,
- NodeCameraEnabled = NodeCameraEnabled,
- NodeLocationEnabled = NodeLocationEnabled,
- NodeBrowserProxyEnabled = NodeBrowserProxyEnabled,
- NodeSttEnabled = NodeSttEnabled,
- SttLanguage = SttLanguage,
- SttModelName = SttModelName,
- SttSilenceTimeout = SttSilenceTimeout,
- VoiceTtsEnabled = VoiceTtsEnabled,
- VoiceAudioFeedback = VoiceAudioFeedback,
- NodeTtsEnabled = NodeTtsEnabled,
- TtsProvider = TtsProvider,
- TtsElevenLabsApiKey = ProtectSettingSecret(TtsElevenLabsApiKey),
- TtsElevenLabsModel = string.IsNullOrWhiteSpace(TtsElevenLabsModel) ? null : TtsElevenLabsModel,
- TtsElevenLabsVoiceId = string.IsNullOrWhiteSpace(TtsElevenLabsVoiceId) ? null : TtsElevenLabsVoiceId,
- TtsWindowsVoiceId = string.IsNullOrWhiteSpace(TtsWindowsVoiceId) ? null : TtsWindowsVoiceId,
- HubNavPaneOpen = HubNavPaneOpen,
- TtsPiperVoiceId = TtsPiperVoiceId,
- EnableMcpServer = EnableMcpServer,
- A2UIImageHosts = A2UIImageHosts.Count == 0 ? null : new List(A2UIImageHosts),
- // McpOnlyMode is legacy — never written; remains null in serialized output.
- HasSeenActivityStreamTip = HasSeenActivityStreamTip,
- SkippedUpdateTag = string.IsNullOrWhiteSpace(SkippedUpdateTag) ? null : SkippedUpdateTag,
- PreferredGatewayId = string.IsNullOrWhiteSpace(PreferredGatewayId) ? null : PreferredGatewayId,
- NotifyChatResponses = NotifyChatResponses,
- PreferStructuredCategories = PreferStructuredCategories,
- UserRules = UserRules
- };
+ Directory.CreateDirectory(_settingsDirectory);
+ // Lock the tray data dir to current user + SYSTEM + Administrators —
+ // it co-locates the MCP bearer token, settings.json (which embeds
+ // gateway/bootstrap credentials), and diagnostics jsonl. Other apps
+ // running as the same user could otherwise read these freely.
+ OpenClaw.Shared.Mcp.McpAuthToken.TryRestrictDataDirectoryAcl(_settingsDirectory);
- var json = data.ToJson();
- File.WriteAllText(_settingsFilePath, json);
-
- Logger.Info("Settings saved");
- }
- catch (Exception ex)
- {
- Logger.Error($"Failed to save settings: {ex.Message}");
+ var data = new SettingsData
+ {
+ GatewayUrl = GatewayUrl,
+ Token = Token,
+ BootstrapToken = string.IsNullOrWhiteSpace(BootstrapToken) ? null : BootstrapToken,
+ UseSshTunnel = UseSshTunnel,
+ SshTunnelUser = SshTunnelUser,
+ SshTunnelHost = SshTunnelHost,
+ SshTunnelRemotePort = SshTunnelRemotePort,
+ SshTunnelLocalPort = SshTunnelLocalPort,
+ AutoStart = AutoStart,
+ GlobalHotkeyEnabled = GlobalHotkeyEnabled,
+ ShowNotifications = ShowNotifications,
+ NotificationSound = NotificationSound,
+ NotifyHealth = NotifyHealth,
+ NotifyUrgent = NotifyUrgent,
+ NotifyReminder = NotifyReminder,
+ NotifyEmail = NotifyEmail,
+ NotifyCalendar = NotifyCalendar,
+ NotifyBuild = NotifyBuild,
+ NotifyStock = NotifyStock,
+ NotifyInfo = NotifyInfo,
+ EnableNodeMode = EnableNodeMode,
+ NodeCanvasEnabled = NodeCanvasEnabled,
+ NodeScreenEnabled = NodeScreenEnabled,
+ NodeCameraEnabled = NodeCameraEnabled,
+ ScreenRecordingConsentGiven = ScreenRecordingConsentGiven,
+ CameraRecordingConsentGiven = CameraRecordingConsentGiven,
+ NodeLocationEnabled = NodeLocationEnabled,
+ NodeBrowserProxyEnabled = NodeBrowserProxyEnabled,
+ NodeSttEnabled = NodeSttEnabled,
+ SttLanguage = SttLanguage,
+ SttModelName = SttModelName,
+ SttSilenceTimeout = SttSilenceTimeout,
+ VoiceTtsEnabled = VoiceTtsEnabled,
+ VoiceAudioFeedback = VoiceAudioFeedback,
+ NodeTtsEnabled = NodeTtsEnabled,
+ TtsProvider = TtsProvider,
+ TtsElevenLabsApiKey = ProtectSettingSecret(TtsElevenLabsApiKey),
+ TtsElevenLabsModel = string.IsNullOrWhiteSpace(TtsElevenLabsModel) ? null : TtsElevenLabsModel,
+ TtsElevenLabsVoiceId = string.IsNullOrWhiteSpace(TtsElevenLabsVoiceId) ? null : TtsElevenLabsVoiceId,
+ TtsWindowsVoiceId = string.IsNullOrWhiteSpace(TtsWindowsVoiceId) ? null : TtsWindowsVoiceId,
+ HubNavPaneOpen = HubNavPaneOpen,
+ TtsPiperVoiceId = TtsPiperVoiceId,
+ EnableMcpServer = EnableMcpServer,
+ A2UIImageHosts = A2UIImageHosts.Count == 0 ? null : new List(A2UIImageHosts),
+ // McpOnlyMode is legacy — never written; remains null in serialized output.
+ HasSeenActivityStreamTip = HasSeenActivityStreamTip,
+ SkippedUpdateTag = string.IsNullOrWhiteSpace(SkippedUpdateTag) ? null : SkippedUpdateTag,
+ PreferredGatewayId = string.IsNullOrWhiteSpace(PreferredGatewayId) ? null : PreferredGatewayId,
+ NotifyChatResponses = NotifyChatResponses,
+ PreferStructuredCategories = PreferStructuredCategories,
+ UserRules = UserRules
+ };
+
+ var json = data.ToJson();
+ File.WriteAllText(_settingsFilePath, json);
+
+ Logger.Info("Settings saved");
+ Saved?.Invoke(this, EventArgs.Empty);
+ }
+ catch (Exception ex)
+ {
+ Logger.Error($"Failed to save settings: {ex.Message}");
+ }
}
}
diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
index 5001c01e..c2a1c81a 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw
@@ -688,6 +688,116 @@ Use one of these options:
Enable camera access in Windows Privacy settings for OpenClaw Tray
+
+
+
+
+ 🔴 Screen Recording Started
+
+
+ OpenClaw agent is recording your screen
+
+
+ ✅ Screen Recording Complete
+
+
+ Screen recording has been sent to the agent
+
+
+ ❌ Screen Recording Failed
+
+
+ An error occurred while recording the screen
+
+
+ 🔴 Camera Recording Started
+
+
+ OpenClaw agent is recording from your camera
+
+
+ ✅ Camera Recording Complete
+
+
+ Camera clip has been sent to the agent
+
+
+
+
+
+ OpenClaw · Permission Request
+
+
+ Allow screen recording?
+
+
+ Allow camera recording?
+
+
+ An agent is requesting to record your screen. This will capture video from your display and send it to the agent. Your choice will be remembered for future requests until you change it in Settings.
+
+
+ An agent is requesting to record from your camera. This will capture video from your webcam and send it to the agent. Your choice will be remembered for future requests until you change it in Settings.
+
+
+ You can change this later in Settings.
+
+
+ Allow recording
+
+
+ Deny
+
+
+
+
+
+ Privacy
+
+
+ Control which capabilities agents can use on this device.
+
+
+ Allow screen recording
+
+
+ Allow camera recording
+
+
+ Privacy
+
+
+ Pre-approve capabilities so agents can use them without a permission prompt each time. You'll still see a countdown before recording starts.
+
+
+ Allow screen recording
+
+
+ Allow camera recording
+
+
+
+
+
+ Screen recording started
+
+
+ Screen recording complete
+
+
+ Camera recording started
+
+
+ Camera recording complete
+
+
+ {0} recording requested by agent
+
+
+ {0} recording sent to agent
+
+
+
⚡ New: Activity Stream
@@ -2572,4 +2682,4 @@ On your gateway host (Mac/Linux), run:
▶ Preview Voice
-
\ No newline at end of file
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
index 71520ac1..fa08322b 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw
@@ -688,6 +688,116 @@ Utilisez l'une de ces options :
Activez l'accès à la caméra dans les paramètres de confidentialité de Windows pour OpenClaw Tray
+
+
+
+
+ 🔴 Enregistrement d'écran démarré
+
+
+ L'agent OpenClaw enregistre votre écran
+
+
+ ✅ Enregistrement d'écran terminé
+
+
+ L'enregistrement d'écran a été envoyé à l'agent
+
+
+ ❌ Échec de l'enregistrement d'écran
+
+
+ Une erreur est survenue lors de l'enregistrement de l'écran
+
+
+ 🔴 Enregistrement caméra démarré
+
+
+ L'agent OpenClaw enregistre depuis votre caméra
+
+
+ ✅ Enregistrement caméra terminé
+
+
+ Le clip caméra a été envoyé à l'agent
+
+
+
+
+
+ OpenClaw · Demande d'autorisation
+
+
+ Autoriser l'enregistrement d'écran ?
+
+
+ Autoriser l'enregistrement de la caméra ?
+
+
+ Un agent demande d'enregistrer votre écran. Cela capturera la vidéo de votre affichage et l'enverra à l'agent. Votre choix sera mémorisé pour les prochaines demandes jusqu'à ce que vous le modifiiez dans les Paramètres.
+
+
+ Un agent demande d'enregistrer depuis votre caméra. Cela capturera la vidéo de votre webcam et l'enverra à l'agent. Votre choix sera mémorisé pour les prochaines demandes jusqu'à ce que vous le modifiiez dans les Paramètres.
+
+
+ Vous pouvez modifier cela ultérieurement dans les Paramètres.
+
+
+ Autoriser l'enregistrement
+
+
+ Refuser
+
+
+
+
+
+ Confidentialité
+
+
+ Contrôlez quelles fonctionnalités les agents peuvent utiliser sur cet appareil.
+
+
+ Autoriser l'enregistrement d'écran
+
+
+ Autoriser l'enregistrement caméra
+
+
+ Confidentialité
+
+
+ Pré-approuvez les fonctionnalités afin que les agents puissent les utiliser sans demander la permission à chaque fois. Un compte à rebours s'affichera toujours avant l'enregistrement.
+
+
+ Autoriser l'enregistrement d'écran
+
+
+ Autoriser l'enregistrement caméra
+
+
+
+
+
+ Enregistrement d'écran démarré
+
+
+ Enregistrement d'écran terminé
+
+
+ Enregistrement caméra démarré
+
+
+ Enregistrement caméra terminé
+
+
+ Enregistrement {0} demandé par l'agent
+
+
+ Enregistrement {0} envoyé à l'agent
+
+
+
⚡ Nouveau: Fil d'activité
@@ -2572,4 +2682,4 @@ Sur votre hôte passerelle (Mac/Linux), exécutez :
▶ Aperçu de la voix
-
\ No newline at end of file
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
index 5a0a5525..df11c18c 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw
@@ -688,6 +688,116 @@ Gebruik een van deze opties:
Schakel cameratoegang in via Windows-privacyinstellingen voor OpenClaw Tray
+
+
+
+
+ 🔴 Schermopname gestart
+
+
+ OpenClaw-agent neemt uw scherm op
+
+
+ ✅ Schermopname voltooid
+
+
+ Schermopname is naar de agent verzonden
+
+
+ ❌ Schermopname mislukt
+
+
+ Er is een fout opgetreden bij het opnemen van het scherm
+
+
+ 🔴 Camera-opname gestart
+
+
+ OpenClaw-agent neemt op vanaf uw camera
+
+
+ ✅ Camera-opname voltooid
+
+
+ Cameraclip is naar de agent verzonden
+
+
+
+
+
+ OpenClaw · Toestemmingsverzoek
+
+
+ Schermopname toestaan?
+
+
+ Camera-opname toestaan?
+
+
+ Een agent vraagt om uw scherm op te nemen. Dit zal video van uw beeldscherm vastleggen en naar de agent sturen. Uw keuze wordt onthouden voor toekomstige verzoeken totdat u deze wijzigt in Instellingen.
+
+
+ Een agent vraagt om op te nemen vanaf uw camera. Dit zal video van uw webcam vastleggen en naar de agent sturen. Uw keuze wordt onthouden voor toekomstige verzoeken totdat u deze wijzigt in Instellingen.
+
+
+ U kunt dit later wijzigen in Instellingen.
+
+
+ Opname toestaan
+
+
+ Weigeren
+
+
+
+
+
+ Privacyinstellingen
+
+
+ Bepaal welke mogelijkheden agents op dit apparaat kunnen gebruiken.
+
+
+ Schermopname toestaan
+
+
+ Camera-opname toestaan
+
+
+ Privacyinstellingen
+
+
+ Keur mogelijkheden vooraf goed zodat agents ze kunnen gebruiken zonder elke keer toestemming te vragen. Er wordt nog steeds een aftelling getoond voordat de opname begint.
+
+
+ Schermopname toestaan
+
+
+ Camera-opname toestaan
+
+
+
+
+
+ Schermopname gestart
+
+
+ Schermopname voltooid
+
+
+ Camera-opname gestart
+
+
+ Camera-opname voltooid
+
+
+ {0}-opname aangevraagd door agent
+
+
+ {0}-opname verzonden naar agent
+
+
+
⚡ Nieuw: Activiteitenstroom
@@ -2572,4 +2682,4 @@ Voer op uw gateway-host (Mac/Linux) uit:
▶ Voorbeeld van stem
-
\ No newline at end of file
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
index 063c1304..8b3ea2f3 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw
@@ -688,6 +688,116 @@
请在 Windows 隐私设置中为 OpenClaw Tray 启用相机访问
+
+
+
+
+ 🔴 屏幕录制已开始
+
+
+ OpenClaw 代理正在录制您的屏幕
+
+
+ ✅ 屏幕录制已完成
+
+
+ 屏幕录制已发送给代理
+
+
+ ❌ 屏幕录制失败
+
+
+ 录制屏幕时发生错误
+
+
+ 🔴 摄像头录制已开始
+
+
+ OpenClaw 代理正在从您的摄像头录制
+
+
+ ✅ 摄像头录制已完成
+
+
+ 摄像头录制片段已发送给代理
+
+
+
+
+
+ OpenClaw · 权限请求
+
+
+ 允许屏幕录制?
+
+
+ 允许摄像头录制?
+
+
+ 代理正在请求录制您的屏幕。这将从您的显示器捕获视频并发送给代理。您的选择将被记住用于以后的请求,直到您在设置中更改。
+
+
+ 代理正在请求从您的摄像头录制。这将从您的网络摄像头捕获视频并发送给代理。您的选择将被记住用于以后的请求,直到您在设置中更改。
+
+
+ 您可以稍后在设置中更改此项。
+
+
+ 允许录制
+
+
+ 拒绝
+
+
+
+
+
+ 隐私
+
+
+ 控制代理可以在此设备上使用哪些功能。
+
+
+ 允许屏幕录制
+
+
+ 允许摄像头录制
+
+
+ 隐私
+
+
+ 预先批准功能,以便代理无需每次都请求权限。录制开始前仍会显示倒计时。
+
+
+ 允许屏幕录制
+
+
+ 允许摄像头录制
+
+
+
+
+
+ 屏幕录制已开始
+
+
+ 屏幕录制已完成
+
+
+ 摄像头录制已开始
+
+
+ 摄像头录制已完成
+
+
+ {0}录制由代理请求
+
+
+ {0}录制已发送给代理
+
+
+
⚡ 新功能: 活动流
@@ -2572,4 +2682,4 @@
▶ 预览语音
-
\ No newline at end of file
+
diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
index f51d7b0f..3946e02f 100644
--- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
+++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw
@@ -688,6 +688,116 @@
請在 Windows 隱私設定中為 OpenClaw Tray 啟用相機訪問
+
+
+
+
+ 🔴 螢幕錄製已開始
+
+
+ OpenClaw 代理正在錄製您的螢幕
+
+
+ ✅ 螢幕錄製已完成
+
+
+ 螢幕錄製已傳送給代理
+
+
+ ❌ 螢幕錄製失敗
+
+
+ 錄製螢幕時發生錯誤
+
+
+ 🔴 攝影機錄製已開始
+
+
+ OpenClaw 代理正在從您的攝影機錄製
+
+
+ ✅ 攝影機錄製已完成
+
+
+ 攝影機錄製片段已傳送給代理
+
+
+
+
+
+ OpenClaw · 權限請求
+
+
+ 允許螢幕錄製?
+
+
+ 允許攝影機錄製?
+
+
+ 代理正在請求錄製您的螢幕。這將從您的顯示器擷取視訊並傳送給代理。您的選擇將被記住用於以後的請求,直到您在設定中變更。
+
+
+ 代理正在請求從您的攝影機錄製。這將從您的網路攝影機擷取視訊並傳送給代理。您的選擇將被記住用於以後的請求,直到您在設定中變更。
+
+
+ 您可以稍後在設定中變更此項。
+
+
+ 允許錄製
+
+
+ 拒絕
+
+
+
+
+
+ 隱私
+
+
+ 控制代理可以在此裝置上使用哪些功能。
+
+
+ 允許螢幕錄製
+
+
+ 允許攝影機錄製
+
+
+ 隱私
+
+
+ 預先核准功能,以便代理無需每次都請求權限。錄製開始前仍會顯示倒數計時。
+
+
+ 允許螢幕錄製
+
+
+ 允許攝影機錄製
+
+
+
+
+
+ 螢幕錄製已開始
+
+
+ 螢幕錄製已完成
+
+
+ 攝影機錄製已開始
+
+
+ 攝影機錄製已完成
+
+
+ {0}錄製由代理請求
+
+
+ {0}錄製已傳送給代理
+
+
+
⚡ 新功能: 串流活動
@@ -2572,4 +2682,4 @@
▶ 預覽語音
-
\ No newline at end of file
+
diff --git a/tests/OpenClaw.Tray.Tests/ConsentAndSettingsSaveTests.cs b/tests/OpenClaw.Tray.Tests/ConsentAndSettingsSaveTests.cs
new file mode 100644
index 00000000..a02ee205
--- /dev/null
+++ b/tests/OpenClaw.Tray.Tests/ConsentAndSettingsSaveTests.cs
@@ -0,0 +1,109 @@
+using OpenClawTray.Services;
+
+namespace OpenClaw.Tray.Tests;
+
+public class ConsentAndSettingsSaveTests
+{
+ [Fact]
+ public async Task Save_IsThreadSafe_ConcurrentCallsDoNotCorruptFile()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), $"openclaw-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var settings = new SettingsManager(tempDir);
+ settings.GatewayUrl = "ws://localhost:9999";
+
+ // Fire many concurrent saves — none should throw or corrupt
+ var tasks = Enumerable.Range(0, 20).Select(i =>
+ {
+ return Task.Run(() =>
+ {
+ settings.ScreenRecordingConsentGiven = (i % 2 == 0);
+ settings.Save();
+ });
+ }).ToArray();
+
+ await Task.WhenAll(tasks);
+
+ // Verify file is still valid JSON and loadable
+ var reloaded = new SettingsManager(tempDir);
+ Assert.Equal("ws://localhost:9999", reloaded.GatewayUrl);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void Save_RaisesSavedEvent()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), $"openclaw-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var settings = new SettingsManager(tempDir);
+ var eventRaised = false;
+ settings.Saved += (s, e) => eventRaised = true;
+
+ settings.ScreenRecordingConsentGiven = true;
+ settings.Save();
+
+ Assert.True(eventRaised);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void ConsentFlags_PersistAcrossReload()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), $"openclaw-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var settings = new SettingsManager(tempDir);
+ Assert.False(settings.ScreenRecordingConsentGiven);
+ Assert.False(settings.CameraRecordingConsentGiven);
+
+ settings.ScreenRecordingConsentGiven = true;
+ settings.CameraRecordingConsentGiven = true;
+ settings.Save();
+
+ var reloaded = new SettingsManager(tempDir);
+ Assert.True(reloaded.ScreenRecordingConsentGiven);
+ Assert.True(reloaded.CameraRecordingConsentGiven);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void ConsentFlags_CanBeRevoked()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), $"openclaw-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var settings = new SettingsManager(tempDir);
+ settings.ScreenRecordingConsentGiven = true;
+ settings.Save();
+
+ // Revoke
+ settings.ScreenRecordingConsentGiven = false;
+ settings.Save();
+
+ var reloaded = new SettingsManager(tempDir);
+ Assert.False(reloaded.ScreenRecordingConsentGiven);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, recursive: true);
+ }
+ }
+}
diff --git a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs
index 03b84c6f..d8289b7d 100644
--- a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs
+++ b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs
@@ -35,6 +35,8 @@ public void RoundTrip_AllFields_Preserved()
NodeCanvasEnabled = false,
NodeScreenEnabled = true,
NodeCameraEnabled = false,
+ ScreenRecordingConsentGiven = true,
+ CameraRecordingConsentGiven = true,
NodeLocationEnabled = true,
NodeBrowserProxyEnabled = false,
NodeSttEnabled = true,
@@ -89,6 +91,8 @@ public void RoundTrip_AllFields_Preserved()
Assert.Equal(original.NodeCanvasEnabled, restored.NodeCanvasEnabled);
Assert.Equal(original.NodeScreenEnabled, restored.NodeScreenEnabled);
Assert.Equal(original.NodeCameraEnabled, restored.NodeCameraEnabled);
+ Assert.Equal(original.ScreenRecordingConsentGiven, restored.ScreenRecordingConsentGiven);
+ Assert.Equal(original.CameraRecordingConsentGiven, restored.CameraRecordingConsentGiven);
Assert.Equal(original.NodeLocationEnabled, restored.NodeLocationEnabled);
Assert.Equal(original.NodeBrowserProxyEnabled, restored.NodeBrowserProxyEnabled);
Assert.Equal(original.NodeSttEnabled, restored.NodeSttEnabled);
@@ -160,6 +164,8 @@ public void MissingFields_UseDefaults()
Assert.True(settings.NodeCanvasEnabled);
Assert.True(settings.NodeScreenEnabled);
Assert.True(settings.NodeCameraEnabled);
+ Assert.False(settings.ScreenRecordingConsentGiven);
+ Assert.False(settings.CameraRecordingConsentGiven);
Assert.True(settings.NodeLocationEnabled);
Assert.True(settings.NodeBrowserProxyEnabled);
Assert.False(settings.NodeSttEnabled);
@@ -231,6 +237,8 @@ public void BackwardCompatibility_OldSettingsWithoutNewFields()
Assert.True(settings.NodeCanvasEnabled);
Assert.True(settings.NodeScreenEnabled);
Assert.True(settings.NodeCameraEnabled);
+ Assert.False(settings.ScreenRecordingConsentGiven);
+ Assert.False(settings.CameraRecordingConsentGiven);
Assert.True(settings.NodeLocationEnabled);
Assert.True(settings.NodeBrowserProxyEnabled);
Assert.False(settings.NodeSttEnabled);
@@ -254,6 +262,32 @@ public void InvalidJson_ReturnsNull()
Assert.Null(SettingsData.FromJson("not json at all"));
}
+ [Fact]
+ public void SettingsManager_PersistsRecordingConsentFlags()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "OpenClaw.Tray.Tests", Guid.NewGuid().ToString("N"));
+
+ try
+ {
+ var settings = new SettingsManager(dir)
+ {
+ ScreenRecordingConsentGiven = true,
+ CameraRecordingConsentGiven = true
+ };
+
+ settings.Save();
+
+ var reloaded = new SettingsManager(dir);
+ Assert.True(reloaded.ScreenRecordingConsentGiven);
+ Assert.True(reloaded.CameraRecordingConsentGiven);
+ }
+ finally
+ {
+ if (Directory.Exists(dir))
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
[WindowsFact]
public void SettingsManager_ProtectsElevenLabsApiKeyForStorage()
{