feat: improve QR setup pairing

Preserve setup bootstrap tokens separately from gateway tokens, support QR image and clipboard setup imports, and improve pairing notification copy flow.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Scott Hanselman 2026-04-26 20:14:18 -07:00
parent c1296be7fd
commit 00670860ed
17 changed files with 563 additions and 56 deletions

View File

@ -238,25 +238,41 @@ When a node connects with `auth: { token: "...", bootstrapToken: "..." }`, the g
- Preferred over shared-secret even if both succeed (QR flow relies on this)
3. **Device token**`auth.token` as device-token fallback (for already-paired devices)
### 4.4 What Our Setup Wizard Does (and the Gap)
### 4.4 Setup Wizard Entry Points
Currently, our Setup Wizard:
1. Decodes the setup code from `openclaw qr`
2. Extracts `url` and `bootstrapToken`
3. Stores `bootstrapToken` as the settings `Token` field
4. Sends it as `auth.token` in the connect handshake
The setup code and QR code are the same bootstrap concept in different packaging:
**The problem**: We send it as `auth.token`, not `auth.bootstrapToken`. The gateway's auth resolution:
- Tries `auth.token` as shared-secret → **fails** (it's not the gateway token)
- Never sees `auth.bootstrapToken` → never tries bootstrap-token auth
- Falls back to device-token → **fails** (no prior pairing)
```text
QR image
-> decodes to setup code text
-> decodes to JSON payload
-> contains gateway URL + bootstrapToken + expiry
```
**The fix**: Send the bootstrap token as `auth.bootstrapToken` in the connect payload, separate from `auth.token`. This lets the gateway correctly classify it as a bootstrap-token handshake, which enables:
Advanced users can drop into setup at any level:
| Entry point | User has | Wizard behavior |
|---|---|---|
| QR image | A saved/screenshot/email attachment containing the QR | Import or paste the image, decode QR text, then decode the setup payload |
| Setup code | The pasteable text from `openclaw qr` | Paste the text directly, then decode the setup payload |
| Manual URL + token | Gateway URL/IP and a long-lived gateway token | Skip bootstrap; connect with `auth.token` and use manual approval if required |
The QR/setup-code path is preferred for first-time node onboarding because it avoids telling users to copy permanent gateway secrets and enables auto-approval.
### 4.5 What Our Setup Wizard Does
The Windows Setup Wizard:
1. Accepts a QR image, clipboard QR image, pasteable setup code, or manual gateway URL/token.
2. For QR/setup-code input, decodes `{ url, bootstrapToken, expiresAtMs }`.
3. Stores `bootstrapToken` separately from the normal gateway `Token` setting.
4. Sends it as `auth.bootstrapToken` in the node connect handshake.
This lets the gateway correctly classify QR setup as a bootstrap-token handshake, which enables:
- Silent auto-approval (no manual `devices approve` needed)
- Bootstrap token revocation after pairing
- Bounded operator token handoff (if configured)
### 4.5 Post-Pairing: Device Tokens
### 4.6 Post-Pairing: Device Tokens
After a successful bootstrap-token pairing:
1. Gateway issues a `deviceToken` in `hello-ok.auth.deviceToken`
@ -264,13 +280,13 @@ After a successful bootstrap-token pairing:
3. Future connections use `auth.token = <deviceToken>` (device-token auth path)
4. The bootstrap token is revoked and no longer valid
**We're not doing step 2-3 yet.** Our node uses the same settings token forever. It works because the settings token matches the gateway's shared secret (if the user entered it manually), but it means QR-based pairing doesn't complete the handoff properly.
Windows stores `hello-ok.auth.deviceToken` in its device identity file and prefers that saved device token on future node connections. The bootstrap token is only used when there is no saved device token yet.
### 4.6 Ideal Bootstrap Flow (What We Should Implement)
### 4.7 Bootstrap Flow
```
1. User runs `openclaw qr` on gateway host
2. User pastes setup code into Windows Setup Wizard
2. User imports/scans QR image or pastes setup code into Windows Setup Wizard
3. Wizard decodes → { url, bootstrapToken, expiresAtMs }
4. Node connects with: auth: { bootstrapToken: "<token>" }
5. Gateway auto-approves pairing (bootstrap-token auth method)
@ -280,7 +296,7 @@ After a successful bootstrap-token pairing:
9. No manual `devices approve` needed!
```
This would make pairing truly seamless — scan QR, auto-paired, done.
Manual URL/token setup remains useful for advanced troubleshooting and environments where QR/bootstrap is unavailable. In that path, the tray may show a pairing notification with an `openclaw devices approve <device-id>` command that must be run on the gateway host.
---
@ -319,10 +335,11 @@ Until the gateway expands Windows safe defaults, the practical local solution is
- [x] Remove `screen.list` from declared commands
- [ ] Remove debug logging from `WindowsNodeClient.cs` (done)
### 5.2 Setup Wizard Improvements (Next Sprint)
### 5.2 Setup Wizard Improvements
- [ ] Send `bootstrapToken` in correct field: `auth.bootstrapToken` not `auth.token`
- [ ] Handle `hello-ok.auth.deviceToken` — save it for future connections
- [x] Send `bootstrapToken` in correct field: `auth.bootstrapToken` not `auth.token`
- [x] Handle `hello-ok.auth.deviceToken` — save it for future connections
- [x] Accept QR images and clipboard setup content as alternate ways to enter the same bootstrap payload
- [ ] Show "auto-paired!" vs "waiting for approval" based on auth method
- [ ] Handle bootstrap token expiry gracefully (re-generate if expired)

View File

@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestPatch"
"rollForward": "latestFeature"
}
}

View File

@ -9,6 +9,7 @@ public class SettingsData
{
public string? GatewayUrl { get; set; }
public string? Token { get; set; }
public string? BootstrapToken { get; set; }
public bool UseSshTunnel { get; set; } = false;
public string? SshTunnelUser { get; set; }
public string? SshTunnelHost { get; set; }

View File

@ -29,6 +29,8 @@ public class WindowsNodeClient : WebSocketClientBase
private bool _isPaired;
// Bridges the gap between an approval event and the next hello-ok when the gateway omits auth.deviceToken.
private bool _pairingApprovedAwaitingReconnect;
private readonly string _gatewayToken;
private readonly string? _bootstrapToken;
// Cached serialization/validation — reused on every message instead of allocating per-call
private static readonly JsonSerializerOptions s_ignoreNullOptions = new()
@ -64,9 +66,12 @@ public class WindowsNodeClient : WebSocketClientBase
protected override int ReceiveBufferSize => 65536;
protected override string ClientRole => "node";
public WindowsNodeClient(string gatewayUrl, string token, string dataPath, IOpenClawLogger? logger = null)
: base(gatewayUrl, token, logger)
public WindowsNodeClient(string gatewayUrl, string token, string dataPath, IOpenClawLogger? logger = null, string? bootstrapToken = null)
: base(gatewayUrl, ResolveRequiredCredential(token, bootstrapToken), logger)
{
_gatewayToken = NormalizeOptionalCredential(token);
_bootstrapToken = NormalizeOptionalCredential(bootstrapToken);
// Initialize device identity
_deviceIdentity = new DeviceIdentity(dataPath, _logger);
_deviceIdentity.Initialize();
@ -80,6 +85,28 @@ public class WindowsNodeClient : WebSocketClientBase
DisplayName = $"Windows Node ({Environment.MachineName})"
};
}
private static string NormalizeOptionalCredential(string? credential)
{
return string.IsNullOrWhiteSpace(credential) ? string.Empty : credential;
}
private static string ResolveRequiredCredential(string? token, string? bootstrapToken)
{
var gatewayToken = NormalizeOptionalCredential(token);
if (!string.IsNullOrEmpty(gatewayToken))
{
return gatewayToken;
}
var bootstrap = NormalizeOptionalCredential(bootstrapToken);
if (!string.IsNullOrEmpty(bootstrap))
{
return bootstrap;
}
throw new ArgumentException("Token or bootstrap token is required.", nameof(token));
}
/// <summary>
/// Register a capability handler
@ -445,30 +472,35 @@ public class WindowsNodeClient : WebSocketClientBase
private const string ClientId = "node-host"; // Must be "node-host" for nodes
private async Task SendNodeConnectAsync(string? nonce, long ts)
{
var isPaired = !string.IsNullOrEmpty(_deviceIdentity.DeviceToken);
var usingBootstrap = !isPaired && !string.IsNullOrEmpty(_bootstrapToken);
_logger.Info($"Connecting with Ed25519 device identity (paired: {isPaired}, bootstrap: {usingBootstrap})");
await SendRawAsync(BuildNodeConnectMessage(nonce, ts));
_logger.Info($"Sent node registration with device ID: {_deviceIdentity.DeviceId[..16]}..., paired: {isPaired}");
}
private string BuildNodeConnectMessage(string? nonce, long ts)
{
// Sign the full payload with Ed25519 - this is how device pairing works
string? signature = null;
var signedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Use device token if we have one (already paired), otherwise use operator token
// IMPORTANT: This token must be included in the signed payload!
var authToken = _deviceIdentity.DeviceToken ?? _token;
var isPaired = !string.IsNullOrEmpty(_deviceIdentity.DeviceToken);
var (auth, tokenForSignature) = BuildConnectAuth();
if (!string.IsNullOrEmpty(nonce))
{
try
{
signature = _deviceIdentity.SignPayload(nonce, signedAt, ClientId, authToken);
signature = _deviceIdentity.SignPayload(nonce, signedAt, ClientId, tokenForSignature);
}
catch (Exception ex)
{
_logger.Error($"Failed to sign payload: {ex.Message}");
}
}
_logger.Info($"Connecting with Ed25519 device identity (paired: {isPaired})");
// Always include device identity - this is required for pairing
var msg = new
{
@ -492,7 +524,7 @@ public class WindowsNodeClient : WebSocketClientBase
caps = _registration.Capabilities,
commands = _registration.Commands,
permissions = _registration.Permissions,
auth = new { token = authToken },
auth,
locale = "en-US",
userAgent = $"openclaw-windows-node/{_registration.Version}",
device = new
@ -505,9 +537,23 @@ public class WindowsNodeClient : WebSocketClientBase
}
}
};
await SendRawAsync(JsonSerializer.Serialize(msg));
_logger.Info($"Sent node registration with device ID: {_deviceIdentity.DeviceId[..16]}..., paired: {isPaired}");
return JsonSerializer.Serialize(msg, s_ignoreNullOptions);
}
private (Dictionary<string, string> Auth, string TokenForSignature) BuildConnectAuth()
{
if (!string.IsNullOrEmpty(_deviceIdentity.DeviceToken))
{
return (new Dictionary<string, string> { ["token"] = _deviceIdentity.DeviceToken }, _deviceIdentity.DeviceToken);
}
if (!string.IsNullOrEmpty(_bootstrapToken))
{
return (new Dictionary<string, string> { ["bootstrapToken"] = _bootstrapToken }, string.Empty);
}
return (new Dictionary<string, string> { ["token"] = _gatewayToken }, _gatewayToken);
}
private void HandleResponse(JsonElement root)

View File

@ -274,7 +274,7 @@ public partial class App : Application
_sshTunnelService.TunnelExited += OnSshTunnelExited;
// First-run check
if (string.IsNullOrWhiteSpace(_settings.Token))
if (RequiresSetup(_settings))
{
await ShowSetupWizardAsync();
}
@ -1152,9 +1152,10 @@ public partial class App : Application
{
if (_settings == null || !_settings.EnableNodeMode) return;
if (_dispatcherQueue == null) return;
if (string.IsNullOrWhiteSpace(_settings.Token))
if (string.IsNullOrWhiteSpace(_settings.Token) &&
string.IsNullOrWhiteSpace(_settings.BootstrapToken))
{
Logger.Warn("Node mode enabled but no token configured — skipping node service. Run Setup Guide to configure.");
Logger.Warn("Node mode enabled but no token or bootstrap token configured — skipping node service. Run Setup Guide to configure.");
return;
}
if (!EnsureSshTunnelConfigured()) return;
@ -1169,13 +1170,23 @@ public partial class App : Application
_nodeService.PairingStatusChanged += OnPairingStatusChanged;
// Connect to gateway as a node (separate connection from operator)
_ = _nodeService.ConnectAsync(_settings.GetEffectiveGatewayUrl(), _settings.Token);
_ = _nodeService.ConnectAsync(_settings.GetEffectiveGatewayUrl(), _settings.Token, _settings.BootstrapToken);
}
catch (Exception ex)
{
Logger.Error($"Failed to initialize node service: {ex.Message}");
}
}
private static bool RequiresSetup(SettingsManager settings)
{
if (!string.IsNullOrWhiteSpace(settings.Token))
{
return false;
}
return !(settings.EnableNodeMode && !string.IsNullOrWhiteSpace(settings.BootstrapToken));
}
private void OnNodeStatusChanged(object? sender, ConnectionStatus status)
{
@ -1211,10 +1222,15 @@ public partial class App : Application
if (args.Status == OpenClaw.Shared.PairingStatus.Pending)
{
AddRecentActivity("Node pairing pending", category: "node", dashboardPath: "nodes", nodeId: args.DeviceId);
var approvalCommand = $"openclaw devices approve {args.DeviceId}";
// Show toast with approval instructions
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_PairingPending"))
.AddText(string.Format(LocalizationHelper.GetString("Toast_PairingPendingDetail"), args.DeviceId.Substring(0, 16))));
.AddText(string.Format(LocalizationHelper.GetString("Toast_PairingPendingDetail"), args.DeviceId.Substring(0, 16)))
.AddButton(new ToastButton()
.SetContent(LocalizationHelper.GetString("Toast_CopyPairingCommand"))
.AddArgument("action", "copy_pairing_command")
.AddArgument("command", approvalCommand)));
}
else if (args.Status == OpenClaw.Shared.PairingStatus.Paired)
{
@ -2150,11 +2166,24 @@ public partial class App : Application
case "open_activity":
ShowActivityStream();
break;
case "copy_pairing_command" when arguments.TryGetValue("command", out var command):
CopyTextToClipboard(command);
ShowToast(new ToastContentBuilder()
.AddText(LocalizationHelper.GetString("Toast_PairingCommandCopied"))
.AddText(command));
break;
}
});
}
}
private static void CopyTextToClipboard(string text)
{
var dataPackage = new global::Windows.ApplicationModel.DataTransfer.DataPackage();
dataPackage.SetText(text);
global::Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(dataPackage);
}
#endregion
#region Exit

View File

@ -39,9 +39,11 @@
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.8.260101001" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4654" />
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
<PackageReference Include="WinUIEx" Version="2.9.0" />
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
<PackageReference Include="Updatum" Version="1.3.4" />
<PackageReference Include="ZXing.Net" Version="0.16.10" />
</ItemGroup>
<ItemGroup>

View File

@ -59,7 +59,7 @@ public class NodeService : IDisposable
/// <summary>
/// Initialize and connect the node
/// </summary>
public async Task ConnectAsync(string gatewayUrl, string token)
public async Task ConnectAsync(string gatewayUrl, string token, string? bootstrapToken = null)
{
if (_nodeClient != null)
{
@ -69,7 +69,7 @@ public class NodeService : IDisposable
_logger.Info($"Starting Windows Node connection to {GatewayUrlHelper.SanitizeForDisplay(gatewayUrl)}");
_token = token;
_nodeClient = new WindowsNodeClient(gatewayUrl, token, _dataPath, _logger);
_nodeClient = new WindowsNodeClient(gatewayUrl, token, _dataPath, _logger, bootstrapToken);
_nodeClient.StatusChanged += OnNodeStatusChanged;
_nodeClient.PairingStatusChanged += OnPairingStatusChanged;

View File

@ -19,6 +19,7 @@ public class SettingsManager
// Connection
public string GatewayUrl { get; set; } = "ws://localhost:18789";
public string Token { get; set; } = "";
public string BootstrapToken { get; set; } = "";
public bool UseSshTunnel { get; set; } = false;
public string SshTunnelUser { get; set; } = "";
public string SshTunnelHost { get; set; } = "";
@ -70,6 +71,7 @@ public class SettingsManager
{
GatewayUrl = loaded.GatewayUrl ?? GatewayUrl;
Token = loaded.Token ?? Token;
BootstrapToken = loaded.BootstrapToken ?? BootstrapToken;
UseSshTunnel = loaded.UseSshTunnel;
SshTunnelUser = loaded.SshTunnelUser ?? SshTunnelUser;
SshTunnelHost = loaded.SshTunnelHost ?? SshTunnelHost;
@ -113,6 +115,7 @@ public class SettingsManager
{
GatewayUrl = GatewayUrl,
Token = Token,
BootstrapToken = string.IsNullOrWhiteSpace(BootstrapToken) ? null : BootstrapToken,
UseSshTunnel = UseSshTunnel,
SshTunnelUser = SshTunnelUser,
SshTunnelHost = SshTunnelHost,

View File

@ -569,6 +569,12 @@ Use one of these options:
<data name="Toast_PairingPendingDetail" xml:space="preserve">
<value>Run on gateway: openclaw devices approve {0}...</value>
</data>
<data name="Toast_CopyPairingCommand" xml:space="preserve">
<value>Copy approval command</value>
</data>
<data name="Toast_PairingCommandCopied" xml:space="preserve">
<value>Pairing command copied</value>
</data>
<data name="Toast_NodePaired" xml:space="preserve">
<value>✅ Node Paired!</value>
</data>
@ -644,6 +650,21 @@ Use one of these options:
<data name="Setup_SetupCodePlaceholder" xml:space="preserve">
<value>Paste the setup code from your gateway dashboard</value>
</data>
<data name="Setup_PasteSetupButton" xml:space="preserve">
<value>Paste setup code / QR</value>
</data>
<data name="Setup_ImportQrButton" xml:space="preserve">
<value>Import QR image...</value>
</data>
<data name="Setup_QrDecoded" xml:space="preserve">
<value>✅ QR image decoded — press Test Connection</value>
</data>
<data name="Setup_QrDecodeFailed" xml:space="preserve">
<value>❌ Could not find a valid setup QR code in that image.</value>
</data>
<data name="Setup_ClipboardUnsupported" xml:space="preserve">
<value>❌ Clipboard does not contain setup code text or a QR image.</value>
</data>
<data name="Setup_ManualEntryToggle" xml:space="preserve">
<value>Or enter URL and token manually ▾</value>
</data>

View File

@ -569,6 +569,12 @@ Utilisez l'une de ces options :
<data name="Toast_PairingPendingDetail" xml:space="preserve">
<value>Lancer sur la passerelle : openclaw devices approve {0}...</value>
</data>
<data name="Toast_CopyPairingCommand" xml:space="preserve">
<value>Copier la commande d'approbation</value>
</data>
<data name="Toast_PairingCommandCopied" xml:space="preserve">
<value>Commande de jumelage copiée</value>
</data>
<data name="Toast_NodePaired" xml:space="preserve">
<value>✅ Noeud appairé!</value>
</data>
@ -644,6 +650,21 @@ Utilisez l'une de ces options :
<data name="Setup_SetupCodePlaceholder" xml:space="preserve">
<value>Collez le code de configuration depuis le tableau de bord de votre passerelle</value>
</data>
<data name="Setup_PasteSetupButton" xml:space="preserve">
<value>Coller le code / QR</value>
</data>
<data name="Setup_ImportQrButton" xml:space="preserve">
<value>Importer une image QR...</value>
</data>
<data name="Setup_QrDecoded" xml:space="preserve">
<value>✅ Image QR décodée — cliquez sur Tester la connexion</value>
</data>
<data name="Setup_QrDecodeFailed" xml:space="preserve">
<value>❌ Aucun code QR de configuration valide trouvé dans cette image.</value>
</data>
<data name="Setup_ClipboardUnsupported" xml:space="preserve">
<value>❌ Le presse-papiers ne contient pas de code de configuration texte ni d'image QR.</value>
</data>
<data name="Setup_ManualEntryToggle" xml:space="preserve">
<value>Ou entrez l'URL et le jeton manuellement ▾</value>
</data>

View File

@ -569,6 +569,12 @@ Gebruik een van deze opties:
<data name="Toast_PairingPendingDetail" xml:space="preserve">
<value>Voer uit op gateway: openclaw devices approve {0}...</value>
</data>
<data name="Toast_CopyPairingCommand" xml:space="preserve">
<value>Goedkeuringsopdracht kopiëren</value>
</data>
<data name="Toast_PairingCommandCopied" xml:space="preserve">
<value>Koppelingsopdracht gekopieerd</value>
</data>
<data name="Toast_NodePaired" xml:space="preserve">
<value>&#x2705; Node gekoppeld!</value>
</data>
@ -644,6 +650,21 @@ Gebruik een van deze opties:
<data name="Setup_SetupCodePlaceholder" xml:space="preserve">
<value>Plak de installatiecode van uw gateway-dashboard</value>
</data>
<data name="Setup_PasteSetupButton" xml:space="preserve">
<value>Setupcode / QR plakken</value>
</data>
<data name="Setup_ImportQrButton" xml:space="preserve">
<value>QR-afbeelding importeren...</value>
</data>
<data name="Setup_QrDecoded" xml:space="preserve">
<value>✅ QR-afbeelding gedecodeerd — klik op Verbinding testen</value>
</data>
<data name="Setup_QrDecodeFailed" xml:space="preserve">
<value>❌ Geen geldige setup-QR-code gevonden in die afbeelding.</value>
</data>
<data name="Setup_ClipboardUnsupported" xml:space="preserve">
<value>❌ Het klembord bevat geen setupcodetekst of QR-afbeelding.</value>
</data>
<data name="Setup_ManualEntryToggle" xml:space="preserve">
<value>Of voer URL en token handmatig in ▾</value>
</data>

View File

@ -569,6 +569,12 @@
<data name="Toast_PairingPendingDetail" xml:space="preserve">
<value>在网关上运行: openclaw devices approve {0}...</value>
</data>
<data name="Toast_CopyPairingCommand" xml:space="preserve">
<value>复制批准命令</value>
</data>
<data name="Toast_PairingCommandCopied" xml:space="preserve">
<value>配对命令已复制</value>
</data>
<data name="Toast_NodePaired" xml:space="preserve">
<value>✅ 节点已配对!</value>
</data>
@ -644,6 +650,21 @@
<data name="Setup_SetupCodePlaceholder" xml:space="preserve">
<value>粘贴来自网关仪表板的设置码</value>
</data>
<data name="Setup_PasteSetupButton" xml:space="preserve">
<value>粘贴设置码 / QR</value>
</data>
<data name="Setup_ImportQrButton" xml:space="preserve">
<value>导入 QR 图片...</value>
</data>
<data name="Setup_QrDecoded" xml:space="preserve">
<value>✅ QR 图片已解码 — 请点击"测试连接"</value>
</data>
<data name="Setup_QrDecodeFailed" xml:space="preserve">
<value>❌ 无法在该图片中找到有效的设置 QR 码。</value>
</data>
<data name="Setup_ClipboardUnsupported" xml:space="preserve">
<value>❌ 剪贴板不包含设置码文本或 QR 图片。</value>
</data>
<data name="Setup_ManualEntryToggle" xml:space="preserve">
<value>或手动输入 URL 和令牌 ▾</value>
</data>

View File

@ -569,6 +569,12 @@
<data name="Toast_PairingPendingDetail" xml:space="preserve">
<value>在閘道器上執行: openclaw devices approve {0}...</value>
</data>
<data name="Toast_CopyPairingCommand" xml:space="preserve">
<value>複製核准命令</value>
</data>
<data name="Toast_PairingCommandCopied" xml:space="preserve">
<value>配對命令已複製</value>
</data>
<data name="Toast_NodePaired" xml:space="preserve">
<value>✅ 節點已配對!</value>
</data>
@ -644,6 +650,21 @@
<data name="Setup_SetupCodePlaceholder" xml:space="preserve">
<value>貼上來自閘道器儀表板的設定碼</value>
</data>
<data name="Setup_PasteSetupButton" xml:space="preserve">
<value>貼上設定碼 / QR</value>
</data>
<data name="Setup_ImportQrButton" xml:space="preserve">
<value>匯入 QR 圖片...</value>
</data>
<data name="Setup_QrDecoded" xml:space="preserve">
<value>✅ QR 圖片已解碼 — 請按「測試連線」</value>
</data>
<data name="Setup_QrDecodeFailed" xml:space="preserve">
<value>❌ 無法在該圖片中找到有效的設定 QR 碼。</value>
</data>
<data name="Setup_ClipboardUnsupported" xml:space="preserve">
<value>❌ 剪貼簿不包含設定碼文字或 QR 圖片。</value>
</data>
<data name="Setup_ManualEntryToggle" xml:space="preserve">
<value>或手動輸入 URL 和權杖 ▾</value>
</data>

View File

@ -91,6 +91,10 @@ public sealed partial class SettingsWindow : WindowEx
_manualGatewayUrl = _settings.GatewayUrl;
}
_settings.Token = TokenTextBox.Text.Trim();
if (!string.IsNullOrWhiteSpace(_settings.Token))
{
_settings.BootstrapToken = "";
}
_settings.AutoStart = AutoStartToggle.IsOn;
_settings.GlobalHotkeyEnabled = GlobalHotkeyToggle.IsOn;
_settings.ShowNotifications = NotificationsToggle.IsOn;

View File

@ -7,8 +7,20 @@ using OpenClaw.Shared;
using OpenClawTray.Helpers;
using OpenClawTray.Services;
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading.Tasks;
using Windows.ApplicationModel.DataTransfer;
using Windows.Storage.Pickers;
using WinUIEx;
using ZXing;
using ZXing.Common;
using DrawingBitmap = System.Drawing.Bitmap;
using DrawingGraphics = System.Drawing.Graphics;
using DrawingImageLockMode = System.Drawing.Imaging.ImageLockMode;
using DrawingPixelFormat = System.Drawing.Imaging.PixelFormat;
namespace OpenClawTray.Windows;
@ -25,6 +37,7 @@ public sealed class SetupWizardWindow : WindowEx
// Draft settings (not saved until Finish)
private string _draftGatewayUrl = "ws://";
private string _draftToken = "";
private string _draftBootstrapToken = "";
private bool _draftEnableNodeMode = false;
// UI elements
@ -59,10 +72,11 @@ public sealed class SetupWizardWindow : WindowEx
_existingSettings = settings;
_draftGatewayUrl = settings.GatewayUrl;
_draftToken = settings.Token;
_draftBootstrapToken = settings.BootstrapToken;
_draftEnableNodeMode = settings.EnableNodeMode;
Title = LocalizationHelper.GetString("Setup_Title");
this.SetWindowSize(720, 700);
this.SetWindowSize(720, 900);
this.CenterOnScreen();
this.SetIcon("Assets\\openclaw.ico");
SystemBackdrop = new MicaBackdrop();
@ -134,6 +148,22 @@ public sealed class SetupWizardWindow : WindowEx
_setupCodeBox.TextChanged += OnSetupCodeChanged;
_stepPanels[0].Children.Add(_setupCodeBox);
var setupCodeActions = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8
};
var pasteSetupButton = new Button { Content = LocalizationHelper.GetString("Setup_PasteSetupButton") };
AutomationProperties.SetAutomationId(pasteSetupButton, "PasteSetupButton");
pasteSetupButton.Click += OnPasteSetupFromClipboard;
setupCodeActions.Children.Add(pasteSetupButton);
var importQrButton = new Button { Content = LocalizationHelper.GetString("Setup_ImportQrButton") };
AutomationProperties.SetAutomationId(importQrButton, "ImportQrButton");
importQrButton.Click += OnImportQrImage;
setupCodeActions.Children.Add(importQrButton);
_stepPanels[0].Children.Add(setupCodeActions);
// Manual entry toggle
var manualToggle = new HyperlinkButton { Content = LocalizationHelper.GetString("Setup_ManualEntryToggle") };
AutomationProperties.SetAutomationId(manualToggle, "ManualEntryToggle");
@ -208,10 +238,7 @@ public sealed class SetupWizardWindow : WindowEx
AutomationProperties.SetAutomationId(_nodeModeToggle, "NodeModeToggle");
_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;
UpdateNodeModePairingVisibility(_nodeModeToggle.IsOn);
};
_stepPanels[1].Children.Add(_nodeModeToggle);
@ -382,17 +409,30 @@ public sealed class SetupWizardWindow : WindowEx
{
_connectionTested = false;
var code = _setupCodeBox.Text.Trim();
if (string.IsNullOrEmpty(code)) return;
if (string.IsNullOrEmpty(code))
{
_draftBootstrapToken = "";
return;
}
if (!TryApplySetupCode(code, LocalizationHelper.GetString("Setup_CodeDecoded")))
{
// Not a valid setup code; that's fine, user might be typing manually.
_draftBootstrapToken = "";
}
}
private bool TryApplySetupCode(string code, string successMessage)
{
try
{
// Try base64url decode
var b64 = code.Replace('-', '+').Replace('_', '/');
var b64 = code.Trim().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);
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("url", out var urlProp))
{
@ -401,19 +441,165 @@ public sealed class SetupWizardWindow : WindowEx
}
if (doc.RootElement.TryGetProperty("bootstrapToken", out var tokenProp))
{
_draftToken = tokenProp.GetString() ?? "";
_tokenBox.Password = _draftToken;
_draftBootstrapToken = tokenProp.GetString() ?? "";
_draftEnableNodeMode = !string.IsNullOrWhiteSpace(_draftBootstrapToken);
_nodeModeToggle.IsOn = _draftEnableNodeMode;
UpdateNodeModePairingVisibility(_draftEnableNodeMode);
}
if (string.IsNullOrWhiteSpace(_draftGatewayUrl) ||
string.IsNullOrWhiteSpace(_draftBootstrapToken))
{
return false;
}
// Show manual fields so user can see what was decoded
_manualEntryPanel.Visibility = Visibility.Visible;
_testStatusLabel.Text = LocalizationHelper.GetString("Setup_CodeDecoded");
_testStatusLabel.Text = successMessage;
_connectionTested = GatewayUrlHelper.IsValidGatewayUrl(_draftGatewayUrl);
Logger.Info($"[Setup] Setup code decoded: gateway={GatewayUrlHelper.SanitizeForDisplay(_draftGatewayUrl)}");
return true;
}
catch
catch (System.FormatException)
{
// Not a valid setup code — that's fine, user might be typing manually
return false;
}
catch (System.Text.Json.JsonException)
{
return false;
}
}
private async void OnPasteSetupFromClipboard(object sender, RoutedEventArgs e)
{
try
{
var content = Clipboard.GetContent();
if (content.Contains(StandardDataFormats.Text))
{
var text = await content.GetTextAsync();
ApplyDecodedSetupCode(text, LocalizationHelper.GetString("Setup_CodeDecoded"));
return;
}
if (content.Contains(StandardDataFormats.Bitmap))
{
var bitmapReference = await content.GetBitmapAsync();
using var randomAccessStream = await bitmapReference.OpenReadAsync();
using var stream = randomAccessStream.AsStreamForRead();
var setupCode = DecodeQrSetupCode(stream);
ApplyDecodedSetupCode(setupCode, LocalizationHelper.GetString("Setup_QrDecoded"));
return;
}
_testStatusLabel.Text = LocalizationHelper.GetString("Setup_ClipboardUnsupported");
}
catch (Exception ex) when (ex is InvalidOperationException or COMException or IOException or UnauthorizedAccessException)
{
Logger.Warn($"[Setup] Clipboard setup import failed: {ex.Message}");
_testStatusLabel.Text = ex is InvalidOperationException
? ex.Message
: LocalizationHelper.GetString("Setup_ClipboardUnsupported");
}
}
private async void OnImportQrImage(object sender, RoutedEventArgs e)
{
try
{
var picker = new FileOpenPicker();
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd);
picker.FileTypeFilter.Add(".png");
picker.FileTypeFilter.Add(".jpg");
picker.FileTypeFilter.Add(".jpeg");
picker.FileTypeFilter.Add(".bmp");
picker.FileTypeFilter.Add(".gif");
var file = await picker.PickSingleFileAsync();
if (file == null)
{
return;
}
using var randomAccessStream = await file.OpenReadAsync();
using var stream = randomAccessStream.AsStreamForRead();
var setupCode = DecodeQrSetupCode(stream);
ApplyDecodedSetupCode(setupCode, LocalizationHelper.GetString("Setup_QrDecoded"));
}
catch (Exception ex) when (ex is InvalidOperationException or COMException or IOException or UnauthorizedAccessException)
{
Logger.Warn($"[Setup] QR image import failed: {ex.Message}");
_testStatusLabel.Text = ex is InvalidOperationException
? ex.Message
: LocalizationHelper.GetString("Setup_QrDecodeFailed");
}
}
private void ApplyDecodedSetupCode(string setupCode, string successMessage)
{
if (string.IsNullOrWhiteSpace(setupCode))
{
throw new InvalidOperationException(LocalizationHelper.GetString("Setup_QrDecodeFailed"));
}
_setupCodeBox.Text = setupCode.Trim();
if (!TryApplySetupCode(setupCode, successMessage))
{
throw new InvalidOperationException(LocalizationHelper.GetString("Setup_QrDecodeFailed"));
}
}
private static string DecodeQrSetupCode(Stream stream)
{
using var source = new DrawingBitmap(stream);
using var bitmap = new DrawingBitmap(source.Width, source.Height, DrawingPixelFormat.Format32bppArgb);
using (var graphics = DrawingGraphics.FromImage(bitmap))
{
graphics.DrawImage(source, 0, 0, source.Width, source.Height);
}
var bounds = new System.Drawing.Rectangle(0, 0, bitmap.Width, bitmap.Height);
var data = bitmap.LockBits(bounds, DrawingImageLockMode.ReadOnly, DrawingPixelFormat.Format32bppArgb);
try
{
var rowBytes = bitmap.Width * 4;
var pixels = new byte[rowBytes * bitmap.Height];
for (var y = 0; y < bitmap.Height; y++)
{
Marshal.Copy(IntPtr.Add(data.Scan0, y * data.Stride), pixels, y * rowBytes, rowBytes);
}
var reader = new BarcodeReaderGeneric
{
AutoRotate = true,
Options = new DecodingOptions
{
PossibleFormats = new List<BarcodeFormat> { BarcodeFormat.QR_CODE },
TryHarder = true,
TryInverted = true
}
};
var result = reader.Decode(pixels, bitmap.Width, bitmap.Height, RGBLuminanceSource.BitmapFormat.BGRA32);
if (string.IsNullOrWhiteSpace(result?.Text))
{
throw new InvalidOperationException(LocalizationHelper.GetString("Setup_QrDecodeFailed"));
}
return result.Text;
}
finally
{
bitmap.UnlockBits(data);
}
}
private void UpdateNodeModePairingVisibility(bool showPairing)
{
_deviceIdText.Visibility = showPairing ? Visibility.Visible : Visibility.Collapsed;
_copyDeviceIdButton.Visibility = showPairing ? Visibility.Visible : Visibility.Collapsed;
_pairingStatusText.Visibility = showPairing ? Visibility.Visible : Visibility.Collapsed;
}
private async void OnTestConnection(object sender, RoutedEventArgs e)
@ -427,12 +613,21 @@ public sealed class SetupWizardWindow : WindowEx
return;
}
if (string.IsNullOrWhiteSpace(_draftToken))
if (string.IsNullOrWhiteSpace(_draftToken) &&
string.IsNullOrWhiteSpace(_draftBootstrapToken))
{
_testStatusLabel.Text = LocalizationHelper.GetString("Setup_TokenRequired");
return;
}
if (string.IsNullOrWhiteSpace(_draftToken) &&
!string.IsNullOrWhiteSpace(_draftBootstrapToken))
{
_testStatusLabel.Text = LocalizationHelper.GetString("Setup_CodeDecoded");
_connectionTested = true;
return;
}
_testStatusLabel.Text = LocalizationHelper.GetString("Setup_Testing");
_testButton.IsEnabled = false;
_connectionTested = false;
@ -526,6 +721,10 @@ public sealed class SetupWizardWindow : WindowEx
_existingSettings.GatewayUrl = _draftGatewayUrl;
_existingSettings.Token = _draftToken;
_existingSettings.EnableNodeMode = _draftEnableNodeMode;
_existingSettings.BootstrapToken =
_draftEnableNodeMode && string.IsNullOrWhiteSpace(_draftToken)
? _draftBootstrapToken
: "";
_existingSettings.Save();
Completed = true;

View File

@ -720,6 +720,93 @@ public class WindowsNodeClientTests
}
}
[Fact]
public void BuildNodeConnectMessage_UsesBootstrapToken_WhenNoStoredDeviceToken()
{
var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(dataPath);
try
{
using var client = new WindowsNodeClient(
"ws://localhost:18789",
"",
dataPath,
bootstrapToken: "bootstrap-token-123");
var json = InvokeBuildNodeConnectMessage(client);
using var doc = JsonDocument.Parse(json);
var auth = doc.RootElement.GetProperty("params").GetProperty("auth");
Assert.Equal("bootstrap-token-123", auth.GetProperty("bootstrapToken").GetString());
Assert.False(auth.TryGetProperty("token", out _));
}
finally
{
if (Directory.Exists(dataPath))
Directory.Delete(dataPath, true);
}
}
[Fact]
public void BuildNodeConnectMessage_UsesStoredDeviceToken_OverBootstrapToken()
{
var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(dataPath);
try
{
using var client = new WindowsNodeClient(
"ws://localhost:18789",
"",
dataPath,
bootstrapToken: "bootstrap-token-123");
var identityField = typeof(WindowsNodeClient).GetField(
"_deviceIdentity",
BindingFlags.NonPublic | BindingFlags.Instance);
var identity = identityField!.GetValue(client)!;
var storeMethod = identity.GetType().GetMethod("StoreDeviceToken");
storeMethod!.Invoke(identity, ["stored-device-token"]);
var json = InvokeBuildNodeConnectMessage(client);
using var doc = JsonDocument.Parse(json);
var auth = doc.RootElement.GetProperty("params").GetProperty("auth");
Assert.Equal("stored-device-token", auth.GetProperty("token").GetString());
Assert.False(auth.TryGetProperty("bootstrapToken", out _));
}
finally
{
if (Directory.Exists(dataPath))
Directory.Delete(dataPath, true);
}
}
[Fact]
public void BuildNodeConnectMessage_UsesGatewayToken_WhenNoBootstrapOrDeviceToken()
{
var dataPath = Path.Combine(Path.GetTempPath(), $"openclaw-node-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(dataPath);
try
{
using var client = new WindowsNodeClient("ws://localhost:18789", "gateway-token", dataPath);
var json = InvokeBuildNodeConnectMessage(client);
using var doc = JsonDocument.Parse(json);
var auth = doc.RootElement.GetProperty("params").GetProperty("auth");
Assert.Equal("gateway-token", auth.GetProperty("token").GetString());
Assert.False(auth.TryGetProperty("bootstrapToken", out _));
}
finally
{
if (Directory.Exists(dataPath))
Directory.Delete(dataPath, true);
}
}
[Fact]
public void RegisterCapability_AddsToCapabilitiesListAndRegistration()
{
@ -1022,6 +1109,16 @@ public class WindowsNodeClientTests
await task!;
}
private static string InvokeBuildNodeConnectMessage(WindowsNodeClient client)
{
var method = typeof(WindowsNodeClient).GetMethod(
"BuildNodeConnectMessage",
BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(method);
return (string)method!.Invoke(client, ["nonce-123", 0L])!;
}
// ─── Command dispatch map tests ────────────────────────────────────────────
private sealed class MockCapability : INodeCapability

View File

@ -12,6 +12,7 @@ public class SettingsRoundTripTests
{
GatewayUrl = "ws://localhost:18789",
Token = "secret-token",
BootstrapToken = "bootstrap-token",
UseSshTunnel = true,
SshTunnelUser = "user1",
SshTunnelHost = "remote-host",
@ -46,6 +47,7 @@ public class SettingsRoundTripTests
Assert.NotNull(restored);
Assert.Equal(original.GatewayUrl, restored.GatewayUrl);
Assert.Equal(original.Token, restored.Token);
Assert.Equal(original.BootstrapToken, restored.BootstrapToken);
Assert.Equal(original.UseSshTunnel, restored.UseSshTunnel);
Assert.Equal(original.SshTunnelUser, restored.SshTunnelUser);
Assert.Equal(original.SshTunnelHost, restored.SshTunnelHost);
@ -97,6 +99,7 @@ public class SettingsRoundTripTests
Assert.NotNull(settings);
Assert.Null(settings.GatewayUrl);
Assert.Null(settings.Token);
Assert.Null(settings.BootstrapToken);
Assert.False(settings.UseSshTunnel);
Assert.Null(settings.SshTunnelUser);
Assert.Null(settings.SshTunnelHost);
@ -149,6 +152,7 @@ public class SettingsRoundTripTests
Assert.NotNull(settings);
Assert.Equal("ws://localhost:18789", settings.GatewayUrl);
Assert.Equal("abc", settings.Token);
Assert.Null(settings.BootstrapToken);
Assert.False(settings.UseSshTunnel);
Assert.Null(settings.SshTunnelUser);
Assert.Null(settings.SshTunnelHost);