Skip to content
2 changes: 2 additions & 0 deletions src/OpenClaw.Shared/SettingsData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions src/OpenClaw.Tray.WinUI/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
/// Ensures the managed SSH tunnel is started using the current settings.
/// Used by the onboarding ConnectionPage when the user picks the SSH topology.
/// </summary>
public void EnsureSshTunnelStarted() => _sshTunnelService?.EnsureStarted(_settings);

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-x64)

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-x64)

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-arm64)

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-arm64)

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build-msix (win-x64)

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

Check warning on line 48 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build-msix (win-arm64)

Possible null reference argument for parameter 'settings' in 'void SshTunnelService.EnsureStarted(SettingsManager settings)'.

/// <summary>
/// Returns the HWND of the active onboarding window, or IntPtr.Zero if none.
Expand Down Expand Up @@ -376,7 +376,7 @@
StartDeepLinkServer();

// Register global hotkey if enabled
if (_settings.GlobalHotkeyEnabled)

Check warning on line 379 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / test

Dereference of a possibly null reference.

Check warning on line 379 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / test

Dereference of a possibly null reference.

Check warning on line 379 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-x64)

Dereference of a possibly null reference.

Check warning on line 379 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-x64)

Dereference of a possibly null reference.

Check warning on line 379 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-arm64)

Dereference of a possibly null reference.

Check warning on line 379 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build (win-arm64)

Dereference of a possibly null reference.

Check warning on line 379 in src/OpenClaw.Tray.WinUI/App.xaml.cs

View workflow job for this annotation

GitHub Actions / build-msix (win-arm64)

Dereference of a possibly null reference.
{
_globalHotkey = new GlobalHotkeyService();
_globalHotkey.HotkeyPressed += OnGlobalHotkeyPressed;
Expand Down Expand Up @@ -760,6 +760,7 @@
private void AddRecentActivity(
string line,
string category = "general",
string? icon = null,
string? dashboardPath = null,
string? details = null,
string? sessionKey = null,
Expand All @@ -768,6 +769,7 @@
ActivityStreamService.Add(
category: category,
title: line,
icon: icon,
details: details,
dashboardPath: dashboardPath,
sessionKey: sessionKey,
Expand Down Expand Up @@ -1670,6 +1672,7 @@
_nodeService.ChannelHealthUpdated += OnChannelHealthUpdated;
_nodeService.InvokeCompleted += OnNodeInvokeCompleted;
_nodeService.GatewaySelfUpdated += OnGatewaySelfUpdated;
_nodeService.RecordingStateChanged += OnRecordingStateChanged;

if (canRunGateway)
{
Expand Down Expand Up @@ -1866,6 +1869,30 @@
}
}

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}");
Expand Down
195 changes: 195 additions & 0 deletions src/OpenClaw.Tray.WinUI/Dialogs/RecordingConsentDialog.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Privacy consent dialog shown before the first screen or camera recording.
/// Parameterized by recording type so each capability gets its own consent.
/// </summary>
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<bool> _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<bool> ShowAsync()

Check warning on line 175 in src/OpenClaw.Tray.WinUI/Dialogs/RecordingConsentDialog.cs

View workflow job for this annotation

GitHub Actions / test

The member 'RecordingConsentDialog.ShowAsync()' does not hide an accessible member. The new keyword is not required.

Check warning on line 175 in src/OpenClaw.Tray.WinUI/Dialogs/RecordingConsentDialog.cs

View workflow job for this annotation

GitHub Actions / test

The member 'RecordingConsentDialog.ShowAsync()' does not hide an accessible member. The new keyword is not required.

Check warning on line 175 in src/OpenClaw.Tray.WinUI/Dialogs/RecordingConsentDialog.cs

View workflow job for this annotation

GitHub Actions / build (win-x64)

The member 'RecordingConsentDialog.ShowAsync()' does not hide an accessible member. The new keyword is not required.

Check warning on line 175 in src/OpenClaw.Tray.WinUI/Dialogs/RecordingConsentDialog.cs

View workflow job for this annotation

GitHub Actions / build (win-x64)

The member 'RecordingConsentDialog.ShowAsync()' does not hide an accessible member. The new keyword is not required.

Check warning on line 175 in src/OpenClaw.Tray.WinUI/Dialogs/RecordingConsentDialog.cs

View workflow job for this annotation

GitHub Actions / build (win-arm64)

The member 'RecordingConsentDialog.ShowAsync()' does not hide an accessible member. The new keyword is not required.

Check warning on line 175 in src/OpenClaw.Tray.WinUI/Dialogs/RecordingConsentDialog.cs

View workflow job for this annotation

GitHub Actions / build (win-arm64)

The member 'RecordingConsentDialog.ShowAsync()' does not hide an accessible member. The new keyword is not required.

Check warning on line 175 in src/OpenClaw.Tray.WinUI/Dialogs/RecordingConsentDialog.cs

View workflow job for this annotation

GitHub Actions / build-msix (win-x64)

The member 'RecordingConsentDialog.ShowAsync()' does not hide an accessible member. The new keyword is not required.

Check warning on line 175 in src/OpenClaw.Tray.WinUI/Dialogs/RecordingConsentDialog.cs

View workflow job for this annotation

GitHub Actions / build-msix (win-arm64)

The member 'RecordingConsentDialog.ShowAsync()' does not hide an accessible member. The new keyword is not required.
{
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;
}
}
133 changes: 133 additions & 0 deletions src/OpenClaw.Tray.WinUI/Dialogs/RecordingCountdownWindow.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Compact chromeless countdown overlay (3-2-1) shown before recording starts.
/// Displays as a small floating dark pill with a white countdown number.
/// </summary>
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;
}
}
Loading
Loading