feat: add setup wizard with pairing-aware connection test (#201)

Add SetupWizardWindow with 3-step onboarding flow:
- Step 1: Paste setup code (auto-decodes URL+token) or manual entry
  with connection test that understands pairing-required as success
- Step 2: Optional node mode with device ID and approve instructions
- Step 3: Done - saves settings and reconnects

Integration:
- Replace WelcomeDialog with wizard on first run (empty token)
- Add Setup Guide menu item to tray menu
- Add openclaw://setup deep link
- Guard node service against empty token (no crash)
- Contextual error messages for token mismatch, origin rejection,
  rate limiting, and pairing required

Addresses #199
This commit is contained in:
Scott Hanselman 2026-04-22 18:22:28 -07:00 committed by GitHub
parent 9134be49e1
commit f235f3f999
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 640 additions and 7 deletions

2
.gitignore vendored
View File

@ -344,3 +344,5 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
Output/
*.lscache
test_ws.py

View File

@ -275,7 +275,7 @@ public partial class App : Application
// First-run check
if (string.IsNullOrWhiteSpace(_settings.Token))
{
await ShowFirstRunWelcomeAsync();
await ShowSetupWizardAsync();
}
// Initialize tray icon (window-less pattern from WinUIEx)
@ -542,6 +542,7 @@ public partial class App : Application
case "activity": ShowActivityStream(); break;
case "healthcheck": _ = RunHealthCheckAsync(userInitiated: true); break;
case "settings": ShowSettings(); break;
case "setup": _ = ShowSetupWizardAsync(); break;
case "autostart": ToggleAutoStart(); break;
case "log": OpenLogFile(); break;
case "copydeviceid": CopyDeviceIdToClipboard(); break;
@ -942,8 +943,9 @@ public partial class App : Application
menu.AddSeparator();
// Settings
// Settings & Setup
menu.AddMenuItem(LocalizationHelper.GetString("Menu_Settings"), "⚙️", "settings");
menu.AddMenuItem("Setup Guide...", "🧭", "setup");
var autoStartText = (_settings?.AutoStart ?? false)
? LocalizationHelper.GetString("Menu_AutoStartEnabled")
: LocalizationHelper.GetString("Menu_AutoStart");
@ -1141,6 +1143,11 @@ public partial class App : Application
{
if (_settings == null || !_settings.EnableNodeMode) return;
if (_dispatcherQueue == null) return;
if (string.IsNullOrWhiteSpace(_settings.Token))
{
Logger.Warn("Node mode enabled but no token configured — skipping node service. Run Setup Guide to configure.");
return;
}
if (!EnsureSshTunnelConfigured()) return;
try
@ -1753,14 +1760,41 @@ public partial class App : Application
_activityStreamWindow.Activate();
}
private async Task ShowFirstRunWelcomeAsync()
private SetupWizardWindow? _setupWizard;
private async Task ShowSetupWizardAsync()
{
var dialog = new WelcomeDialog();
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
if (_settings == null) return;
if (_setupWizard != null)
{
ShowSettings();
try { _setupWizard.Activate(); return; } catch { _setupWizard = null; }
}
_setupWizard = new SetupWizardWindow(_settings);
_setupWizard.SetupCompleted += (s, e) =>
{
Logger.Info("Setup wizard completed, reinitializing connections");
_setupWizard = null;
// Mirror OnSettingsSaved — clean up both, then start only one
UnsubscribeGatewayEvents();
_gatewayClient?.Dispose();
_gatewayClient = null;
var oldNodeService = _nodeService;
_nodeService = null;
try { oldNodeService?.Dispose(); } catch (Exception ex) { Logger.Warn($"Node dispose error: {ex.Message}"); }
_currentStatus = ConnectionStatus.Disconnected;
UpdateTrayIcon();
if (_settings.EnableNodeMode)
InitializeNodeService();
else
InitializeGatewayClient();
};
_setupWizard.Closed += (s, e) => _setupWizard = null;
_setupWizard.Activate();
}
private void ShowSurfaceImprovementsTipIfNeeded()
@ -2032,6 +2066,7 @@ public partial class App : Application
DeepLinkHandler.Handle(uri, new DeepLinkActions
{
OpenSettings = ShowSettings,
OpenSetup = () => _ = ShowSetupWizardAsync(),
OpenChat = ShowWebChat,
OpenDashboard = OpenDashboard,
OpenQuickSend = ShowQuickSend,

View File

@ -59,6 +59,10 @@ public static class DeepLinkHandler
actions.OpenSettings?.Invoke();
break;
case "setup":
actions.OpenSetup?.Invoke();
break;
case "chat":
actions.OpenChat?.Invoke();
break;
@ -110,6 +114,7 @@ public static class DeepLinkHandler
public class DeepLinkActions
{
public Action? OpenSettings { get; set; }
public Action? OpenSetup { get; set; }
public Action? OpenChat { get; set; }
public Action<string?>? OpenDashboard { get; set; }
public Action<string?>? OpenQuickSend { get; set; }

View File

@ -0,0 +1,591 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Text;
using OpenClaw.Shared;
using OpenClawTray.Helpers;
using OpenClawTray.Services;
using System;
using System.Threading.Tasks;
using WinUIEx;
namespace OpenClawTray.Windows;
/// <summary>
/// Multi-step setup wizard for first-run and re-configuration.
/// Steps: Gateway URL → Token → Node Mode (optional) → Done
/// Settings are drafted in memory and committed once on Finish.
/// </summary>
public sealed class SetupWizardWindow : WindowEx
{
private int _currentStep = 0;
private const int TotalSteps = 3;
// Draft settings (not saved until Finish)
private string _draftGatewayUrl = "ws://";
private string _draftToken = "";
private bool _draftEnableNodeMode = false;
// UI elements
private readonly StackPanel[] _stepPanels = new StackPanel[TotalSteps];
private readonly Button _backButton;
private readonly Button _nextButton;
private readonly TextBlock _stepIndicator;
// Step 0: Setup code + manual entry
private readonly TextBox _setupCodeBox;
private readonly TextBox _gatewayUrlBox;
private readonly PasswordBox _tokenBox;
private readonly TextBlock _testStatusLabel;
private readonly Button _testButton;
private readonly StackPanel _manualEntryPanel;
private bool _connectionTested = false;
// Step 1: Node mode
private readonly ToggleSwitch _nodeModeToggle;
private readonly TextBlock _deviceIdText;
private readonly Button _copyDeviceIdButton;
private readonly TextBlock _pairingStatusText;
// Result
public bool Completed { get; private set; } = false;
public event EventHandler? SetupCompleted;
private readonly SettingsManager _existingSettings;
public SetupWizardWindow(SettingsManager settings)
{
_existingSettings = settings;
_draftGatewayUrl = settings.GatewayUrl;
_draftToken = settings.Token;
_draftEnableNodeMode = settings.EnableNodeMode;
Title = "OpenClaw Setup";
this.SetWindowSize(720, 700);
this.CenterOnScreen();
this.SetIcon("Assets\\openclaw.ico");
SystemBackdrop = new MicaBackdrop();
var root = new Grid { Padding = new Thickness(32) };
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // Header
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // Step indicator
root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); // Content
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); // Buttons
// Header
var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12, Margin = new Thickness(0, 0, 0, 8) };
header.Children.Add(new TextBlock { Text = "🦞", FontSize = 36 });
header.Children.Add(new TextBlock
{
Text = "OpenClaw Setup",
Style = (Style)Application.Current.Resources["TitleTextBlockStyle"],
VerticalAlignment = VerticalAlignment.Center
});
Grid.SetRow(header, 0);
root.Children.Add(header);
// Step indicator
_stepIndicator = new TextBlock
{
Text = "Step 1 of 3 — Connect",
Foreground = new SolidColorBrush(Microsoft.UI.Colors.Gray),
Margin = new Thickness(0, 0, 0, 16)
};
Grid.SetRow(_stepIndicator, 1);
root.Children.Add(_stepIndicator);
// Content area — all step panels stacked, visibility toggled
var contentArea = new Grid();
// === Step 0: Setup Code (combined URL + Token) ===
_stepPanels[0] = new StackPanel { Spacing = 12 };
_stepPanels[0].Children.Add(new TextBlock
{
Text = "Connect to your gateway",
FontWeight = FontWeights.SemiBold,
FontSize = 16
});
_stepPanels[0].Children.Add(new TextBlock
{
Text = "On your gateway host (Mac/Linux), run this to get a setup code:",
TextWrapping = TextWrapping.Wrap,
Foreground = new SolidColorBrush(Microsoft.UI.Colors.Gray)
});
var cmdHint = new TextBox
{
Text = "openclaw qr --url ws://your-gateway-ip:18789",
IsReadOnly = true,
FontFamily = new FontFamily("Cascadia Mono, Consolas"),
BorderThickness = new Thickness(1),
Background = new SolidColorBrush(Microsoft.UI.ColorHelper.FromArgb(255, 40, 40, 40)),
Foreground = new SolidColorBrush(Microsoft.UI.Colors.LightGreen),
Padding = new Thickness(12, 8, 12, 8)
};
_stepPanels[0].Children.Add(cmdHint);
_setupCodeBox = new TextBox
{
Header = "Setup Code",
PlaceholderText = "Paste the setup code from your gateway dashboard",
TextWrapping = TextWrapping.Wrap,
AcceptsReturn = false
};
_setupCodeBox.TextChanged += OnSetupCodeChanged;
_stepPanels[0].Children.Add(_setupCodeBox);
// Manual entry toggle
var manualToggle = new HyperlinkButton { Content = "Or enter URL and token manually ▾" };
_manualEntryPanel = new StackPanel { Spacing = 8, Visibility = Visibility.Collapsed };
manualToggle.Click += (s, e) =>
{
_manualEntryPanel.Visibility = _manualEntryPanel.Visibility == Visibility.Visible
? Visibility.Collapsed : Visibility.Visible;
manualToggle.Content = _manualEntryPanel.Visibility == Visibility.Visible
? "Hide manual entry ▴" : "Or enter URL and token manually ▾";
};
_stepPanels[0].Children.Add(manualToggle);
_gatewayUrlBox = new TextBox
{
Header = "Gateway URL",
PlaceholderText = "ws://192.168.1.x:18789",
Text = _draftGatewayUrl
};
_gatewayUrlBox.TextChanged += (s, e) => _connectionTested = false;
_manualEntryPanel.Children.Add(_gatewayUrlBox);
_manualEntryPanel.Children.Add(new TextBlock
{
Text = "💡 Accepts ws://, wss://, http://, or https://",
FontSize = 12, Foreground = new SolidColorBrush(Microsoft.UI.Colors.Gray)
});
_tokenBox = new PasswordBox
{
Header = "Gateway Token",
PlaceholderText = "Paste your token here",
Password = _draftToken
};
_tokenBox.PasswordChanged += (s, e) => _connectionTested = false;
_manualEntryPanel.Children.Add(_tokenBox);
_stepPanels[0].Children.Add(_manualEntryPanel);
// Test connection
_testButton = new Button { Content = "Test Connection" };
_testButton.Click += OnTestConnection;
_stepPanels[0].Children.Add(_testButton);
_testStatusLabel = new TextBlock
{
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 4, 0, 0)
};
_stepPanels[0].Children.Add(_testStatusLabel);
contentArea.Children.Add(_stepPanels[0]);
// === Step 1: Node Mode ===
_stepPanels[1] = new StackPanel { Spacing = 12, Visibility = Visibility.Collapsed };
_stepPanels[1].Children.Add(new TextBlock
{
Text = "Enable Node Mode (optional)",
FontWeight = FontWeights.SemiBold,
FontSize = 16
});
_stepPanels[1].Children.Add(new TextBlock
{
Text = "Node Mode lets your Windows machine run tasks for OpenClaw — like screen capture, camera access, and canvas drawing.",
TextWrapping = TextWrapping.Wrap,
Foreground = new SolidColorBrush(Microsoft.UI.Colors.Gray)
});
_nodeModeToggle = new ToggleSwitch
{
Header = "Enable Node Mode",
IsOn = _draftEnableNodeMode
};
_nodeModeToggle.Toggled += (s, e) =>
{
var showPairing = _nodeModeToggle.IsOn;
_deviceIdText.Visibility = showPairing ? Visibility.Visible : Visibility.Collapsed;
_copyDeviceIdButton.Visibility = showPairing ? Visibility.Visible : Visibility.Collapsed;
_pairingStatusText.Visibility = showPairing ? Visibility.Visible : Visibility.Collapsed;
};
_stepPanels[1].Children.Add(_nodeModeToggle);
_deviceIdText = new TextBlock
{
Text = "Device ID: loading...",
FontFamily = new FontFamily("Cascadia Mono, Consolas"),
IsTextSelectionEnabled = true,
TextWrapping = TextWrapping.Wrap,
Visibility = _draftEnableNodeMode ? Visibility.Visible : Visibility.Collapsed
};
_stepPanels[1].Children.Add(_deviceIdText);
_copyDeviceIdButton = new Button
{
Content = "📋 Copy Device ID",
Visibility = _draftEnableNodeMode ? Visibility.Visible : Visibility.Collapsed
};
_copyDeviceIdButton.Click += OnCopyDeviceId;
_stepPanels[1].Children.Add(_copyDeviceIdButton);
_pairingStatusText = new TextBlock
{
TextWrapping = TextWrapping.Wrap,
Visibility = _draftEnableNodeMode ? Visibility.Visible : Visibility.Collapsed
};
_stepPanels[1].Children.Add(_pairingStatusText);
var pairingInstructions = new StackPanel
{
Spacing = 4,
Margin = new Thickness(0, 8, 0, 0)
};
pairingInstructions.Children.Add(new TextBlock
{
Text = "To approve this node, run on your gateway host:",
TextWrapping = TextWrapping.Wrap,
Foreground = new SolidColorBrush(Microsoft.UI.Colors.Gray)
});
var approveCmd = new TextBox
{
Text = "openclaw devices list\nopenclaw devices approve <device-id>",
IsReadOnly = true,
FontFamily = new FontFamily("Cascadia Mono, Consolas"),
BorderThickness = new Thickness(1),
Background = new SolidColorBrush(Microsoft.UI.ColorHelper.FromArgb(255, 40, 40, 40)),
Foreground = new SolidColorBrush(Microsoft.UI.Colors.LightGreen),
Padding = new Thickness(12, 8, 12, 8),
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap
};
pairingInstructions.Children.Add(approveCmd);
pairingInstructions.Children.Add(new TextBlock
{
Text = "💡 You can finish setup now — pairing will continue in the background. You'll get a notification when approved.",
TextWrapping = TextWrapping.Wrap,
FontSize = 12,
Foreground = new SolidColorBrush(Microsoft.UI.Colors.Gray)
});
_stepPanels[1].Children.Add(pairingInstructions);
contentArea.Children.Add(_stepPanels[1]);
// === Step 2: Done ===
_stepPanels[2] = new StackPanel { Spacing = 12, Visibility = Visibility.Collapsed };
_stepPanels[2].Children.Add(new TextBlock
{
Text = "🎉 You're all set!",
FontWeight = FontWeights.SemiBold,
FontSize = 16
});
_stepPanels[2].Children.Add(new TextBlock
{
Text = "OpenClaw Tray will connect to your gateway and start monitoring.",
TextWrapping = TextWrapping.Wrap,
Foreground = new SolidColorBrush(Microsoft.UI.Colors.Gray)
});
contentArea.Children.Add(_stepPanels[2]);
var scrollViewer = new ScrollViewer
{
Content = contentArea,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto
};
Grid.SetRow(scrollViewer, 2);
root.Children.Add(scrollViewer);
// Navigation buttons
var navPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
Margin = new Thickness(0, 16, 0, 0)
};
_backButton = new Button { Content = "Back", Visibility = Visibility.Collapsed };
_backButton.Click += (s, e) => GoToStep(_currentStep - 1);
navPanel.Children.Add(_backButton);
_nextButton = new Button
{
Content = "Next",
Style = (Style)Application.Current.Resources["AccentButtonStyle"]
};
_nextButton.Click += OnNextClicked;
navPanel.Children.Add(_nextButton);
Grid.SetRow(navPanel, 3);
root.Children.Add(navPanel);
Content = root;
Logger.Info("[Setup] Wizard opened");
// Load device identity for step 3
LoadDeviceIdentity();
}
private void GoToStep(int step)
{
if (step < 0 || step >= TotalSteps) return;
_stepPanels[_currentStep].Visibility = Visibility.Collapsed;
_currentStep = step;
_stepPanels[_currentStep].Visibility = Visibility.Visible;
_backButton.Visibility = _currentStep > 0 ? Visibility.Visible : Visibility.Collapsed;
var stepNames = new[] { "Connect", "Node Mode", "Done" };
_stepIndicator.Text = $"Step {_currentStep + 1} of {TotalSteps} — {stepNames[_currentStep]}";
if (_currentStep == TotalSteps - 1)
{
_nextButton.Content = "Finish";
}
else
{
_nextButton.Content = "Next";
}
}
private void OnNextClicked(object sender, RoutedEventArgs e)
{
switch (_currentStep)
{
case 0: // Connection — must have tested successfully
if (!_connectionTested)
{
_testStatusLabel.Text = "⚠️ Please test the connection first";
return;
}
GoToStep(1);
break;
case 1: // Node mode
_draftEnableNodeMode = _nodeModeToggle.IsOn;
GoToStep(2);
break;
case 2: // Finish — save and close
SaveAndFinish();
break;
}
}
private void OnSetupCodeChanged(object sender, TextChangedEventArgs e)
{
_connectionTested = false;
var code = _setupCodeBox.Text.Trim();
if (string.IsNullOrEmpty(code)) return;
try
{
// Try base64url decode
var b64 = code.Replace('-', '+').Replace('_', '/');
var pad = b64.Length % 4;
if (pad > 0) b64 += new string('=', 4 - pad);
var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(b64));
var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("url", out var urlProp))
{
_draftGatewayUrl = urlProp.GetString() ?? "";
_gatewayUrlBox.Text = _draftGatewayUrl;
}
if (doc.RootElement.TryGetProperty("bootstrapToken", out var tokenProp))
{
_draftToken = tokenProp.GetString() ?? "";
_tokenBox.Password = _draftToken;
}
// Show manual fields so user can see what was decoded
_manualEntryPanel.Visibility = Visibility.Visible;
_testStatusLabel.Text = "✅ Setup code decoded — press Test Connection";
Logger.Info($"[Setup] Setup code decoded: gateway={GatewayUrlHelper.SanitizeForDisplay(_draftGatewayUrl)}");
}
catch
{
// Not a valid setup code — that's fine, user might be typing manually
}
}
private async void OnTestConnection(object sender, RoutedEventArgs e)
{
_draftGatewayUrl = _gatewayUrlBox.Text.Trim();
_draftToken = _tokenBox.Password;
if (!GatewayUrlHelper.IsValidGatewayUrl(_draftGatewayUrl))
{
_testStatusLabel.Text = $"❌ {GatewayUrlHelper.ValidationMessage}";
return;
}
if (string.IsNullOrWhiteSpace(_draftToken))
{
_testStatusLabel.Text = "❌ Please enter a token";
return;
}
_testStatusLabel.Text = "⏳ Testing...";
_testButton.IsEnabled = false;
_connectionTested = false;
Logger.Info("[Setup] Test connection initiated");
try
{
var testLogger = new SetupTestLogger();
using var client = new OpenClawGatewayClient(
_draftGatewayUrl,
_draftToken,
testLogger);
var connected = false;
var tcs = new TaskCompletionSource<bool>();
client.StatusChanged += (s, status) =>
{
if (status == ConnectionStatus.Connected)
{
connected = true;
tcs.TrySetResult(true);
}
else if (status == ConnectionStatus.Error)
{
tcs.TrySetResult(false);
}
};
_ = client.ConnectAsync();
// Wait up to 15 seconds (device signature cycling takes time)
var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(15000));
if (completedTask != tcs.Task)
connected = false;
var lastError = testLogger.LastError ?? "";
var lastWarn = testLogger.LastWarn ?? "";
if (connected)
{
Logger.Info("[Setup] Test succeeded - fully connected");
_testStatusLabel.Text = "✅ Connected!";
_connectionTested = true;
}
else if (lastError.Contains("pairing required", StringComparison.OrdinalIgnoreCase) ||
lastWarn.Contains("Pairing approval required", StringComparison.OrdinalIgnoreCase))
{
Logger.Info("[Setup] Test succeeded - pairing approval needed");
var deviceId = _copyDeviceIdButton.Tag?.ToString() ?? "your-device-id";
_testStatusLabel.Text = $"✅ Gateway reached! Device needs pairing approval.\n\nOn your gateway host (Mac/Linux), run:\n\n openclaw devices approve {deviceId}";
_connectionTested = true;
}
else if (lastError.Contains("token mismatch", StringComparison.OrdinalIgnoreCase))
{
_testStatusLabel.Text = "❌ Token doesn't match.\n\n💡 Check gateway auth token:\n cat ~/.openclaw/openclaw.json | grep token";
}
else if (lastError.Contains("origin not allowed", StringComparison.OrdinalIgnoreCase))
{
_testStatusLabel.Text = "❌ Origin not allowed.\n\n💡 Add this machine to gateway.controlUi.allowedOrigins.";
}
else if (lastError.Contains("too many failed", StringComparison.OrdinalIgnoreCase))
{
_testStatusLabel.Text = "❌ Rate-limited. Wait a minute and try again.";
}
else if (!string.IsNullOrEmpty(lastError))
{
_testStatusLabel.Text = $"❌ {lastError}";
}
else
{
_testStatusLabel.Text = "❌ Timed out. Check the URL and gateway is running.";
}
}
catch (Exception ex)
{
Logger.Error($"[Setup] Test connection error: {ex.Message}");
_testStatusLabel.Text = $"❌ {ex.Message}";
}
finally
{
_testButton.IsEnabled = true;
}
}
private void SaveAndFinish()
{
Logger.Info($"[Setup] Saving settings: gateway={GatewayUrlHelper.SanitizeForDisplay(_draftGatewayUrl)}, nodeMode={_draftEnableNodeMode}");
_existingSettings.GatewayUrl = _draftGatewayUrl;
_existingSettings.Token = _draftToken;
_existingSettings.EnableNodeMode = _draftEnableNodeMode;
_existingSettings.Save();
Completed = true;
SetupCompleted?.Invoke(this, EventArgs.Empty);
Logger.Info("[Setup] Wizard completed");
Close();
}
private void LoadDeviceIdentity()
{
try
{
var dataPath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OpenClawTray");
var identity = new DeviceIdentity(dataPath);
identity.Initialize();
var fullId = identity.PublicKeyBase64Url;
var shortId = fullId.Length > 12 ? fullId[..12] : fullId;
_deviceIdText.Text = $"Device ID: {shortId}...";
_copyDeviceIdButton.Tag = fullId;
}
catch (Exception ex)
{
Logger.Warn($"[Setup] Could not load device identity: {ex.Message}");
_deviceIdText.Text = "Device ID: (will be generated on first connect)";
}
}
private void OnCopyDeviceId(object sender, RoutedEventArgs e)
{
try
{
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);
_copyDeviceIdButton.Content = "✅ Copied!";
Logger.Info("[Setup] Device ID copied to clipboard");
// Reset button text after 2 seconds
_ = Task.Delay(2000).ContinueWith(_ =>
{
DispatcherQueue.TryEnqueue(() => _copyDeviceIdButton.Content = "📋 Copy Device ID");
});
}
catch (Exception ex)
{
Logger.Warn($"[Setup] Failed to copy device ID: {ex.Message}");
}
}
private class SetupTestLogger : IOpenClawLogger
{
public string? LastError { get; private set; }
public string? LastWarn { get; private set; }
public void Info(string message) => Logger.Info($"[Setup:TestClient] {message}");
public void Debug(string message) { }
public void Warn(string message)
{
LastWarn = message;
LastError ??= message;
Logger.Warn($"[Setup:TestClient] {message}");
}
public void Error(string message, Exception? ex = null)
{
LastError = message;
Logger.Error($"[Setup:TestClient] {message}");
}
}
}