diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 026099fa7..4c2f57162 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -67,6 +67,8 @@ public partial class App : Application, OpenClawTray.Services.IAppCommands internal VoiceService? VoiceService => _nodeService?.VoiceService ?? _standaloneVoiceService; /// The full device ID of the local node service (if running). internal string? NodeFullDeviceId => _nodeService?.FullDeviceId; + /// Live node service instance used by settings surfaces for MCP status. + internal NodeService? ActiveNodeService => _nodeService; /// /// Session key that the chat surface should select on its next mount. @@ -644,7 +646,7 @@ _dispatcherQueue is null credentialResolver, clientFactory, _gatewayRegistry, appLogger, identityStore: new DeviceIdentityFileStore(appLogger), nodeConnector: nodeConnector, - isNodeEnabled: ShouldInitializeNodeService, + isNodeEnabled: IsGatewayNodeEnabled, diagnostics: diagnostics, tunnelManager: _sshTunnelService); _connectionManager.OperatorClientChanged += OnOperatorClientChanged; @@ -1541,7 +1543,7 @@ record = SyncGatewayBrowserProxyForward(record); if (credential == null) { var nodeCredential = ResolveStartupNodeCredential(record, resolver, identityDir); - if (nodeCredential != null && ShouldInitializeNodeService()) + if (nodeCredential != null && IsGatewayNodeEnabled()) { Logger.Info( $"Connecting node-only gateway during {context}: {record.Url} ({nodeCredential.Source})"); @@ -1562,6 +1564,8 @@ record = SyncGatewayBrowserProxyForward(record); ObserveBackgroundFault( _connectionManager.ConnectAsync(record.Id), $"[App] Startup gateway connect failed during {context}"); + if (!IsGatewayNodeEnabled()) + TryStartLocalMcpOnlyNode(); return true; } @@ -1891,6 +1895,12 @@ private bool ShouldInitializeNodeService() return _settings?.EnableNodeMode == true || _settings?.EnableMcpServer == true; } + /// True when this PC should connect as a gateway node. + private bool IsGatewayNodeEnabled() + { + return _settings?.EnableNodeMode == true; + } + /// /// Ensures a WSL keepalive process is running for the local gateway distro /// so the WSL2 VM stays up even after the tray exits. @@ -2672,8 +2682,8 @@ private void OnGatewayConnectionStatusChanged(object? sender, ConnectionStatus s if (status == ConnectionStatus.Connected) { _ = RunHealthCheckAsync(); - // For local gateways, the NodeConnector is suppressed because NodeService - // owns the identity. Connect the NodeService directly after operator connects. + // Gateway-node mode connects the NodeService after operator auth; MCP-only + // mode keeps serving local tools and must not escalate into node pairing. _ = TryConnectLocalNodeServiceAsync(); } } @@ -2686,7 +2696,7 @@ private void OnGatewayConnectionStatusChanged(object? sender, ConnectionStatus s /// private async Task TryConnectLocalNodeServiceAsync() { - if (_connectionManager == null) + if (_connectionManager == null || !IsGatewayNodeEnabled()) return; Logger.Info("[App] Auto-connecting local NodeService via EnsureNodeConnectedAsync"); diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs index 065b5e044..b26936f96 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml.cs @@ -321,10 +321,9 @@ private void ApplyPlan(ConnectionPagePlan plan) bool isRecovery = plan.Mode == ConnectionPageMode.Recovery; bool isAdding = plan.Mode == ConnectionPageMode.AddGateway; - // Operator + Node cards only when we actually have an active operator - // connection AND we're not in a focused sub-view (Welcome / Recovery / - // AddGateway). Recovery's help block carries the action; the role - // cards would just compete with it. + // Operator + Node cards are normally tied to an active operator session. + // Local MCP-only mode has no operator session, but still needs the Node + // card so users can see that MCP is serving local tools. bool hasOperatorSession = _lastSnapshot.OverallState is OverallConnectionState.Connected or OverallConnectionState.Ready @@ -332,9 +331,12 @@ or OverallConnectionState.Degraded or OverallConnectionState.Connecting or OverallConnectionState.PairingRequired or OverallConnectionState.Disconnecting; - bool showRoles = hasOperatorSession && !isWelcome && !isAdding && !isRecovery; + var hasStandaloneNodeCard = plan.NodeCard != NodeCardState.Hidden && !hasOperatorSession; + bool showRoles = (hasOperatorSession || hasStandaloneNodeCard) && !isAdding && !isRecovery; CockpitPanel.Visibility = showRoles ? Visibility.Visible : Visibility.Collapsed; - OperatorSection.Visibility = showRoles ? Visibility.Visible : Visibility.Collapsed; + OperatorSection.Visibility = showRoles && plan.OperatorCard != OperatorCardState.Hidden + ? Visibility.Visible + : Visibility.Collapsed; // Bottom section: exactly one of these is visible // • SavedGatewaysCard — Cockpit / Recovery (always present when registry has items) @@ -814,6 +816,14 @@ or NodeCardState.OnNodeRateLimited Helpers.FluentIconCatalog.StatusOk, "SystemFillColorSuccessBrush", capCount == 1 ? LocalizationHelper.GetString("ConnectionPage_NodeActiveOneCapability") : string.Format(LocalizationHelper.GetString("ConnectionPage_NodeActiveCapabilities"), capCount)), + NodeCardState.OnNodeConnecting => ( + Helpers.FluentIconCatalog.Sync, + "SystemFillColorCautionBrush", + LocalizationHelper.GetString("ConnectionPage_NodeStarting")), + NodeCardState.OffMcpOnly => ( + Helpers.FluentIconCatalog.Terminal, + "SystemFillColorAttentionBrush", + LocalizationHelper.GetString("ConnectionPage_NodeMcpOnly")), NodeCardState.OnPermissionsIncomplete => ( Helpers.FluentIconCatalog.StatusWarn, "SystemFillColorCautionBrush", @@ -858,47 +868,72 @@ or NodeCardState.OnNodeRateLimited ? ResolveBrush("SystemFillColorCriticalBrush") : ResolveBrush("TextFillColorPrimaryBrush"); - // The gateway's node-list contract owns this boundary. Pending - // declarations are visible for approval context but never counted or - // labeled as approved/effective. - bool showSurfaces = settings != null && plan.NodeCard != NodeCardState.Off - && plan.NodeCard != NodeCardState.Hidden; - NodeCapabilityText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed; - NodeCommandText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed; - NodePermissionText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed; - if (showSurfaces) - { - NodeCapabilityText.Text = BuildNodeSurfaceListString( - "ConnectionPage_NodeEffectiveCapabilities", - plan.NodeEffectiveCapabilities); - NodeCommandText.Text = BuildNodeSurfaceListString( - "ConnectionPage_NodeEffectiveCommands", - plan.NodeEffectiveCommands); - NodePermissionText.Text = BuildNodePermissionListString( - "ConnectionPage_NodeEffectivePermissions", - plan.NodeEffectivePermissions); - } - - var showPendingDeclarations = showSurfaces && - (plan.NodeApprovalState is GatewayNodeApprovalState.PendingApproval or - GatewayNodeApprovalState.PendingReapproval || - plan.NodePendingDeclaredCapabilities.Count > 0 || - plan.NodePendingDeclaredCommands.Count > 0 || - plan.NodePendingDeclaredPermissions.Count > 0); - NodePendingDeclarationsPanel.Visibility = showPendingDeclarations - ? Visibility.Visible - : Visibility.Collapsed; - if (showPendingDeclarations) + if (plan.NodeCard == NodeCardState.OffMcpOnly) + { + NodeCapabilityText.Visibility = Visibility.Visible; + NodeCapabilityText.Text = LocalizationHelper.Format( + "ConnectionPage_NodeMcpOnlyReachable", NodeService.McpServerUrl); + NodeCommandText.Visibility = Visibility.Collapsed; + NodePermissionText.Visibility = Visibility.Collapsed; + NodePendingDeclarationsPanel.Visibility = Visibility.Collapsed; + + var mcpError = CurrentApp.ActiveNodeService?.McpStartupError; + if (!string.IsNullOrEmpty(mcpError)) + { + NodeStatusIcon.Glyph = Helpers.FluentIconCatalog.StatusErr; + NodeStatusIcon.Foreground = ResolveBrush("SystemFillColorCriticalBrush"); + NodeStatusText.Text = LocalizationHelper.GetString("ConnectionPage_NodeMcpError"); + NodeStatusText.Foreground = ResolveBrush("SystemFillColorCriticalBrush"); + NodeCapabilityText.Visibility = Visibility.Collapsed; + NodeBodyText.Text = mcpError; + NodeBodyText.Foreground = ResolveBrush("SystemFillColorCriticalBrush"); + NodeBodyText.Visibility = Visibility.Visible; + } + } + else { - NodePendingCapabilityText.Text = BuildNodeSurfaceListString( - "ConnectionPage_NodePendingDeclaredCapabilities", - plan.NodePendingDeclaredCapabilities); - NodePendingCommandText.Text = BuildNodeSurfaceListString( - "ConnectionPage_NodePendingDeclaredCommands", - plan.NodePendingDeclaredCommands); - NodePendingPermissionText.Text = BuildNodePermissionListString( - "ConnectionPage_NodePendingDeclaredPermissions", - plan.NodePendingDeclaredPermissions); + // Pending declarations are visible for approval context but never + // counted as the active node contract. + bool showSurfaces = settings != null && plan.NodeCard != NodeCardState.Off + && plan.NodeCard != NodeCardState.Hidden + && plan.NodeCard != NodeCardState.OnNodeConnecting; + NodeCapabilityText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed; + NodeCommandText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed; + NodePermissionText.Visibility = showSurfaces ? Visibility.Visible : Visibility.Collapsed; + if (showSurfaces) + { + NodeCapabilityText.Text = BuildNodeSurfaceListString( + "ConnectionPage_NodeEffectiveCapabilities", + plan.NodeEffectiveCapabilities); + NodeCommandText.Text = BuildNodeSurfaceListString( + "ConnectionPage_NodeEffectiveCommands", + plan.NodeEffectiveCommands); + NodePermissionText.Text = BuildNodePermissionListString( + "ConnectionPage_NodeEffectivePermissions", + plan.NodeEffectivePermissions); + } + + var showPendingDeclarations = showSurfaces && + (plan.NodeApprovalState is GatewayNodeApprovalState.PendingApproval or + GatewayNodeApprovalState.PendingReapproval || + plan.NodePendingDeclaredCapabilities.Count > 0 || + plan.NodePendingDeclaredCommands.Count > 0 || + plan.NodePendingDeclaredPermissions.Count > 0); + NodePendingDeclarationsPanel.Visibility = showPendingDeclarations + ? Visibility.Visible + : Visibility.Collapsed; + if (showPendingDeclarations) + { + NodePendingCapabilityText.Text = BuildNodeSurfaceListString( + "ConnectionPage_NodePendingDeclaredCapabilities", + plan.NodePendingDeclaredCapabilities); + NodePendingCommandText.Text = BuildNodeSurfaceListString( + "ConnectionPage_NodePendingDeclaredCommands", + plan.NodePendingDeclaredCommands); + NodePendingPermissionText.Text = BuildNodePermissionListString( + "ConnectionPage_NodePendingDeclaredPermissions", + plan.NodePendingDeclaredPermissions); + } } // Sync toggle from current settings (suppress event) @@ -1102,7 +1137,9 @@ private List BuildCapabilityChips(IReadOnlyList? capabilities, N { var chips = new List(); if (capabilities == null || capabilities.Count == 0) return chips; - if (state == NodeCardState.Off || state == NodeCardState.Hidden) return chips; + if (state == NodeCardState.Off || state == NodeCardState.Hidden + || state == NodeCardState.OffMcpOnly || state == NodeCardState.OnNodeConnecting) + return chips; void Add(string label, bool enabled, bool warn = false, bool error = false) { diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs index c32e75d01..e08310a59 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs @@ -86,7 +86,11 @@ internal enum NodeCardState { Hidden, Off, + /// Gateway node is off, local MCP server is enabled. + OffMcpOnly, OnHealthy, + /// Node role is connecting / starting up (not yet ready). + OnNodeConnecting, OnPermissionsIncomplete, OnNodeApprovalRequired, OnNodeReapprovalRequired, @@ -220,7 +224,7 @@ private static ConnectionPagePlan BuildDerived( // ─── Derived layout ─── return snap.OverallState switch { - OverallConnectionState.Idle => BuildIdle(savedGatewayCount, activeRecord), + OverallConnectionState.Idle => BuildIdle(savedGatewayCount, activeRecord, settings), OverallConnectionState.Connecting => BuildCockpitConnecting(snap, activeRecord, displayName), @@ -249,7 +253,7 @@ private static ConnectionPagePlan BuildDerived( ActiveGatewayHasSshTunnel = activeRecord?.SshTunnel != null, }, - _ => BuildIdle(savedGatewayCount, activeRecord), + _ => BuildIdle(savedGatewayCount, activeRecord, settings), }; } @@ -257,8 +261,12 @@ private static ConnectionPagePlan BuildDerived( // Mode builders // ─────────────────────────────────────────────────────────────────── - private static ConnectionPagePlan BuildIdle(int savedCount, GatewayRecord? activeRecord) + private static ConnectionPagePlan BuildIdle( + int savedCount, + GatewayRecord? activeRecord, + SettingsManager? settings) { + var idleNodeCard = BuildIdleNodeCardState(settings); if (savedCount == 0) { return new ConnectionPagePlan @@ -268,11 +276,12 @@ private static ConnectionPagePlan BuildIdle(int savedCount, GatewayRecord? activ StripAccent = ConnectionAccent.Neutral, StripHeadline = "No gateway yet", StripSub = "Add a gateway to get started.", + NodeCard = idleNodeCard, }; } // Saved gateways exist but none active — drop straight into Cockpit - // (Operator/Node panels hide themselves because OperatorCardState=Hidden). + // (role panels hide themselves unless local MCP-only status is visible). return new ConnectionPagePlan { Mode = ConnectionPageMode.Cockpit, @@ -280,6 +289,7 @@ private static ConnectionPagePlan BuildIdle(int savedCount, GatewayRecord? activ StripAccent = ConnectionAccent.Neutral, StripHeadline = "Not connected", StripSub = "Pick a gateway below, or add a new one.", + NodeCard = idleNodeCard, RelevantGatewayId = activeRecord?.Id, }; } @@ -617,7 +627,8 @@ GatewayNodeApprovalState.PendingApproval or var nodeCardAllowsTrustOverride = plan.NodeCard is NodeCardState.OnHealthy or NodeCardState.OnPermissionsIncomplete or - NodeCardState.OnNodePairingRequired || + NodeCardState.OnNodePairingRequired or + NodeCardState.OnNodeConnecting || nodeConnectingAllowsTrustOverride; // Authoritative node-list trust can override any non-device-pair card. // Snapshot fallback is narrower: Unknown stays on discovery-only pairing UI. @@ -685,14 +696,16 @@ NodeCardState.OnPermissionsIncomplete or private static NodeCardState BuildNodeCardState(GatewayConnectionSnapshot snap, SettingsManager? settings) { if (settings == null) return NodeCardState.Hidden; - if (!settings.EnableNodeMode) return NodeCardState.Off; - // Operator must be connected for the node card to be meaningful. + if (!settings.EnableNodeMode) + return settings.EnableMcpServer ? NodeCardState.OffMcpOnly : NodeCardState.Off; + if (snap.OperatorState != RoleConnectionState.Connected) return NodeCardState.Off; return snap.NodeState switch { + RoleConnectionState.Connecting => NodeCardState.OnNodeConnecting, RoleConnectionState.PairingRequired => NodeCardState.OnNodePairingRequired, RoleConnectionState.PairingRejected => NodeCardState.OnNodeRejected, RoleConnectionState.RateLimited => NodeCardState.OnNodeRateLimited, @@ -702,6 +715,15 @@ _ when CountEnabledCapabilities(settings) == 0 => NodeCardState.OnPermissionsInc }; } + private static NodeCardState BuildIdleNodeCardState(SettingsManager? settings) + { + if (settings == null) return NodeCardState.Hidden; + + return !settings.EnableNodeMode && settings.EnableMcpServer + ? NodeCardState.OffMcpOnly + : NodeCardState.Hidden; + } + private static string? BuildNodeApproveCommand(GatewayConnectionSnapshot snap) { if (snap.NodeState != RoleConnectionState.PairingRequired) return null; diff --git a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml index 320f47b98..aa1c46309 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml @@ -175,7 +175,7 @@ diff --git a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs index 9ab3e5fb0..1309f89b8 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/PermissionsPage.xaml.cs @@ -1,6 +1,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; +using OpenClaw.Connection; using OpenClaw.Shared; using OpenClaw.Shared.Capabilities; using OpenClawTray.Helpers; @@ -24,6 +25,7 @@ public sealed partial class PermissionsPage : Page private bool _suppressTtsProviderChange; private readonly List _featureToggles = new(); private List _policyRules = new(); + private const int BrowserProxyToggleIndex = 1; // Sentinel rendered into the API key PasswordBox so the user can see // that a key is already saved without us ever surfacing the plaintext. @@ -56,12 +58,29 @@ private void OnLoaded(object sender, RoutedEventArgs e) { if (CurrentApp.Settings != null) CurrentApp.Settings.Saved += OnSettingsSaved; + + var mgr = CurrentApp.ConnectionManager; + if (mgr != null) + mgr.StateChanged += OnConnectionStateChanged; } private void OnUnloaded(object sender, RoutedEventArgs e) { if (CurrentApp.Settings != null) CurrentApp.Settings.Saved -= OnSettingsSaved; + + var mgr = CurrentApp.ConnectionManager; + if (mgr != null) + mgr.StateChanged -= OnConnectionStateChanged; + } + + private void OnConnectionStateChanged(object? sender, GatewayConnectionSnapshot snapshot) + { + DispatcherQueue?.TryEnqueue(() => + { + if (!IsLoaded) return; + UpdateNodeStatus(); + }); } private bool _suppressNodeModeToggle; @@ -119,18 +138,18 @@ private void ReloadFeatureToggleStates() } } - /// - /// Disables and dims the sub-toggles when Node Mode is off so users see they have - /// no effect until Node Mode is back on. ItemsRepeater isn't a Control (no IsEnabled), - /// so we apply per-toggle plus an Opacity on the repeater. - /// + /// Enables capability toggles whenever either node transport can serve them. private void ApplyFeaturesEnabledState() { - var nodeEnabled = CurrentApp.Settings?.EnableNodeMode ?? false; - CapabilityRepeater.Opacity = nodeEnabled ? 1.0 : 0.4; - foreach (var toggle in _featureToggles) - toggle.IsEnabled = nodeEnabled; - FeaturesSectionDescription.Text = LocalizationHelper.GetString(nodeEnabled + var s = CurrentApp.Settings; + var canServe = (s?.EnableNodeMode ?? false) || (s?.EnableMcpServer ?? false); + CapabilityRepeater.Opacity = canServe ? 1.0 : 0.4; + for (int i = 0; i < _featureToggles.Count; i++) + { + var isBrowserProxyToggle = i == BrowserProxyToggleIndex; + _featureToggles[i].IsEnabled = canServe && (!isBrowserProxyToggle || s?.EnableNodeMode == true); + } + FeaturesSectionDescription.Text = LocalizationHelper.GetString(canServe ? "PermissionsPage_FeaturesDescription_Enabled" : "PermissionsPage_FeaturesDescription_Disabled"); } @@ -472,16 +491,52 @@ private void OnTtsElevenLabsCommitted(object sender, RoutedEventArgs e) private void UpdateNodeStatus() { - var nodeEnabled = CurrentApp.Settings?.EnableNodeMode ?? false; - var isConnected = (CurrentApp.AppState?.Status ?? ConnectionStatus.Disconnected) == ConnectionStatus.Connected; + var settings = CurrentApp.Settings; + var nodeEnabled = settings?.EnableNodeMode ?? false; + var mcpEnabled = settings?.EnableMcpServer ?? false; if (!nodeEnabled) { - NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.Gray); - NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_Disabled"); - NodeDetailsText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_DisabledDetails"); + if (mcpEnabled && settings != null) + { + var mcpError = CurrentApp.ActiveNodeService?.McpStartupError; + if (!string.IsNullOrEmpty(mcpError)) + { + NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.OrangeRed); + NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_McpError"); + NodeDetailsText.Text = mcpError; + } + else + { + NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.DodgerBlue); + NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_McpOnly"); + NodeDetailsText.Text = LocalizationHelper.Format( + "PermissionsPage_NodeStatus_McpOnlyDetailsFormat", + NodeCapabilityGating.CountMcpServedCapabilities(settings), + NodeService.McpServerUrl); + } + } + else + { + NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.Gray); + NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_Disabled"); + NodeDetailsText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_DisabledDetails"); + } + return; } - else if (isConnected) + + var snap = CurrentApp.ConnectionManager?.CurrentSnapshot; + var nodeState = snap?.NodeState ?? RoleConnectionState.Idle; + var operatorConnected = snap?.OperatorState == RoleConnectionState.Connected; + var mcpStartupError = CurrentApp.ActiveNodeService?.McpStartupError; + + if (mcpEnabled && !string.IsNullOrEmpty(mcpStartupError)) + { + NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.OrangeRed); + NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_McpError"); + NodeDetailsText.Text = mcpStartupError; + } + else if (nodeState == RoleConnectionState.Connected && operatorConnected) { NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.LimeGreen); NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_Active"); @@ -496,11 +551,22 @@ private void UpdateNodeStatus() caps.Count, string.Join(", ", caps)) : LocalizationHelper.GetString("PermissionsPage_NodeStatus_NoCapabilities"); } + else if (nodeState == RoleConnectionState.Connecting) + { + NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.Goldenrod); + NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_Starting"); + NodeDetailsText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_NotConnectedDetails"); + } else { NodeStatusDot.Fill = new SolidColorBrush(Microsoft.UI.Colors.Orange); NodeStatusText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_NotConnected"); - NodeDetailsText.Text = LocalizationHelper.GetString("PermissionsPage_NodeStatus_NotConnectedDetails"); + NodeDetailsText.Text = mcpEnabled && settings != null && string.IsNullOrEmpty(mcpStartupError) + ? LocalizationHelper.Format( + "PermissionsPage_NodeStatus_McpOnlyDetailsFormat", + NodeCapabilityGating.CountMcpServedCapabilities(settings), + NodeService.McpServerUrl) + : LocalizationHelper.GetString("PermissionsPage_NodeStatus_NotConnectedDetails"); } } @@ -519,6 +585,14 @@ private void UpdateMcpStatus() if (settings.EnableMcpServer) { + var mcpError = CurrentApp.ActiveNodeService?.McpStartupError; + if (!string.IsNullOrEmpty(mcpError)) + { + McpStatusText.Text = + $"{LocalizationHelper.GetString("PermissionsPage_NodeStatus_McpError")}: {mcpError}"; + return; + } + var tokenPath = NodeService.McpTokenPath; var tokenExists = File.Exists(tokenPath); McpStatusText.Text = LocalizationHelper.GetString(tokenExists @@ -535,6 +609,8 @@ private void OnMcpToggled(object sender, RoutedEventArgs e) CurrentApp.Settings.Save(); ((IAppCommands)CurrentApp).NotifySettingsSaved(); UpdateMcpStatus(); + UpdateNodeStatus(); + ApplyFeaturesEnabledState(); } private void OnCopyMcpToken(object sender, RoutedEventArgs e) diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs b/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs index a26bd026e..6a2c2911b 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeCapabilityGating.cs @@ -61,4 +61,17 @@ public static bool ShouldRegisterBrowserProxy(SettingsManager? s, string? shared } public static bool ShouldRegisterSystemRun(SettingsManager? s) => s?.NodeSystemRunEnabled != false; + + /// Counts node capability categories served by local MCP without a gateway node client. + public static int CountMcpServedCapabilities(SettingsManager? s) + { + int n = 2; // system + device are always registered + if (ShouldRegisterCanvas(s)) n++; + if (ShouldRegisterScreen(s)) n++; + if (ShouldRegisterCamera(s)) n++; + if (ShouldRegisterLocation(s)) n++; + if (ShouldRegisterTts(s)) n++; + if (ShouldRegisterStt(s)) n++; + return n; + } } diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 8bcb4e73f..02652edf6 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -1684,7 +1684,7 @@ On your gateway host (Mac/Linux), run: Local MCP Server - Expose capabilities via HTTP for CLI tools and local integrations. + Serves capabilities to local MCP clients (CLI tools, integrations) on this PC over HTTP. Endpoint: @@ -3547,6 +3547,18 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo Providing {0} capabilities: {1} + + Local MCP only + + + Serving {0} capabilities to local MCP clients at {1} + + + Node starting… + + + Local MCP server failed to start + No capabilities enabled. @@ -5329,6 +5341,18 @@ Make sure the gateway is running. Node mode disabled + + Node starting… + + + Serving capabilities locally (MCP only) + + + Reachable by local MCP clients at {0} + + + Local MCP server failed to start + No capabilities enabled. Pick what to share in Permissions. diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 7a415c547..96ea77906 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -1636,7 +1636,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : Serveur MCP local - Exposez les fonctionnalités via HTTP pour les outils CLI et les intégrations locales. + Fournit des fonctionnalités aux clients MCP locaux (outils CLI, intégrations) sur ce PC via HTTP. Point de terminaison : @@ -3499,6 +3499,18 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Providing {0} capabilities: {1} + + MCP local uniquement + + + Fourniture de {0} capacités aux clients MCP locaux à {1} + + + Node en cours de démarrage… + + + Échec du démarrage du serveur MCP local + No capabilities enabled. @@ -5281,6 +5293,18 @@ Assurez-vous que la passerelle est en cours d'exécution. Mode nœud désactivé + + Node en cours de démarrage… + + + Capacités fournies localement (MCP uniquement) + + + Accessible par les clients MCP locaux à {0} + + + Échec du démarrage du serveur MCP local + Aucune capacité activée. Choisissez ce que vous souhaitez partager dans les Autorisations. diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index ce5b67238..01dde0c68 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -1637,7 +1637,7 @@ Voer op uw gateway-host (Mac/Linux) uit: Lokale MCP-server - Stel mogelijkheden beschikbaar via HTTP voor CLI-hulpprogramma’s en lokale integraties. + Levert mogelijkheden aan lokale MCP-clients (CLI-hulpprogramma's, integraties) op deze pc via HTTP. Eindpunt: @@ -3500,6 +3500,18 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Providing {0} capabilities: {1} + + Alleen lokale MCP + + + Levert {0} mogelijkheden aan lokale MCP-clients op {1} + + + Node wordt gestart… + + + Lokale MCP-server kon niet worden gestart + No capabilities enabled. @@ -5282,6 +5294,18 @@ Controleer of de gateway actief is. Nodemodus uitgeschakeld + + Node wordt gestart… + + + Mogelijkheden lokaal beschikbaar (alleen MCP) + + + Bereikbaar voor lokale MCP-clients op {0} + + + Lokale MCP-server kon niet worden gestart + Geen mogelijkheden ingeschakeld. Kies wat u wilt delen bij Machtigingen. diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 42a6cd3e7..38e5ecbad 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -1636,7 +1636,7 @@ 本地 MCP 服务器 - 通过 HTTP 向 CLI 工具和本地集成公开功能。 + 通过 HTTP 向此电脑上的本地 MCP 客户端(CLI 工具、集成)提供功能。 终结点: @@ -3499,6 +3499,18 @@ Providing {0} capabilities: {1} + + 仅本地 MCP + + + 正在通过 {1} 向本地 MCP 客户端提供 {0} 项功能 + + + Node 正在启动… + + + 本地 MCP 服务器启动失败 + No capabilities enabled. @@ -5282,6 +5294,18 @@ 节点模式已禁用 + + Node 正在启动… + + + 正在本地提供功能(仅 MCP) + + + 本地 MCP 客户端可通过 {0} 访问 + + + 本地 MCP 服务器启动失败 + 未启用任何能力。请在权限中选择要共享的内容。 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 971af07ec..08258bfa4 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -1636,7 +1636,7 @@ 本機 MCP 伺服器 - 透過 HTTP 向 CLI 工具和本機整合公開功能。 + 透過 HTTP 向此電腦上的本機 MCP 用戶端(CLI 工具、整合)提供功能。 端點: @@ -3499,6 +3499,18 @@ Providing {0} capabilities: {1} + + 僅本機 MCP + + + 正在透過 {1} 向本機 MCP 用戶端提供 {0} 項功能 + + + Node 正在啟動… + + + 本機 MCP 伺服器啟動失敗 + No capabilities enabled. @@ -5282,6 +5294,18 @@ 節點模式已停用 + + Node 正在啟動… + + + 正在本機提供功能(僅 MCP) + + + 本機 MCP 用戶端可透過 {0} 存取 + + + 本機 MCP 伺服器啟動失敗 + 未啟用任何功能。請在權限中選擇要共用的內容。 diff --git a/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs index 9b3423f8e..1a573cfca 100644 --- a/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs +++ b/tests/OpenClaw.E2ETests/Setup/SetupAndConnectTests.cs @@ -724,6 +724,14 @@ private async Task ApproveNewPendingDeviceRequestsUntilReadyAsync( if (credentials.HasOperatorToken && credentials.HasNodeToken && !credentials.HasBootstrapToken) return; + if (credentials.HasOperatorToken && !credentials.HasNodeToken && !credentials.HasBootstrapToken) + { + using var reconnectNodeDoc = await tray.Client.CallToolExpectSuccessAsync("app.connection.reconnectNode"); + Assert.True(reconnectNodeDoc.RootElement.GetProperty("reconnected").GetBoolean()); + await Task.Delay(500); + continue; + } + using var approvals = await ReadPendingApprovalsFromConnectionPageAsync(); lastDevicesOutput = approvals.RootElement.GetRawText(); var approvedAny = false; diff --git a/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs b/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs index 5a7c337af..14fc49871 100644 --- a/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs +++ b/tests/OpenClaw.Shared.Tests/WebSocketClientBaseTests.cs @@ -409,7 +409,9 @@ await WaitForConditionAsync( Task.Delay(TimeSpan.FromSeconds(3))); Assert.Same(client.ThirdConnected, reconnect); - Assert.True(server.AcceptedCount >= 3); + await WaitForConditionAsync( + () => server.AcceptedCount >= 3, + TimeSpan.FromSeconds(2)); client.Dispose(); } diff --git a/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs b/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs index 1294b1555..46793a2e7 100644 --- a/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs +++ b/tests/OpenClaw.Tray.Tests/NodeCapabilityGatingTests.cs @@ -166,6 +166,55 @@ public void DefaultOnCapabilities_OnlyDisabledWhenExplicitlySetToFalse() Assert.False(NodeCapabilityGating.ShouldRegisterSystemRun(s)); } + // ── CountMcpServedCapabilities ──────────────────────────────────────────── + + [Fact] + public void CountMcpServed_Defaults_AreSixCapabilities() + { + var s = NewSettings(); + Assert.Equal(6, NodeCapabilityGating.CountMcpServedCapabilities(s)); + } + + [Fact] + public void CountMcpServed_NullSettings_AreSix() + { + Assert.Equal(6, NodeCapabilityGating.CountMcpServedCapabilities(null)); + } + + [Fact] + public void CountMcpServed_ExcludesBrowserProxy() + { + var s = NewSettings(); + var before = NodeCapabilityGating.CountMcpServedCapabilities(s); + s.NodeBrowserProxyEnabled = false; + Assert.Equal(before, NodeCapabilityGating.CountMcpServedCapabilities(s)); + } + + [Fact] + public void CountMcpServed_SystemAndDeviceAlwaysCounted_EvenWhenSystemRunDisabled() + { + var s = NewSettings(); + s.NodeCanvasEnabled = false; + s.NodeScreenEnabled = false; + s.NodeCameraEnabled = false; + s.NodeLocationEnabled = false; + s.NodeBrowserProxyEnabled = false; + s.NodeSystemRunEnabled = false; + s.NodeTtsEnabled = false; + s.NodeSttEnabled = false; + Assert.Equal(2, NodeCapabilityGating.CountMcpServedCapabilities(s)); + } + + [Fact] + public void CountMcpServed_OptInCapabilities_IncrementCount() + { + var s = NewSettings(); + var baseline = NodeCapabilityGating.CountMcpServedCapabilities(s); + s.NodeTtsEnabled = true; + s.NodeSttEnabled = true; + Assert.Equal(baseline + 2, NodeCapabilityGating.CountMcpServedCapabilities(s)); + } + // ── GetLocalNodeCapabilities ────────────────────────────────────────────── [Fact] diff --git a/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs b/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs new file mode 100644 index 000000000..1abe6014c --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs @@ -0,0 +1,233 @@ +using System.Linq; +using OpenClaw.Connection; +using OpenClawTray.Pages; +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +/// +/// Pins source-level UI contracts because OpenClaw.Tray.Tests cannot reference +/// the WinUI assembly directly. +/// +public sealed class NodeModeUiStateTests +{ + [Fact] + public void NodeCardState_DeclaresMcpOnlyAndConnecting() + { + var plan = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "ConnectionPagePlan.cs"); + + Assert.Contains("OffMcpOnly", plan); + Assert.Contains("OnNodeConnecting", plan); + } + + [Fact] + public void BuildNodeCardState_MapsMcpOnlyWhenNodeModeOffButMcpEnabled() + { + var plan = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "ConnectionPagePlan.cs"); + + Assert.Contains( + "settings.EnableMcpServer ? NodeCardState.OffMcpOnly : NodeCardState.Off", + plan); + } + + [Theory] + [InlineData(0, (int)ConnectionPageMode.Welcome)] + [InlineData(1, (int)ConnectionPageMode.Cockpit)] + public void IdlePlan_SurfacesMcpOnlyNodeCardWithoutGatewaySession( + int savedGatewayCount, + int expectedMode) + { + var settingsDirectory = Path.Combine( + Path.GetTempPath(), + "openclaw-node-mode-ui-" + Guid.NewGuid().ToString("N")); + try + { + var settings = new SettingsManager(settingsDirectory) + { + EnableMcpServer = true, + EnableNodeMode = false + }; + + var plan = ConnectionPagePlan.Build( + GatewayConnectionSnapshot.Idle, + activeRecord: null, + self: null, + settings: settings, + savedGatewayCount: savedGatewayCount); + + Assert.Equal((ConnectionPageMode)expectedMode, plan.Mode); + Assert.Equal(NodeCardState.OffMcpOnly, plan.NodeCard); + Assert.Equal(OperatorCardState.Hidden, plan.OperatorCard); + } + finally + { + if (Directory.Exists(settingsDirectory)) + Directory.Delete(settingsDirectory, recursive: true); + } + } + + [Fact] + public void BuildNodeCardState_MapsConnectingToStartingState() + { + var plan = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "ConnectionPagePlan.cs"); + + Assert.Contains( + "RoleConnectionState.Connecting => NodeCardState.OnNodeConnecting", + plan); + } + + [Fact] + public void ConnectionPage_PresentsMcpOnlyAndStartingStates() + { + var page = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "ConnectionPage.xaml.cs"); + + Assert.Contains("NodeCardState.OffMcpOnly", page); + Assert.Contains("NodeCardState.OnNodeConnecting", page); + Assert.Contains("ConnectionPage_NodeMcpOnly", page); + Assert.Contains("ConnectionPage_NodeMcpOnlyReachable", page); + Assert.Contains("ConnectionPage_NodeStarting", page); + Assert.Contains("NodeService.McpServerUrl", page); + Assert.Contains("ConnectionPage_NodeMcpError", page); + Assert.Contains("ActiveNodeService", page); + Assert.Contains("var hasStandaloneNodeCard = plan.NodeCard != NodeCardState.Hidden && !hasOperatorSession;", page); + Assert.Contains("showRoles = (hasOperatorSession || hasStandaloneNodeCard)", page); + } + + [Fact] + public void App_GatewayNodeConnection_GatedOnNodeModeOnly_NotMcp() + { + var app = ReadSource("src", "OpenClaw.Tray.WinUI", "App.xaml.cs"); + var connectMethod = ExtractMethodBody(app, "bool TryConnectGatewayIfCredentialAvailable"); + + Assert.Contains("isNodeEnabled: IsGatewayNodeEnabled", app); + + var gate = ExtractMethodBody(app, "bool IsGatewayNodeEnabled"); + Assert.Contains("EnableNodeMode == true", gate); + Assert.DoesNotContain("EnableMcpServer", gate); + + Assert.Contains("nodeCredential != null && IsGatewayNodeEnabled()", app); + Assert.Contains("TryStartLocalMcpOnlyNode()", connectMethod); + + var localNodeConnect = ExtractMethodBody(app, "Task TryConnectLocalNodeServiceAsync"); + Assert.Contains("!IsGatewayNodeEnabled()", localNodeConnect); + Assert.Contains("EnsureNodeConnectedAsync()", localNodeConnect); + } + + [Fact] + public void PermissionsPage_PresentsMcpOnlyNodeStatus() + { + var page = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs"); + + Assert.Contains("EnableMcpServer", page); + Assert.Contains("PermissionsPage_NodeStatus_McpOnly", page); + Assert.Contains("PermissionsPage_NodeStatus_McpOnlyDetailsFormat", page); + Assert.Contains("NodeService.McpServerUrl", page); + Assert.Contains("CountMcpServedCapabilities", page); + Assert.Contains("PermissionsPage_NodeStatus_McpError", page); + Assert.Contains("ActiveNodeService", page); + Assert.Contains("mcpEnabled && !string.IsNullOrEmpty(mcpStartupError)", page); + Assert.Contains("McpStatusText.Text =", page); + } + + [Fact] + public void PermissionsPage_DrivesNodeStatusFromRoleState_AndSubscribesToChanges() + { + var page = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs"); + + Assert.Contains("RoleConnectionState", page); + Assert.Contains("CurrentSnapshot", page); + Assert.Contains("PermissionsPage_NodeStatus_Starting", page); + Assert.Contains("StateChanged", page); + Assert.Contains("OnConnectionStateChanged", page); + } + + [Fact] + public void PermissionsPage_CapabilityToggles_StayActionableInMcpOnly() + { + var page = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs"); + + Assert.Contains( + "var canServe = (s?.EnableNodeMode ?? false) || (s?.EnableMcpServer ?? false);", + page); + Assert.Contains("BrowserProxyToggleIndex", page); + Assert.Contains("!isBrowserProxyToggle || s?.EnableNodeMode == true", page); + } + + [Fact] + public void PermissionsPage_McpToggleRefreshesNodeStatus() + { + var page = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "PermissionsPage.xaml.cs"); + + var toggle = ExtractMethodBody(page, "OnMcpToggled"); + Assert.Contains("UpdateNodeStatus()", toggle); + } + + [Fact] + public void NewNodeStateStrings_ExistInEnUsResources() + { + var resw = ReadSource( + "src", "OpenClaw.Tray.WinUI", "Strings", "en-us", "Resources.resw"); + + foreach (var key in new[] + { + "ConnectionPage_NodeStarting", + "ConnectionPage_NodeMcpOnly", + "ConnectionPage_NodeMcpOnlyReachable", + "ConnectionPage_NodeMcpError", + "PermissionsPage_NodeStatus_McpOnly", + "PermissionsPage_NodeStatus_McpOnlyDetailsFormat", + "PermissionsPage_NodeStatus_Starting", + "PermissionsPage_NodeStatus_McpError", + }) + { + Assert.Contains($"name=\"{key}\"", resw); + } + } + + private static string ExtractMethodBody(string source, string methodName) + { + var sigIndex = source.IndexOf(methodName + "(", System.StringComparison.Ordinal); + if (sigIndex < 0) return string.Empty; + var bodyStart = source.IndexOf('{', sigIndex); + if (bodyStart < 0) return string.Empty; + int depth = 0; + for (int i = bodyStart; i < source.Length; i++) + { + if (source[i] == '{') depth++; + else if (source[i] == '}') + { + depth--; + if (depth == 0) return source.Substring(bodyStart, i - bodyStart + 1); + } + } + return source.Substring(bodyStart); + } + + private static string ReadSource(params string[] relativePathParts) + { + var root = GetRepositoryRoot(); + return File.ReadAllText(Path.Combine(new[] { root }.Concat(relativePathParts).ToArray())); + } + + private static string GetRepositoryRoot() + { + var env = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (!string.IsNullOrWhiteSpace(env) && Directory.Exists(env)) + return env; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "openclaw-windows-node.slnx")) && + Directory.Exists(Path.Combine(directory.FullName, "src"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException( + "Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path."); + } +}