diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6218b515c..56efb46b8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -74,9 +74,6 @@ openclaw-windows-hub/ │ ├── OpenClaw.SetupEngine.Tests/ # Setup engine tests │ └── OpenClaw.Tray.UITests/ # Native WinUI/A2UI UI tests │ -├── tools/ -│ └── icongen/ # Icon generation tool -│ ├── .github/workflows/ │ └── ci.yml # GitHub Actions CI/CD workflow │ diff --git a/README.md b/README.md index 90dbe71b3..ef4726faf 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ dotnet run --project src/OpenClaw.Cli -- --url ws://127.0.0.1:18789 --token "?`), `PermissionRowSnapshot`, `PermissionSeverity`. | -| `src/OpenClawTray.OnboardingV2/OnboardingV2App.cs` | Root `Component`. Owns the page area + nav bar (Back / Next-or-Finish + dot indicator). Welcome has no chrome by design. | -| `src/OpenClaw.SetupPreview/` | Standalone WinUI 3 unpackaged exe used for the inner-loop. Mounts the V2 tree against a fake `OnboardingV2State`. Reads env vars to switch page / scenario / locale and to enter capture mode (`OPENCLAW_PREVIEW_CAPTURE=1`). | -| `tools/v2_visual_diff.py` | Renders side-by-side `expected | actual` PNGs by spawning the SetupPreview exe in capture mode for each page scenario. The agent then *views* those PNGs and judges visual parity. | -| `tools/v2-design-refs/Dialog{,-1..-6}.png` | Designer source of truth (committed). | - -## How the inner loop works - -``` -edit V2 page → python tools/v2_visual_diff.py --pages welcome - → view out/v2-visual/welcome/diff.png - → iterate -``` - -`v2_visual_diff.py` does an incremental `dotnet build` of -`OpenClaw.SetupPreview` once per invocation (cached so `--all` only -builds once). Each page render takes ~2-3 s on a warm tree. - -Page scenarios baked into `PAGES` in `tools/v2_visual_diff.py`: - -| Key | Page | Notes | -| --- | --- | --- | -| `welcome` | Welcome | `Dialog.png` — no chrome, lobster + CTA + Advanced setup. | -| `progress-running` | LocalSetupProgress (in-flight) | `Dialog-1.png` — running stage card. | -| `progress-failed` | LocalSetupProgress (failure) | `Dialog-6.png` — pink Try-again card slides in. | -| `gateway` | GatewayWelcome | `Dialog-2.png` — gateway URL + health checkmark. | -| `permissions` | Permissions | `Dialog-5.png` — 5 capability rows + Refresh status. | -| `allset` | AllSet (node-mode active) | `Dialog-4.png` — amber Node-Mode card + Launch toggle. | -| `allset-no-node` | AllSet (node-mode off) | Variant — no amber card. | - -## Cutover - -The V2 flow is mounted in the live app via `OnboardingWindow`. The old -standalone/fallback v1 setup shell and pages have been removed. The -provider/model setup experience is hosted by the explicit tray-side -`GatewayWizardPage` / `GatewayWizardState` pair and embedded inside V2's -`GatewayWelcomePage`. - -Service wiring is centralised in -[`OnboardingV2Bridge`](../src/OpenClaw.Tray.WinUI/Onboarding/V2/OnboardingV2Bridge.cs): - -| Real service | V2 state field | Notes | -| --- | --- | --- | -| `LocalGatewaySetupEngine.StateChanged` | `LocalSetupRows`, `LocalSetupErrorMessage` | Re-uses `LocalSetupProgressStageMap` so setup stage behavior stays consistent. | -| `PermissionChecker.CheckAllAsync` + `SubscribeToAccessChanges` | `Permissions` | Snapshot list of `PermissionRowSnapshot`. Marshals back to UI thread. | -| `SettingsManager.GetEffectiveGatewayUrl` | `GatewayUrl` | Flips `ws://` → `http://` for the browser-launch link. | -| `SettingsManager.AutoStart` ↔ `LaunchAtStartup` | `LaunchAtStartup` | Two-way: initial value from settings; toggle change calls `_settings.Save()`. | -| `Settings.EnableNodeMode` | `NodeModeActive` | Seeded once at construction. | -| `GatewayRegistry` + WSL distro probe | `ExistingGateway` | Drives Welcome CTA/warning behavior for none, app-owned local WSL, and external-only connections. | - -Threading: every cross-thread mutation marshals through -`DispatcherQueue.TryEnqueue`. The V2 state's `StateChanged` event fires -on the UI thread, bumping a render tick in -[`OnboardingV2App.UseEffect`](../src/OpenClawTray.OnboardingV2/OnboardingV2App.cs). - -Completion: `OnboardingWindow.TryCompleteOnboarding` treats -`V2Route.AllSet` as the terminal setup page. The bridge's `Finished` event -closes the window, which routes through the shared completion logic — -persisting `Settings.AutoStart` via `AutoStartManager`, firing -`OnboardingCompleted`, and launching `HubWindow` on the chat tab when -setup is complete. - -Advanced setup: Welcome's "Advanced setup" link raises -`OnboardingV2State.AdvancedSetupRequested`; `OnboardingWindow` closes setup -without completing it and opens `HubWindow` on the Connections tab. Users -connect to existing, remote, or manual gateways there. - -Existing connections: first-run setup no longer opens automatically when -there is any usable saved gateway connection. Users can intentionally start -local setup from the Connections tab via **Install new WSL Gateway**. - -Welcome CTA/warnings: - -1. No existing gateway: primary CTA stays **Set up locally**. -2. Existing app-owned WSL gateway: primary CTA becomes **Install new WSL Gateway**; confirmation warns that the current OpenClaw WSL gateway and `OpenClawGateway` distro will be deleted before a fresh install. -3. External-only gateway: primary CTA stays **Set up locally**; confirmation says a new local WSL gateway will be installed and connected, while the external gateway remains available in Connections. - -## Follow-up backlog - -The cutover deliberately scopes down to the items below to keep this PR -reviewable. None are blocking — V2 works end-to-end without them. - -1. **Restyle the gateway wizard to native V2 UI.** The current - `GatewayWizardPage` is no longer part of the v1 shell, but it still owns - its own card/buttons while the gateway-driven provider/model flow remains - embedded in V2. -2. **Real translations for V2_* keys.** `tools/seed_v2_resw.py` seeds - every V2_* key into all five `.resw` locales with the English value - and the `Resources_AreTranslatedAllOrNoneAcrossNonEnglishLocales` - test is taught (via a `key.StartsWith("V2_")` predicate) that they - are intentionally English-only at first ship. Translations land in - a follow-up by replacing each non-en-us value. - -## Animation discipline - -- All animations live in `src/OpenClawTray.OnboardingV2/Animations.cs`. - Pages opt in via extension methods (`element.WithEntranceFadeIn(...)`). -- Pages must call `ElementCompositionPreview.SetIsTranslationEnabled(fe, true)` - before animating `Translation`. The helper does this for you. -- The `ShouldAnimate` predicate gates every helper. It returns `false` - if `V2Animations.DisableForCapture` is set OR if - `Windows.UI.ViewManagement.UISettings.AnimationsEnabled` is `false` - (Windows reduce-motion). The SetupPreview sets `DisableForCapture` in - capture mode so screenshots are deterministic. -- Don't add page-local `Composition` calls; extend `Animations.cs` so - the gating stays centralised. - -## Accessibility checklist - -- [x] Re-enabled WinUI's system focus visuals (cyan ring) on every V2 - button by removing `UseSystemFocusVisuals = false`. -- [x] Stable `AutomationProperties.AutomationId` on Back / Next / - Finish nav buttons and on every page CTA. -- [x] `AutomationProperties.Name` on the AllSet launch-at-startup - `ToggleSwitch` (which uses empty `OnContent`/`OffContent` so the - "On" label can render to the toggle's left, matching the design). -- [x] `AutomationProperties.Name` on the custom title bar, the lobster - icon, and the title text. -- [ ] Keyboard nav verified end-to-end against the live UI (cutover - gate — capture mode skips animation, we should manually confirm - tab order in interactive mode). -- [ ] Screen reader smoke-test (Narrator + NVDA) at cutover. - -## Visual validation - -`python tools/v2_visual_diff.py --all` regenerates side-by-side -PNGs under `out/v2-visual//diff.png`. A human (or the agent -running this codebase) opens those PNGs and judges parity directly -against the designer references in `tools/v2-design-refs/`. We -intentionally do not pixel-diff — small, intentional rendering -differences (DPI scaling, font hinting, drop shadows) would dominate -the signal. - -When running visual validation: - -1. Render all pages: `python tools/v2_visual_diff.py --all`. -2. View each `diff.png` and note any discrepancies in: - - Layout / spacing / alignment - - Typography (size, weight) - - Color (especially accent cyan, accent green, error pink, amber - warning) - - Iconography (asset / size / position) - - Copy (the V2Strings dictionary holds the source of truth — the - designer mocks contain a couple of typos we intentionally fixed: - `localhost18789` → `http://localhost:18789`, `Stays` → `stays`, - and `Acvtive` → `Active`). -3. If any discrepancy matters, edit the relevant page, re-render, and - visually re-check until parity is restored. diff --git a/docs/ONBOARDING_WIZARD.md b/docs/ONBOARDING_WIZARD.md index 79443d607..2eb9c75e0 100644 --- a/docs/ONBOARDING_WIZARD.md +++ b/docs/ONBOARDING_WIZARD.md @@ -1,32 +1,39 @@ # Onboarding Wizard -The onboarding wizard is now the V2 setup flow for installing a new app-owned local WSL gateway on Windows. +The onboarding wizard installs a new app-owned local WSL gateway on Windows and then runs OpenClaw onboard. ## Overview On first launch, the wizard appears only when there is no usable saved gateway connection. Users with existing gateways manage connections from the tray app's Connections tab. The local WSL setup affordance in Connections is shown only when setup has not already created an app-owned WSL gateway on this device. -The V2 setup flow walks users through: +The setup flow walks users through: -1. **Welcome** — Greeting and introduction -2. **Local setup progress** — Fresh app-owned `OpenClawGateway` WSL installation -3. **Gateway setup** — Gateway-driven provider/model configuration hosted by `GatewayWizardPage` -4. **Permissions** — Windows system permission review -5. **All set** — Feature summary and completion +1. **Security notice** — Device-trust warning before setup choices +2. **Welcome / Advanced** — Install app-owned WSL gateway or connect existing gateway from Settings +3. **Capabilities** — Recommended profile, inline Windows permission status, and install review +4. **Local setup progress** — Fresh app-owned `OpenClawGateway` WSL installation +5. **Gateway installed** — Explicit handoff from infrastructure setup to OpenClaw onboard +6. **OpenClaw onboard** — Gateway-driven provider/model/key configuration +7. **All set** — Feature summary, startup preference, and completion -The setup flow no longer configures remote/manual gateways. The Welcome page's **Advanced setup** link closes setup and opens the tray app's Connections tab. +The setup flow no longer configures remote/manual gateways inline. The Welcome page's **Connect to an existing gateway** option closes setup and opens the tray app's Connections tab. ## Screen Details ### Welcome -Displays the OpenClaw lobster icon, app title, and a brief description. If an app-owned local WSL gateway already exists, the primary CTA reads **Install new WSL Gateway** and confirmation warns that the current OpenClaw WSL gateway and distro will be deleted. If only an external gateway exists, the CTA remains **Set up locally** and confirmation explains that the external connection remains available in Connections. +Displays the OpenClaw icon, app title, and a brief description. If an app-owned local WSL gateway already exists, the primary CTA reads **Install new WSL Gateway** and confirmation warns that the current OpenClaw WSL gateway and distro will be deleted. If only an external gateway exists, the CTA remains **Set up locally** and confirmation explains that the external connection remains available in Connections. ### Local setup progress Installs and connects a new app-owned `OpenClawGateway` WSL instance from a clean WSL baseline. Setup does not export from or mutate an existing user Ubuntu distro; if WSL cannot create the named app-owned distro directly, setup fails with an actionable update message. When replacing an app-owned local gateway, the removal step is shown as part of progress and can be retried on failure. The managed distro is locked down and is not intended to be a normal interactive Ubuntu profile. For editing `openclaw.json` as the `openclaw` user and using root for protected-file administration, see [Managing the locked-down WSL gateway](WSL_GATEWAY_ADMIN.md). -### Wizard +### Capabilities and Windows permissions + +The Capabilities page applies the selected profile to both setup config and runtime `Node*` settings. Inline Windows permission rows are shown only for capabilities that need OS-level state (camera, microphone, location, screen capture). Notifications are always shown as an app-level permission. Screen capture is passive: Windows asks what to share each capture through the Graphics Capture picker. + +### OpenClaw onboard + Renders server-defined setup steps via RPC (`wizard.start` / `wizard.next`). The gateway controls the flow — steps can be: - **Note** — informational messages - **Confirm** — yes/no decisions @@ -40,18 +47,8 @@ The wizard keeps recovery choices visible while setup steps are running so users When the gateway config wizard surfaces an error and the active gateway is an app-managed WSL distro, the error state also offers **Open terminal** and **Restart gateway**. The wizard does not parse or classify the gateway's error text; it leaves the message visible and selectable so the user can copy any command the gateway reports. The buttons reuse the shared `GatewayTerminalLauncher` and `WslGatewayController` (in `OpenClaw.Connection`, also used by the Connections tab). Restart re-enters the gateway config wizard (the provider/model onboarding step — not the whole V2 onboarding, and without re-installing the WSL distro) so fixes such as newly-installed tools are picked up on `PATH`. Because the gateway restart clears its wizard session, this resumes at the first config question rather than the exact step that failed. Detection is gated on `GatewayRecord.SetupManagedDistroName`, so it never appears for remote/SSH gateways. -### Permissions -Checks 5 Windows permissions using native APIs and registry: -- Notifications (Toast capability) -- Camera (Windows.Devices.Enumeration) -- Microphone (Windows.Devices.Enumeration) -- Screen Capture (Graphics.Capture) -- Location (optional, registry-based) - -Each permission shows its current status (Enabled/Disabled/Allowed/Denied) with an "Open Settings" button linking to the relevant `ms-settings:` URI. - ### All set -Displays a completion summary, a Launch at startup toggle, and a Finish button that saves settings and closes setup. +Displays a completion summary, a Launch at startup toggle, and a Finish button that saves the startup preference before restarting the tray. Launch at startup defaults on so OpenClaw is ready after reboot. ## Security @@ -87,16 +84,15 @@ Use a temp settings directory for tests that construct `SettingsManager`, or set | Path | Purpose | |------|---------| -| `Onboarding/OnboardingWindow.cs` | Host window for the V2 setup shell | -| `src/OpenClawTray.OnboardingV2/OnboardingV2App.cs` | V2 Functional UI root component and page navigation | -| `src/OpenClawTray.OnboardingV2/OnboardingV2State.cs` | V2 shared setup state | -| `Onboarding/GatewayWizard/GatewayWizardState.cs` | Host-owned state for the embedded gateway wizard | -| `Onboarding/GatewayWizard/GatewayWizardPage.cs` | Embedded provider/model setup page inside V2 | -| `Services/LocalGatewaySetup/SetupCodeDecoder.cs` | Base64url setup code parsing used from Connections | -| `Onboarding/Services/InputValidator.cs` | Security input validation | -| `Onboarding/Services/WizardStepParser.cs` | Wizard JSON step parsing | -| `Onboarding/Services/LocalGatewayApprover.cs` | Local gateway URL classification | -| `Onboarding/Services/PermissionChecker.cs` | Windows permission checks | -| `Services/Connection/GatewayRegistry.cs` | Persistent gateway records and migration target | -| `Services/Connection/GatewayConnectionManager.cs` | Operator/node connection lifecycle used by onboarding | -| `Services/SetupExistingGatewayClassifier.cs` | Existing gateway classification for V2 Welcome and startup gating | +| `src/OpenClaw.SetupEngine.UI/SetupWindow.xaml(.cs)` | Tray-hosted setup shell, run lock, preview routing, and page navigation | +| `src/OpenClaw.SetupEngine.UI/Pages/SecurityNoticePage.xaml(.cs)` | First-run device-trust warning before setup choices | +| `src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml(.cs)` | Install-new-WSL vs connect-existing choice and existing-gateway replacement prompt | +| `src/OpenClaw.SetupEngine.UI/Pages/AdvancedSetupPage.xaml(.cs)` | Connect-existing handoff to Connection settings | +| `src/OpenClaw.SetupEngine.UI/Pages/CapabilitiesPage.xaml(.cs)` | Capability profile, inline Windows permission status, and install review | +| `src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml(.cs)` | WSL gateway install progress and gateway-installed handoff | +| `src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml(.cs)` | OpenClaw onboard provider/model/key wizard driven by gateway `wizard.*` frames | +| `src/OpenClaw.SetupEngine.UI/Pages/CompletePage.xaml(.cs)` | Success, failure, log/help, and startup preference summary | +| `src/OpenClaw.SetupEngine.UI/Pages/SetupPermissionHelper.cs` | Passive Windows permission checks and inline permission rows | +| `src/OpenClaw.Connection/GatewayRegistry.cs` | Persistent gateway records and migration target | +| `src/OpenClaw.Connection/GatewayConnectionManager.cs` | Operator/node connection lifecycle used by onboarding | +| `src/OpenClaw.Tray.WinUI/Services/SetupExistingGatewayClassifier.cs` | Existing gateway classification for Welcome and startup gating | diff --git a/docs/SETUP.md b/docs/SETUP.md index 9320a748a..3d9217d0b 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -42,7 +42,7 @@ The installer offers optional shortcuts and startup integration: ### 4. First Launch -After the installer finishes, OpenClaw Companion starts automatically. Look for the 🦞 lobster icon in the system tray (bottom-right corner of the taskbar, near the clock). +After the installer finishes, OpenClaw Companion starts automatically. Look for the OpenClaw icon in the system tray (bottom-right corner of the taskbar, near the clock). If you don't see it, check the **hidden icons** area (the `^` arrow next to the tray). @@ -52,28 +52,21 @@ The installer also creates a Start Menu group with shortcuts for **OpenClaw Comp On first launch, Molty opens the onboarding wizard when there is no usable saved gateway connection. The default flow installs and configures a dedicated app-owned local WSL gateway: -1. **Welcome** — A friendly greeting introducing OpenClaw and Molty. Click **Install new WSL Gateway** to install a new local WSL gateway. +1. **Security notice** — Confirms this is a trusted PC before local setup starts. - If you already have a local or remote gateway, choose **Advanced setup** instead. This opens the tray app's Connections tab, where you can connect with an existing gateway URL, token, or setup code without installing a new local WSL gateway. +2. **Welcome** — Choose **Install a local gateway (WSL)** to install the app-owned WSL gateway, or **Connect to an existing gateway** to open the tray app's Connections tab. For the role split behind these choices, see [Operator and node concepts](OPERATOR_NODE_CONCEPTS.md). -2. **Capabilities** — Reviews the Windows node capabilities that can be enabled, such as system commands, canvas, screen capture, camera, location, browser automation, device controls, text-to-speech, and speech-to-text. +3. **Capabilities** — Choose a capability profile, review matching Windows permission status, and see exactly what setup will install before anything runs. -3. **Local setup progress** — Installs a fresh app-owned `OpenClawGateway` WSL instance and connects Molty to it. This does not modify an existing user Ubuntu distro. +4. **Local setup progress** — Installs a fresh app-owned `OpenClawGateway` WSL instance and connects Molty to it. This does not modify an existing user Ubuntu distro. -4. **Gateway setup** — If your gateway supports it, this screen walks you through gateway-driven configuration steps (AI provider selection, personality setup, communication channels). The steps are defined by your gateway via RPC. If the gateway doesn't support wizard mode, this screen is skipped automatically. +5. **Gateway installed** — Confirms the private gateway is running and offers **Start OpenClaw onboard**. -5. **Permissions** — Reviews Windows system permissions needed for full functionality: - - **Notifications** — for toast alerts - - **Camera** — for camera capture - - **Microphone** — for voice input - - **Screen Capture** — for screenshots - - **Location** — optional, for location-aware features; packaged installs declare this capability so Windows may prompt for location consent the first time it is used +6. **OpenClaw onboard** — Gateway-driven provider/model/key setup rendered as a transcript. Recovery options stay available if the gateway wizard needs attention. - Each permission shows its current status. Click **Open Settings** next to any permission to jump directly to the relevant Windows Settings page. - -6. **All set** — A summary of available features (tray menu, channels, voice, canvas, skills). Toggle **Launch at Login** to start Molty with Windows, then click **Finish** to complete setup. +7. **All set** — A summary of available features and startup preference. Fresh setup defaults launch-at-startup on; direct OpenClaw onboard preserves any existing startup preference. After the wizard, the tray icon turns green when connected. You can re-run the wizard or change settings anytime from the tray menu. diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index c8538e958..aa55fa6ba 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -31,7 +31,7 @@ The bundled `default-config.json` ships with the tray executable and provides se ┌─────────────────────────────────────────────────────────────┐ │ OpenClaw.SetupEngine.UI (net10.0-windows10.0.22621, WinUI3)│ │ SetupWindow + pages, direct code-behind, no MVVM │ -│ Welcome → Capabilities → Progress → Permissions → Complete │ +│ Security → Welcome → Capabilities → Progress → Onboard → Complete │ └─────────────────────────────────────────────────────────────┘ ▲ hosted by project reference │ @@ -63,11 +63,12 @@ src/OpenClaw.SetupEngine.UI/ ├── OpenClaw.SetupEngine.UI.csproj # WinAppSDK library referenced by tray ├── SetupWindow.xaml / .xaml.cs # 720×820 window, Mica, title bar, navigation, setup events └── Pages/ - ├── WelcomePage.xaml / .cs # Logo, info card, Install button + ContentDialog - ├── CapabilitiesPage.xaml / .cs # 2-column grid with icons + descriptions - ├── ProgressPage.xaml / .cs # Live step rows + streaming log viewer - ├── PermissionsPage.xaml / .cs # 5 permission checks + Open Settings buttons - └── CompletePage.xaml / .cs # Party popper, amber banner, startup toggle + ├── SecurityNoticePage.xaml / .cs # Device-trust warning + ├── WelcomePage.xaml / .cs # Install WSL gateway vs connect existing + ├── CapabilitiesPage.xaml / .cs # Profile, inline permissions, install review + ├── ProgressPage.xaml / .cs # Live step rows + gateway-installed handoff + ├── WizardPage.xaml / .cs # OpenClaw onboard transcript + └── CompletePage.xaml / .cs # Mascot status badge, summary, startup toggle ``` **Total engine code: ~1,882 lines across 8 files.** UI adds ~10 more files. @@ -260,42 +261,42 @@ Log path defaults to `%APPDATA%\OpenClawTray\Logs\Setup\setup-.log` The WinUI app is a **thin shell** — no business logic, just rendering pipeline state. End-user UI runs default to `RollbackOnFailure=true`; `--no-rollback-on-failure` preserves an explicit debugging opt-out. -### Page Flow: Welcome → Capabilities → Progress → Permissions → Complete +### Page Flow: Security → Welcome → Capabilities → Progress → OpenClaw onboard → Complete + +**SecurityNoticePage** +- Native warning InfoBar for device-trust and setup transparency **WelcomePage** -- Lobster icon + "OpenClaw Setup" title bar -- Info card explaining what will be installed -- "Install new WSL Gateway" button with ContentDialog confirmation -- "Advanced setup" link → launches tray with `--page connection` +- OpenClaw icon + "OpenClaw Setup" title bar +- Install app-owned WSL gateway (recommended) or connect to existing gateway +- Replacement prompt when an app-owned WSL gateway already exists **CapabilitiesPage** -- 2-column grid showing capabilities from config -- Icons + descriptions for each (System, Canvas, Screen, Camera, etc.) -- "Continue" proceeds to Progress +- Capability profile defaults to Standard +- Inline Windows permission status for selected capabilities +- Install review showing WSL distro, OpenClaw CLI, local gateway service, and possible UAC **ProgressPage** - Step rows with spinning ProgressRing → ✓/✗ badges -- Live streaming log viewer (monospace, auto-scroll) -- On success → navigates to Permissions +- Live activity ledger collapsed by default +- On success → gateway-installed milestone with explicit OpenClaw onboard CTA - On failure → navigates to Complete(success=false) -**PermissionsPage** -- 5 permission rows: Notifications, Camera, Microphone, Location, Screen Capture -- Live status checks (registry, DeviceAccessInformation, GraphicsCaptureSession) -- "Open Settings" buttons launch `ms-settings://` URIs -- "Refresh status" button, "Continue" proceeds to Complete +**WizardPage** +- Transcript-style gateway `wizard.*` flow for provider/model/key setup +- Error state uses More options plus gateway recovery actions when available **CompletePage** -- Party popper image +- OpenClaw mascot with corner status badge - "All set!" / error heading -- Amber "Node Mode Active" warning banner -- "Launch OpenClaw at startup?" toggle (reported to tray host) -- "Finish" button asks the tray host to self-restart and open chat +- Native InfoBar for node mode +- "Launch OpenClaw at startup" toggle defaults on and is persisted before restart +- "Finish" asks the tray host to self-restart and open chat ### Window Properties - 720×820 logical pixels (DPI-scaled) - Mica backdrop -- Custom title bar with lobster icon +- Custom title bar with OpenClaw icon --- diff --git a/src/OpenClaw.Connection/ConnectionStateMachine.cs b/src/OpenClaw.Connection/ConnectionStateMachine.cs index 7613e6122..a4b709b70 100644 --- a/src/OpenClaw.Connection/ConnectionStateMachine.cs +++ b/src/OpenClaw.Connection/ConnectionStateMachine.cs @@ -123,9 +123,16 @@ public void SetNodeEnabled(bool enabled) { _nodeEnabled = enabled; if (!enabled) + { _nodeState = RoleConnectionState.Disabled; + _nodeError = null; + _nodeCredentialSource = null; + } else if (_nodeState == RoleConnectionState.Disabled) + { _nodeState = RoleConnectionState.Idle; + _nodeError = null; + } RebuildSnapshot(); } @@ -199,6 +206,15 @@ internal void SetNodeCredentialSource(string? source) RebuildSnapshot(); } + internal void BlockNodeStart(string detail) + { + _nodeEnabled = true; + _nodeState = RoleConnectionState.Error; + _nodeError = detail; + _nodeCredentialSource = null; + RebuildSnapshot(); + } + /// Update the operator pairing request ID in the snapshot. internal void SetOperatorPairingRequestId(string? requestId) { @@ -304,6 +320,7 @@ private void ApplyTransition(ConnectionTrigger trigger, string? detail) case ConnectionTrigger.NodePairingRequired: _nodeState = RoleConnectionState.PairingRequired; + _nodeError = null; break; case ConnectionTrigger.NodePaired: @@ -340,6 +357,7 @@ private void RebuildSnapshot() // Clear requestId when no longer in PairingRequired to prevent stale reads OperatorPairingRequestId = _operatorState == RoleConnectionState.PairingRequired ? Current.OperatorPairingRequestId : null, + NodeConnectionIntended = _nodeEnabled, NodeState = _nodeState, NodeError = _nodeError, NodeCredentialSource = _nodeCredentialSource, diff --git a/src/OpenClaw.Connection/GatewayConnectionManager.cs b/src/OpenClaw.Connection/GatewayConnectionManager.cs index a4792c1fc..b68179a36 100644 --- a/src/OpenClaw.Connection/GatewayConnectionManager.cs +++ b/src/OpenClaw.Connection/GatewayConnectionManager.cs @@ -51,6 +51,17 @@ public sealed class GatewayConnectionManager : IGatewayConnectionManager private bool _activeConnectUsedBootstrapToken; private bool _postBootstrapOperatorReconnectScheduled; + private const string MissingNodeCredentialMessage = + "No node credential available. Re-pair this PC or add a shared/bootstrap gateway token."; + private const string MissingNodeConnectorMessage = + "Node mode is enabled, but no node connector is configured."; + private const string MissingActiveGatewayForNodeMessage = + "Node mode is enabled, but there is no active gateway context for node startup."; + private const string MissingGatewayRecordForNodeMessage = + "Node mode is enabled, but the active gateway record could not be found."; + private const string NodeTunnelStartFailedMessage = + "Node mode is enabled, but the SSH tunnel for node startup could not be started."; + public event EventHandler? StateChanged; public event EventHandler? DiagnosticEvent; public event EventHandler? OperatorClientChanged; @@ -119,7 +130,6 @@ public async Task ConnectAsync(string? gatewayId = null) public async Task ConnectNodeOnlyAsync(string? gatewayId = null) { ThrowIfDisposed(); - var prevState = _stateMachine.Current.OverallState; long? preparedGeneration = null; await _transitionSemaphore.WaitAsync(); @@ -137,7 +147,7 @@ public async Task ConnectNodeOnlyAsync(string? gatewayId = null) var startedGeneration = await StartNodeConnectionAsync(preparedGeneration.Value); if (startedGeneration.HasValue) - EmitStateChanged(prevState); + EmitStateChanged(); } /// Core connect logic. Caller must hold . @@ -202,16 +212,16 @@ private async Task ConnectCoreAsync(string? gatewayId = null) _activeGatewayRecordId = record.Id; _activeSshTunnel = record.SshTunnel; _gatewayNeedsV2Signature = record.IsLocal || record.RequiresV2Signature; + SyncNodeIntentFromSettings(); if (credential == null) { _logger.Warn("[ConnMgr] No credential available for gateway"); - var prev = _stateMachine.Current.OverallState; // Must go through Connecting → Error since AuthenticationFailed requires Connecting state _stateMachine.TryTransition(ConnectionTrigger.ConnectRequested); _stateMachine.SetOperatorCredentialSource(null); _stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, "No credential available"); - EmitStateChanged(prev); + EmitStateChanged(); return; } @@ -220,7 +230,7 @@ private async Task ConnectCoreAsync(string? gatewayId = null) _stateMachine.TryTransition(ConnectionTrigger.ConnectRequested); _stateMachine.SetOperatorCredentialSource(credential.Source); _diagnostics.RecordStateChange(prevState, _stateMachine.Current.OverallState); - EmitStateChanged(prevState); + EmitStateChanged(); // Create client via factory — use a diagnostic-tee logger so client handshake // logs appear in the Connection Status window timeline. @@ -235,9 +245,8 @@ tunnel.SshPort is < 1 or > 65535 || { _logger.Warn("[ConnMgr] SSH tunnel config is incomplete"); _diagnostics.Record("tunnel", "SSH tunnel config is incomplete"); - var p = _stateMachine.Current.OverallState; _stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, "SSH tunnel config is incomplete"); - EmitStateChanged(p); + EmitStateChanged(); return; } try @@ -249,9 +258,8 @@ tunnel.SshPort is < 1 or > 65535 || { _logger.Error($"[ConnMgr] SSH tunnel start failed: {ex.Message}"); _diagnostics.Record("tunnel", "SSH tunnel start failed", ex.Message); - var p = _stateMachine.Current.OverallState; _stateMachine.TryTransition(ConnectionTrigger.WebSocketError, $"SSH tunnel failed: {ex.Message}"); - EmitStateChanged(p); + EmitStateChanged(); return; } } @@ -356,14 +364,6 @@ tunnel.SshPort is < 1 or > 65535 || if (!Directory.Exists(perGatewayIdentityDir)) Directory.CreateDirectory(perGatewayIdentityDir); - var nodeCredential = _credentialResolver.ResolveNode(record, perGatewayIdentityDir); - if (nodeCredential == null) - { - _logger.Warn("[ConnMgr] No node credential available for node-only connect"); - _diagnostics.Record("node", "No node credential available for node-only connect"); - return null; - } - // Same-gateway node reapproval reconnects keep the operator alive so it can // request the post-handshake node.list; all other paths reset lifecycle/tunnel state. var preservesOperatorConnection = @@ -396,15 +396,31 @@ tunnel.SshPort is < 1 or > 65535 || GatewayUrl = record.Url, GatewayName = record.FriendlyName }; + _stateMachine.SetNodeEnabled(true); + _stateMachine.StartNodeConnecting(); + _stateMachine.SetNodeCredentialSource(null); + + var nodeCredential = _credentialResolver.ResolveNode(record, perGatewayIdentityDir); + if (nodeCredential == null) + { + _logger.Warn("[ConnMgr] No node credential available for node-only connect"); + _diagnostics.Record("node", "No node credential available for node-only connect"); + _stateMachine.BlockNodeStart(MissingNodeCredentialMessage); + EmitStateChanged(); + return null; + } _diagnostics.RecordCredentialResolution(nodeCredential); _stateMachine.SetOperatorCredentialSource(operatorCredentialSource); - _stateMachine.SetNodeCredentialSource(nodeCredential.Source); _diagnostics.Record("node", $"Starting node-only connection to {record.Url}", $"Credential source: {nodeCredential.Source}"); if (!preservesOperatorConnection && !await TryStartTunnelForNodeOnlyAsync(record)) + { + _stateMachine.BlockNodeStart(NodeTunnelStartFailedMessage); + EmitStateChanged(); return null; + } return Interlocked.Read(ref _generation) == gen ? gen : null; } @@ -469,9 +485,10 @@ private async Task DisconnectCoreAsync() var prev = _stateMachine.Current.OverallState; await DisposeActiveClientAsync(); + SyncNodeIntentFromSettings(); _stateMachine.TryTransition(ConnectionTrigger.DisconnectRequested); _diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState); - EmitStateChanged(prev); + EmitStateChanged(); } public async Task ReconnectAsync() @@ -703,7 +720,6 @@ private async Task HandleOperatorStatusChangedAsync(ConnectionStatus status, lon { if (Interlocked.Read(ref _generation) != gen) return; - var prev = _stateMachine.Current.OverallState; switch (status) { case ConnectionStatus.Connected: @@ -725,7 +741,7 @@ private async Task HandleOperatorStatusChangedAsync(ConnectionStatus status, lon _diagnostics.RecordWebSocketEvent("WebSocket connecting"); break; } - EmitStateChanged(prev); + EmitStateChanged(); } finally { @@ -743,10 +759,9 @@ private async Task HandleAuthenticationFailedAsync(string message, long gen) if (TryScheduleOperatorTokenRecovery(message, gen)) return; - var prev = _stateMachine.Current.OverallState; _diagnostics.Record("error", "Authentication failed", message); _stateMachine.TryTransition(ConnectionTrigger.AuthenticationFailed, message); - EmitStateChanged(prev); + EmitStateChanged(); } finally { @@ -785,6 +800,10 @@ private static bool IsOperatorDeviceTokenMismatch(string message) => private async Task HandleHandshakeSucceededAsync(long gen) { + bool shouldStartNodeConnection = false; + bool missingGatewayRecordForNode = false; + bool missingActiveGatewayForNode = false; + bool missingNodeConnector = false; await _transitionSemaphore.WaitAsync(); try { @@ -793,7 +812,7 @@ private async Task HandleHandshakeSucceededAsync(long gen) var prev = _stateMachine.Current.OverallState; _diagnostics.Record("state", "Handshake succeeded (hello-ok)"); _stateMachine.TryTransition(ConnectionTrigger.HandshakeSucceeded); - _diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState); + var nodeModeIntended = SyncNodeIntentFromSettings(); if (_operatorTokenRecoveryAttemptedGatewayId == _activeGatewayRecordId) _operatorTokenRecoveryAttemptedGatewayId = null; @@ -803,7 +822,43 @@ private async Task HandleHandshakeSucceededAsync(long gen) _stateMachine.SetOperatorDeviceId(client.OperatorDeviceId); } - EmitStateChanged(prev); + missingActiveGatewayForNode = + nodeModeIntended && + (_activeGatewayRecordId == null || _activeIdentityPath == null); + missingGatewayRecordForNode = + nodeModeIntended && + !missingActiveGatewayForNode && + _activeGatewayRecordId != null && + _registry.GetById(_activeGatewayRecordId) == null; + shouldStartNodeConnection = + !missingActiveGatewayForNode && + !missingGatewayRecordForNode && + ShouldStartNodeConnection(); + missingNodeConnector = shouldStartNodeConnection && _nodeConnector == null; + if (missingActiveGatewayForNode) + { + _stateMachine.BlockNodeStart(MissingActiveGatewayForNodeMessage); + } + else if (missingGatewayRecordForNode) + { + _stateMachine.BlockNodeStart(MissingGatewayRecordForNodeMessage); + } + else if (missingNodeConnector) + { + _stateMachine.BlockNodeStart(MissingNodeConnectorMessage); + } + else if (shouldStartNodeConnection) + { + _stateMachine.SetNodeEnabled(true); + if (_nodeConnector != null) + { + _stateMachine.StartNodeConnecting(); + _stateMachine.SetNodeCredentialSource(null); + } + } + + _diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState); + EmitStateChanged(); // Stamp LastConnected so auto-reconnect on next startup can use this gateway. // Uses the atomic Update helper to avoid overwriting concurrent registry changes. @@ -826,10 +881,18 @@ private async Task HandleHandshakeSucceededAsync(long gen) _transitionSemaphore.Release(); } - // Start node connection outside the semaphore to avoid deadlocks - if (_nodeConnector != null && ShouldStartNodeConnection()) + // Start node connection outside the semaphore to avoid deadlocks. + // If Node mode is intended but no connector exists, publish the blocker + // through the same manager snapshot instead of leaving node Idle/healthy. + if (missingActiveGatewayForNode || missingGatewayRecordForNode || missingNodeConnector) { - await StartNodeConnectionAsync(gen); + return; + } + + if (shouldStartNodeConnection) + { + if (_nodeConnector != null) + await StartNodeConnectionAsync(gen); } } @@ -972,7 +1035,7 @@ private async Task HandlePairingRequiredAsync(string? requestId, long gen) // Store requestId in snapshot so setup flows can use it for explicit approval _stateMachine.SetOperatorPairingRequestId(requestId); _diagnostics.RecordStateChange(prev, _stateMachine.Current.OverallState); - EmitStateChanged(prev); + EmitStateChanged(); } finally { @@ -1095,6 +1158,18 @@ private bool ShouldStartNodeConnection() return _isNodeEnabled?.Invoke() ?? false; } + private bool SyncNodeIntentFromSettings() + { + var enabled = _isNodeEnabled?.Invoke() ?? false; + if (_stateMachine.Current.NodeConnectionIntended != enabled || + (!enabled && _stateMachine.Current.NodeState != RoleConnectionState.Disabled)) + { + _stateMachine.SetNodeEnabled(enabled); + } + + return enabled; + } + private bool IsCurrentNodeAttempt(long lifecycleGeneration, long nodeGeneration) => !_disposed && Interlocked.Read(ref _generation) == lifecycleGeneration && @@ -1104,9 +1179,11 @@ private bool IsCurrentNodeAttempt(long lifecycleGeneration, long nodeGeneration) long expectedLifecycleGeneration, long? expectedNodeGeneration = null) { - CancellationTokenSource nodeOperationCts; - CancellationToken nodeOperationToken; - long nodeGeneration; + CancellationTokenSource? nodeOperationCts = null; + CancellationToken nodeOperationToken = CancellationToken.None; + long nodeGeneration = 0; + string? preStartBlocker = null; + CancellationToken preStartBlockerToken = CancellationToken.None; await _nodeStartSemaphore.WaitAsync(); try @@ -1132,26 +1209,31 @@ private bool IsCurrentNodeAttempt(long lifecycleGeneration, long nodeGeneration) "Previous node disconnect")) { _diagnostics.Record("node", "Previous node disconnect timed out"); - return null; + preStartBlocker = "Previous node disconnect timed out"; + preStartBlockerToken = _operationCts?.Token ?? CancellationToken.None; } } catch (Exception ex) { _logger.Error($"[ConnMgr] Previous node disconnect failed: {ex.Message}"); _diagnostics.Record("node", "Previous node disconnect failed", ex.Message); - return null; + preStartBlocker = $"Previous node disconnect failed: {ex.Message}"; + preStartBlockerToken = _operationCts?.Token ?? CancellationToken.None; } } - lock (_nodeOperationLock) + if (preStartBlocker == null) { - if (!IsExpectedNodeStartCurrent(expectedLifecycleGeneration, expectedNodeGeneration)) - return null; + lock (_nodeOperationLock) + { + if (!IsExpectedNodeStartCurrent(expectedLifecycleGeneration, expectedNodeGeneration)) + return null; - nodeOperationCts = new CancellationTokenSource(); - nodeOperationToken = nodeOperationCts.Token; - nodeGeneration = Interlocked.Increment(ref _nodeConnectionGeneration); - _nodeOperationCts = nodeOperationCts; + nodeOperationCts = new CancellationTokenSource(); + nodeOperationToken = nodeOperationCts.Token; + nodeGeneration = Interlocked.Increment(ref _nodeConnectionGeneration); + _nodeOperationCts = nodeOperationCts; + } } } finally @@ -1159,9 +1241,19 @@ private bool IsCurrentNodeAttempt(long lifecycleGeneration, long nodeGeneration) _nodeStartSemaphore.Release(); } + if (preStartBlocker != null) + { + await BlockNodeStartAsync( + preStartBlocker, + preStartBlockerToken, + expectedLifecycleGeneration, + expectedNodeGeneration); + return null; + } + try { - return await StartNodeConnectionCoreAsync(nodeGeneration, nodeOperationToken) + return await StartNodeConnectionCoreAsync(expectedLifecycleGeneration, nodeGeneration, nodeOperationToken) ? nodeGeneration : null; } @@ -1176,7 +1268,7 @@ private bool IsCurrentNodeAttempt(long lifecycleGeneration, long nodeGeneration) if (ReferenceEquals(_nodeOperationCts, nodeOperationCts)) _nodeOperationCts = null; } - nodeOperationCts.Dispose(); + nodeOperationCts!.Dispose(); } } @@ -1188,7 +1280,50 @@ private bool IsExpectedNodeStartCurrent( (!expectedNodeGeneration.HasValue || Interlocked.Read(ref _nodeConnectionGeneration) == expectedNodeGeneration.Value); + private async Task BlockNodeStartAsync( + string detail, + CancellationToken cancellationToken, + long? expectedLifecycleGeneration = null, + long? expectedNodeGeneration = null) + { + if (expectedLifecycleGeneration.HasValue && + Interlocked.Read(ref _generation) != expectedLifecycleGeneration.Value) + { + return; + } + + if (expectedNodeGeneration.HasValue && + Interlocked.Read(ref _nodeConnectionGeneration) != expectedNodeGeneration.Value) + { + return; + } + + await _transitionSemaphore.WaitAsync(cancellationToken); + try + { + if (expectedLifecycleGeneration.HasValue && + Interlocked.Read(ref _generation) != expectedLifecycleGeneration.Value) + { + return; + } + + if (expectedNodeGeneration.HasValue && + Interlocked.Read(ref _nodeConnectionGeneration) != expectedNodeGeneration.Value) + { + return; + } + + _stateMachine.BlockNodeStart(detail); + EmitStateChanged(); + } + finally + { + _transitionSemaphore.Release(); + } + } + private async Task StartNodeConnectionCoreAsync( + long expectedLifecycleGeneration, long nodeGeneration, CancellationToken cancellationToken) { @@ -1198,30 +1333,63 @@ private async Task StartNodeConnectionCoreAsync( return false; } - if (_nodeConnector == null || _activeGatewayRecordId == null || _activeIdentityPath == null) return false; + if (_nodeConnector == null) + { + await BlockNodeStartAsync(MissingNodeConnectorMessage, cancellationToken, expectedLifecycleGeneration, nodeGeneration); + return false; + } + + if (_activeGatewayRecordId == null || _activeIdentityPath == null) + { + await BlockNodeStartAsync(MissingActiveGatewayForNodeMessage, cancellationToken, expectedLifecycleGeneration, nodeGeneration); + return false; + } var record = _registry.GetById(_activeGatewayRecordId); if (record == null) { _logger.Warn("[ConnMgr] Cannot start node — gateway record not found"); + await BlockNodeStartAsync(MissingGatewayRecordForNodeMessage, cancellationToken, expectedLifecycleGeneration, nodeGeneration); return false; } + // Mark node as enabled in the state machine so UI reflects node state + // before credential resolution can fail. Otherwise node mode could look + // healthy even though the intended node never started. + await _transitionSemaphore.WaitAsync(cancellationToken); + try + { + if (!IsExpectedNodeStartCurrent(expectedLifecycleGeneration, nodeGeneration)) + return false; + + var before = _stateMachine.Current; + _stateMachine.SetNodeEnabled(true); + _stateMachine.StartNodeConnecting(); + _stateMachine.SetNodeCredentialSource(null); + if (_stateMachine.Current != before) + EmitStateChanged(); + } + finally + { + _transitionSemaphore.Release(); + } + // Use root identity path — clients always read/write from root, not per-gateway var nodeCredential = _credentialResolver.ResolveNode(record, _activeIdentityPath!); if (nodeCredential == null) { _logger.Warn("[ConnMgr] No node credential available — skipping node connection"); _diagnostics.Record("node", "No node credential available"); + await BlockNodeStartAsync(MissingNodeCredentialMessage, cancellationToken, expectedLifecycleGeneration, nodeGeneration); return false; } - // Mark node as enabled in the state machine so UI reflects node state - // State machine is not thread-safe — acquire semaphore for mutation await _transitionSemaphore.WaitAsync(cancellationToken); try { - _stateMachine.SetNodeEnabled(true); + if (!IsExpectedNodeStartCurrent(expectedLifecycleGeneration, nodeGeneration)) + return false; + _stateMachine.SetNodeCredentialSource(nodeCredential.Source); } finally @@ -1262,6 +1430,12 @@ await _nodeConnector.ConnectAsync(nodeConnectUrl, nodeCredential, _activeIdentit _logger.Error($"[ConnMgr] Node connect failed: {ex.Message}"); _diagnostics.Record("node", "Node connect failed", ex.Message); + await BlockNodeStartAsync( + $"Node connect failed: {ex.Message}", + cancellationToken, + expectedLifecycleGeneration, + nodeGeneration); + return false; } return !cancellationToken.IsCancellationRequested && @@ -1297,7 +1471,6 @@ private async Task OnNodeStatusChangedAsync(ConnectionStatus status) await _transitionSemaphore.WaitAsync(); try { - var prev = _stateMachine.Current.OverallState; switch (status) { case ConnectionStatus.Connected: @@ -1336,7 +1509,7 @@ private async Task OnNodeStatusChangedAsync(ConnectionStatus status) } TryClearBootstrapTokenAfterDurablePairing(); - EmitStateChanged(prev); + EmitStateChanged(); } finally { @@ -1371,7 +1544,6 @@ private async Task OnNodePairingStatusChangedAsync( if (!IsCurrentNodeAttempt(lifecycleGeneration, nodeGeneration)) return; - var prev = _stateMachine.Current.OverallState; switch (e.Status) { case PairingStatus.Paired: @@ -1404,7 +1576,7 @@ private async Task OnNodePairingStatusChangedAsync( } TryClearBootstrapTokenAfterDurablePairing(); - EmitStateChanged(prev); + EmitStateChanged(); } finally { @@ -1686,7 +1858,7 @@ private static string BuildDeviceAutoApprovalFailureDetail(IReadOnlyList // ─── Helpers ─── - private void EmitStateChanged(OverallConnectionState previousOverall) + private void EmitStateChanged() { var snapshot = _stateMachine.Current; // Always fire when any part of the snapshot changed — not just OverallState. diff --git a/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs b/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs index 28fd05879..b06cec57d 100644 --- a/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs +++ b/src/OpenClaw.Connection/GatewayConnectionSnapshot.cs @@ -22,6 +22,7 @@ public sealed record GatewayConnectionSnapshot public string? OperatorPairingRequestId { get; init; } // ─── Node ─── + public bool NodeConnectionIntended { get; init; } public RoleConnectionState NodeState { get; init; } public string? NodeError { get; init; } public OpenClaw.Shared.PairingStatus NodePairingStatus { get; init; } @@ -67,10 +68,26 @@ public static OverallConnectionState DeriveOverall( if (op == RoleConnectionState.Connecting) return OverallConnectionState.Connecting; + if (op != RoleConnectionState.Connected && nodeEnabled) + { + if (node == RoleConnectionState.PairingRequired) + return OverallConnectionState.PairingRequired; + + if (node == RoleConnectionState.Connecting) + return OverallConnectionState.Connecting; + + if (node == RoleConnectionState.Connected) + return OverallConnectionState.Connected; + + if (node is RoleConnectionState.Error or RoleConnectionState.PairingRejected or RoleConnectionState.RateLimited) + return OverallConnectionState.Error; + } + // From here, operator is Connected. if (op == RoleConnectionState.Connected && nodeEnabled && (node == RoleConnectionState.Error || + node == RoleConnectionState.Idle || node == RoleConnectionState.PairingRejected || node == RoleConnectionState.RateLimited)) return OverallConnectionState.Degraded; diff --git a/src/OpenClaw.SetupEngine.UI/Pages/AdvancedSetupPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/AdvancedSetupPage.xaml new file mode 100644 index 000000000..3622b58b2 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/Pages/AdvancedSetupPage.xaml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - diff --git a/src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml.cs deleted file mode 100644 index 2e152a7de..000000000 --- a/src/OpenClaw.SetupEngine.UI/Pages/PermissionsPage.xaml.cs +++ /dev/null @@ -1,229 +0,0 @@ -using Microsoft.UI; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media; -using Microsoft.UI.Xaml.Navigation; -using OpenClaw.SetupEngine.UI; -using Microsoft.Win32; -using Windows.Devices.Enumeration; -using Windows.Graphics.Capture; -using Windows.UI; - -namespace OpenClaw.SetupEngine.UI.Pages; - -public sealed partial class PermissionsPage : Page -{ - private SetupConfig? _config; - - private record PermDef(string Name, string Glyph, string SettingsUri, Func> Check); - - private static readonly PermDef[] Permissions = - [ - new("Notifications", "\uEA8F", "ms-settings:notifications", CheckNotificationsAsync), - new("Camera", "\uE722", "ms-settings:privacy-webcam", CheckCameraAsync), - new("Microphone", "\uE720", "ms-settings:privacy-microphone", CheckMicrophoneAsync), - new("Location (optional)", "\uE81D", "ms-settings:privacy-location", CheckLocationAsync), - new("Screen Capture", "\uE7F4", "", CheckScreenCaptureAsync), - ]; - - public PermissionsPage() - { - InitializeComponent(); - } - - protected override void OnNavigatedTo(NavigationEventArgs e) - { - _config = e.Parameter as SetupConfig ?? new SetupConfig(); - _ = RefreshPermissions(); - } - - private async Task RefreshPermissions() - { - PermRows.Children.Clear(); - var isDark = ActualTheme == ElementTheme.Dark; - var cardBg = new SolidColorBrush(isDark - ? Color.FromArgb(255, 0x2C, 0x2C, 0x2C) - : Color.FromArgb(255, 0xF5, 0xF5, 0xF5)); - - foreach (var perm in Permissions) - { - var (status, granted) = await perm.Check(); - PermRows.Children.Add(BuildRow(perm, status, granted, cardBg, isDark)); - } - } - - private static FrameworkElement BuildRow(PermDef perm, string status, bool granted, Brush cardBg, bool isDark) - { - var statusColor = granted - ? Color.FromArgb(255, 0x2B, 0xC3, 0x6F) // green - : Color.FromArgb(255, 0xF4, 0xA6, 0xB0); // pink - - // Icon badge - var iconBadge = new Border - { - Width = 40, Height = 40, - CornerRadius = new CornerRadius(20), - Background = isDark - ? new SolidColorBrush(Microsoft.UI.Colors.Transparent) - : new SolidColorBrush(Color.FromArgb(255, 0x33, 0x33, 0x33)), - Child = new TextBlock - { - Text = perm.Glyph, - FontFamily = IconFonts.SymbolThemeFontFamily, - FontSize = 20, - Foreground = new SolidColorBrush(isDark ? Microsoft.UI.Colors.White : Microsoft.UI.Colors.White), - HorizontalAlignment = HorizontalAlignment.Center, - VerticalAlignment = VerticalAlignment.Center, - }, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 16, 0), - }; - - // Title + status - var textStack = new StackPanel { Spacing = 2, VerticalAlignment = VerticalAlignment.Center }; - textStack.Children.Add(new TextBlock { Text = perm.Name, FontSize = 15, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }); - textStack.Children.Add(new TextBlock { Text = status, FontSize = 13, Foreground = new SolidColorBrush(statusColor) }); - - // Open Settings button (only if URI exists) - FrameworkElement actionCol; - if (!string.IsNullOrEmpty(perm.SettingsUri)) - { - var btn = new Button - { - Padding = new Thickness(8, 6, 8, 6), - Background = new SolidColorBrush(Microsoft.UI.Colors.Transparent), - BorderBrush = new SolidColorBrush(Microsoft.UI.Colors.Transparent), - }; - var btnContent = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; - btnContent.Children.Add(new TextBlock - { - Text = "\uE8A7", FontFamily = IconFonts.SymbolThemeFontFamily, - FontSize = 14, VerticalAlignment = VerticalAlignment.Center - }); - btnContent.Children.Add(new TextBlock { Text = "Open Settings", FontSize = 13, VerticalAlignment = VerticalAlignment.Center }); - btn.Content = btnContent; - var uri = perm.SettingsUri; - btn.Click += async (_, _) => - { - try { await Windows.System.Launcher.LaunchUriAsync(new Uri(uri)); } - // slopwatch-ignore: SW003 UI helper action is best-effort and failure should not break the owning UI flow. - catch { /* best effort */ } - }; - actionCol = btn; - } - else - { - actionCol = new Border { Width = 1 }; - } - - var grid = new Grid - { - ColumnDefinitions = - { - new ColumnDefinition { Width = GridLength.Auto }, - new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }, - new ColumnDefinition { Width = GridLength.Auto }, - }, - VerticalAlignment = VerticalAlignment.Center, - }; - Grid.SetColumn(iconBadge, 0); - Grid.SetColumn(textStack, 1); - Grid.SetColumn(actionCol, 2); - grid.Children.Add(iconBadge); - grid.Children.Add(textStack); - grid.Children.Add(actionCol); - - return new Border - { - Child = grid, - Background = cardBg, - CornerRadius = new CornerRadius(8), - Padding = new Thickness(20, 18, 20, 18), - }; - } - - private void Refresh_Click(object sender, RoutedEventArgs e) => _ = RefreshPermissions(); - - private void BackToWizard_Click(object sender, RoutedEventArgs e) - => SetupWindow.Active?.NavigateToWizard(); - - private void Next_Click(object sender, RoutedEventArgs e) - => SetupWindow.Active?.NavigateToComplete(true, TimeSpan.Zero, null); - - // ── Permission checks (passive, no OS consent dialogs) ── - - private static Task<(string, bool)> CheckNotificationsAsync() - { - try - { - using var key = Registry.CurrentUser.OpenSubKey( - @"Software\Microsoft\Windows\CurrentVersion\PushNotifications"); - if (key?.GetValue("ToastEnabled") is int val && val == 0) - return Task.FromResult(("Disabled", false)); - return Task.FromResult(("Enabled", true)); - } - catch - { - return Task.FromResult(("Unable to check", false)); - } - } - - private static async Task<(string, bool)> CheckCameraAsync() - { - try - { - var devices = await DeviceInformation.FindAllAsync(DeviceClass.VideoCapture); - if (devices.Count == 0) return ("No camera detected", false); - var access = DeviceAccessInformation.CreateFromDeviceClass(DeviceClass.VideoCapture); - return access.CurrentStatus == DeviceAccessStatus.Allowed - ? ("Allowed", true) - : ("Denied — open Settings to allow", false); - } - catch { return ("Unable to check", false); } - } - - private static async Task<(string, bool)> CheckMicrophoneAsync() - { - try - { - var devices = await DeviceInformation.FindAllAsync(DeviceClass.AudioCapture); - if (devices.Count == 0) return ("No microphone detected", false); - var access = DeviceAccessInformation.CreateFromDeviceClass(DeviceClass.AudioCapture); - return access.CurrentStatus == DeviceAccessStatus.Allowed - ? ("Allowed", true) - : ("Denied — open Settings to allow", false); - } - catch { return ("Unable to check", false); } - } - - private static Task<(string, bool)> CheckLocationAsync() - { - try - { - using var sysKey = Registry.LocalMachine.OpenSubKey( - @"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\location"); - if (sysKey?.GetValue("Value") is string sv && sv.Equals("Deny", StringComparison.OrdinalIgnoreCase)) - return Task.FromResult(("Location services disabled", false)); - - using var userKey = Registry.CurrentUser.OpenSubKey( - @"SOFTWARE\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\location"); - var uv = userKey?.GetValue("Value") as string; - if (uv != null && uv.Equals("Deny", StringComparison.OrdinalIgnoreCase)) - return Task.FromResult(("Disabled for this user", false)); - - return Task.FromResult(("Location services enabled", true)); - } - catch { return Task.FromResult(("Unable to check", false)); } - } - - private static Task<(string, bool)> CheckScreenCaptureAsync() - { - try - { - return Task.FromResult(GraphicsCaptureSession.IsSupported() - ? ("Available — uses picker per capture", true) - : ("Not supported on this device", false)); - } - catch { return Task.FromResult(("Unable to check", false)); } - } -} diff --git a/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml index cff2dd1fb..787e9a4d6 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/ProgressPage.xaml @@ -7,62 +7,116 @@ - - - - + + - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs index 89ea70cd4..f8e51b203 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/WelcomePage.xaml.cs @@ -2,19 +2,17 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Hosting; -using Microsoft.UI.Xaml.Media; using Microsoft.UI.Xaml.Navigation; using OpenClaw.SetupEngine; using OpenClaw.SetupEngine.UI; using OpenClaw.Shared; using System.Numerics; -using Windows.UI; namespace OpenClaw.SetupEngine.UI.Pages; public sealed partial class WelcomePage : Page { - private const string InstallButtonText = "Install new WSL Gateway"; + private const string InstallButtonText = "Install a local gateway (WSL)"; private const string CheckingButtonText = "Checking existing setup..."; private SetupConfig? _config; @@ -31,23 +29,15 @@ protected override void OnNavigatedTo(NavigationEventArgs e) private void OnLoaded(object sender, RoutedEventArgs e) { - var isDark = ActualTheme == ElementTheme.Dark; - InfoCard.Background = new SolidColorBrush(isDark - ? Color.FromArgb(255, 0x2C, 0x2C, 0x2C) - : Color.FromArgb(255, 0xF0, 0xF0, 0xF0)); - - InfoText.Text = "This local setup installs a small WSL Linux instance dedicated to OpenClaw. " - + "If you'd rather connect to an existing or remote gateway, choose Advanced setup."; - - StartLobsterBreatheAnimation(); + StartMascotBreatheAnimation(); } - private void StartLobsterBreatheAnimation() + private void StartMascotBreatheAnimation() { - var visual = ElementCompositionPreview.GetElementVisual(LobsterHero); + var visual = ElementCompositionPreview.GetElementVisual(MascotHero); var compositor = visual.Compositor; - var centerX = LobsterHero.ActualWidth > 0 ? LobsterHero.ActualWidth / 2 : LobsterHero.Width / 2; - var centerY = LobsterHero.ActualHeight > 0 ? LobsterHero.ActualHeight / 2 : LobsterHero.Height / 2; + var centerX = MascotHero.ActualWidth > 0 ? MascotHero.ActualWidth / 2 : MascotHero.Width / 2; + var centerY = MascotHero.ActualHeight > 0 ? MascotHero.ActualHeight / 2 : MascotHero.Height / 2; visual.CenterPoint = new Vector3((float)centerX, (float)centerY, 0f); var pulse = compositor.CreateVector3KeyFrameAnimation(); @@ -73,8 +63,8 @@ private async Task StartButtonClickAsync() ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OpenClawTray"); var setupWindow = SetupWindow.Active; - StartButton.IsEnabled = false; - StartButton.Content = CheckingButtonText; + InstallButton.IsEnabled = false; + InstallTitle.Text = CheckingButtonText; var navigating = false; try { @@ -108,14 +98,15 @@ private async Task StartButtonClickAsync() { if (!navigating && setupWindow is { IsClosed: false }) { - StartButton.Content = InstallButtonText; - StartButton.IsEnabled = true; + InstallTitle.Text = InstallButtonText; + InstallButton.IsEnabled = true; } } } private void AdvancedSetup_Click(object sender, RoutedEventArgs e) { - SetupWindow.Active?.RequestAdvancedSetup(); + // Show quick connect instructions before handing off to the companion app. + SetupWindow.Active?.NavigateToAdvancedSetup(); } } diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml index e349a95fb..93c64bb67 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml +++ b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml @@ -5,22 +5,32 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" NavigationCacheMode="Disabled"> - + - - - + + + - + + + - - + + + Style="{StaticResource CaptionTextBlockStyle}" + Foreground="{ThemeResource TextFillColorSecondaryBrush}" /> @@ -44,7 +52,7 @@ + - - + Style="{StaticResource AccentButtonStyle}" + Click="Primary_Click" IsEnabled="False" /> diff --git a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs index 15417467e..969b7f227 100644 --- a/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/Pages/WizardPage.xaml.cs @@ -16,8 +16,6 @@ public sealed partial class WizardPage : Page private const int MaxWizardSteps = 50; private const int MaxSameStepVisits = 3; - // Bound progress polling separately from interactive wizard steps. - private SetupConfig? _config; private OpenClawGatewayClient? _client; private string _sessionId = ""; @@ -35,13 +33,9 @@ public sealed partial class WizardPage : Page private int _totalProgressPolls; private readonly Dictionary _stepVisits = new(StringComparer.OrdinalIgnoreCase); private readonly List _options = []; - // Tails the WSL gateway log and surfaces openclaw plugin console.log output - // (OAuth URLs, install fallback messages, etc) inline on the active step. - // wizard.payload frames don't carry this content. + // wizard.payload frames do not include plugin console output, so tail the gateway log inline. private WizardConsoleTail? _consoleTail; - // Host access for the active gateway, captured on connect. Drives the - // "Open terminal" / "Restart gateway" recovery affordances shown when the - // wizard fails because a tool is missing from an app-managed WSL gateway. + // Captured on connect for "Open terminal" / "Restart gateway" recovery actions. private GatewayHostAccessPlan _hostAccessPlan = GatewayHostAccessPlan.None(); public WizardPage() @@ -54,15 +48,57 @@ public WizardPage() protected override void OnNavigatedTo(NavigationEventArgs e) { _config = e.Parameter as SetupConfig ?? new SetupConfig(); + if (SetupPreview.IsActive) + { + RenderWizardPreview(); + return; + } _ = StartWizardAsync(); } + private void RenderWizardPreview() + { + BusyRing.Visibility = Visibility.Collapsed; + BusyRing.IsActive = false; + ShowRecoveryActions(); + AppendTranscriptTurn("Welcome — let's connect your agent", null); + AppendTranscriptTurn("Choose your AI provider", "Anthropic — Claude"); + AppendTranscriptTurn("Paste your API key", "••••••"); + + if (SetupPreview.RequestedPage == "wizard-error") + { + TitleText.Text = "OpenClaw onboard hit a problem"; + ShowError("The gateway restarted before the current wizard step finished. Your setup is still installed; choose Start wizard again, or use More options to restart onboard or skip and exit."); + return; + } + + StatusText.Text = "A few quick questions to connect your agent"; + _stepType = "select"; + _stepId = "model"; + TitleText.Text = "Default AI model"; + SelectOptions.Visibility = Visibility.Visible; + foreach (var (val, lbl) in new[] { ("opus", "claude-opus-4.8"), ("sonnet", "claude-sonnet-4.6"), ("haiku", "claude-haiku-4.5") }) + { + SelectOptions.Children.Add(new RadioButton + { + Content = lbl, + Tag = val, + GroupName = "preview", + Padding = new Thickness(8, 6, 8, 6), + }); + } + ((RadioButton)SelectOptions.Children[0]).IsChecked = true; + PrimaryButton.Content = "Continue"; + PrimaryButton.IsEnabled = true; + SecondaryButton.Visibility = Visibility.Collapsed; + } + protected override void OnNavigatedFrom(NavigationEventArgs e) { _ = DisconnectAsync(); } - private async Task StartWizardAsync() + private async Task StartWizardAsync(bool clearTranscript = true) { var generation = AdvanceOperationGeneration(); try @@ -89,6 +125,8 @@ private async Task StartWizardAsync() if (generation != _operationGeneration) return; + if (clearTranscript) + TranscriptPanel.Children.Clear(); await ApplyPayloadAsync(payload); } catch (Exception ex) @@ -197,10 +235,8 @@ private async Task ApplyPayloadAsync(JsonElement payload) if (generation != _operationGeneration || _errorState) return; - if (_config!.SkipPermissions) - SetupWindow.Active?.NavigateToComplete(true, TimeSpan.Zero, _config.LogPath); - else - SetupWindow.Active?.NavigateToPermissions(); + // Permissions are collected before install, so the wizard completes straight to summary. + SetupWindow.Active?.NavigateToComplete(true, TimeSpan.Zero, _config!.LogPath); return; } @@ -300,7 +336,7 @@ private async Task ApplyPayloadAsync(JsonElement payload) BusyRing.Visibility = Visibility.Collapsed; BusyRing.IsActive = false; ShowRecoveryActions(); - StatusText.Text = "Answer the gateway setup question"; + StatusText.Text = "A few quick questions to connect your agent"; PrimaryButton.IsEnabled = !WizardSelection.RequiresAnswer(_stepType); SecondaryButton.IsEnabled = true; PrimaryButton.Content = _stepType == "confirm" ? "Yes" : "Continue"; @@ -492,12 +528,6 @@ private void Secondary_Click(object sender, RoutedEventArgs e) => private async Task SecondaryClickAsync() { - if (_errorState) - { - await SkipWizardAsync(); - return; - } - await SendCurrentAnswerAsync(skip: true); } @@ -547,6 +577,8 @@ private async Task SendCurrentAnswerAsync(bool skip) // render and the user's current click. Once they answer, those messages // are "consumed" — wipe so the next step starts with a clean slate. ClearConsoleBanner(); + var answeredQuestion = TitleText.Text; + var answeredLabel = CurrentAnswerLabel(skip); object parameters; if (skip) { @@ -567,7 +599,9 @@ private async Task SendCurrentAnswerAsync(bool skip) if (generation != _operationGeneration) return; + AppendTranscriptTurn(answeredQuestion, answeredLabel); await ApplyPayloadAsync(payload); + ScrollActiveIntoView(); } catch (Exception ex) { @@ -578,6 +612,107 @@ private async Task SendCurrentAnswerAsync(bool skip) } } + private string? CurrentAnswerLabel(bool skip) + { + if (skip) + return _stepType == "confirm" ? "No" : "Skipped"; + + return _stepType switch + { + "confirm" => "Yes", + "text" => _sensitive ? "••••••" : (string.IsNullOrEmpty(TextInput.Text) ? null : TextInput.Text), + "select" or "multiselect" => LabelForValues(GetSelectedOptionValues()), + _ => null, + }; + } + + private string? LabelForValues(string[] values) + { + if (values.Length == 0) + return null; + var labels = values.Select(v => _options.FirstOrDefault(o => o.Value == v)?.Label ?? v); + return string.Join(", ", labels); + } + + // Presentation-only transcript; protocol frames are unchanged. + private void AppendTranscriptTurn(string question, string? answer) + { + if (string.IsNullOrWhiteSpace(question)) + return; + + var grid = new Grid { Padding = new Thickness(2, 4, 2, 4) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var dot = new Border + { + Width = 18, + Height = 18, + CornerRadius = new CornerRadius(9), + Background = ResourceBrush("SystemFillColorSuccessBrush"), + Margin = new Thickness(0, 1, 10, 0), + VerticalAlignment = VerticalAlignment.Top, + Child = new FontIcon + { + Glyph = "\uE73E", + FontSize = 10, + Foreground = new SolidColorBrush(Microsoft.UI.Colors.White), + IsTextScaleFactorEnabled = false, + }, + }; + + var stack = new StackPanel { Spacing = 1, VerticalAlignment = VerticalAlignment.Center }; + stack.Children.Add(new TextBlock + { + Text = question, + FontSize = 13, + Foreground = ResourceBrush("TextFillColorTertiaryBrush"), + TextWrapping = TextWrapping.Wrap, + }); + if (!string.IsNullOrWhiteSpace(answer)) + { + stack.Children.Add(new TextBlock + { + Text = answer, + FontSize = 13, + Foreground = ResourceBrush("TextFillColorSecondaryBrush"), + TextWrapping = TextWrapping.Wrap, + }); + } + + Grid.SetColumn(dot, 0); + Grid.SetColumn(stack, 1); + grid.Children.Add(dot); + grid.Children.Add(stack); + TranscriptPanel.Children.Add(grid); + } + + private void ScrollActiveIntoView() + { + MainScroller.UpdateLayout(); + // Bring the active step card's TITLE into view (just below the last answered + // step) rather than jumping to the very bottom — scrolling to the bottom hid + // the step's introduction/question when it had many options (e.g. web search). + if (MainScroller.Content is FrameworkElement content) + { + try + { + var cardTop = StepCard.TransformToVisual(content) + .TransformPoint(new Windows.Foundation.Point(0, 0)).Y; + // Leave a little room above so the most recent answered step stays + // visible for continuity, but keep the active title at the top. + var target = Math.Max(0, cardTop - 44); + MainScroller.ChangeView(null, target, null); + return; + } + catch + { + // Fall back to the previous behaviour if the transform fails. + } + } + MainScroller.ChangeView(null, MainScroller.ScrollableHeight, null); + } + private bool TryBuildAnswerValue(out object value) { value = _stepType switch @@ -877,10 +1012,9 @@ private void ShowError(string message) ErrorText.Visibility = Visibility.Visible; PrimaryButton.Content = "Start wizard again"; PrimaryButton.IsEnabled = true; - SecondaryButton.Content = "Skip wizard"; - SecondaryButton.IsEnabled = true; - SecondaryButton.Visibility = Visibility.Visible; - HideRecoveryActions(); + SecondaryButton.IsEnabled = false; + SecondaryButton.Visibility = Visibility.Collapsed; + ShowRecoveryActions(); MaybeShowGatewayRecovery(); } @@ -892,9 +1026,8 @@ private async Task EnterWizardErrorAsync(string detail) // Invalidate in-flight wizard.next calls before tearing down the connection. AdvanceOperationGeneration(); _errorState = true; - // Cancel the server-side wizard session before disconnecting so that - // subsequent retries (Start wizard again / Skip wizard) don't hit a - // "wizard already running" error from a lingering gateway session. + // Cancel the server-side wizard session before disconnecting so retries + // don't hit a "wizard already running" error from a lingering session. await CancelCurrentSessionAsync(); ShowError(detail); } @@ -991,7 +1124,7 @@ private async Task RestartGatewayAsync() // onboarding) — we do NOT return to Welcome or re-install WSL. The // gateway restart wiped its wizard session, so this resumes at the // first config question rather than the exact step that failed. - await StartWizardAsync(); + await StartWizardAsync(clearTranscript: false); } catch (Exception ex) { @@ -1008,10 +1141,9 @@ private async Task SkipWizardAsync() HideRecoveryActions(); SetBusy("Skipping wizard..."); await CancelCurrentSessionAsync(); - if (_config!.SkipPermissions) - SetupWindow.Active?.NavigateToComplete(true, TimeSpan.Zero, _config.LogPath); - else - SetupWindow.Active?.NavigateToPermissions(); + // Permissions were already collected before install, so skipping OpenClaw + // onboard completes straight to the summary. + SetupWindow.Active?.NavigateToComplete(true, TimeSpan.Zero, _config!.LogPath); } private async Task CancelCurrentSessionAsync() @@ -1029,19 +1161,14 @@ private async Task CancelCurrentSessionAsync() private void ShowRecoveryActions() { - if (_errorState) - return; - - RecoveryActions.Visibility = Visibility.Visible; - StartOverButton.IsEnabled = true; - SkipWizardButton.IsEnabled = true; + MoreOptionsButton.Visibility = Visibility.Visible; + MoreOptionsButton.IsEnabled = true; } private void HideRecoveryActions() { - RecoveryActions.Visibility = Visibility.Collapsed; - StartOverButton.IsEnabled = false; - SkipWizardButton.IsEnabled = false; + MoreOptionsButton.Visibility = Visibility.Collapsed; + MoreOptionsButton.IsEnabled = false; } private async Task DisconnectAsync() diff --git a/src/OpenClaw.SetupEngine.UI/SetupPreview.cs b/src/OpenClaw.SetupEngine.UI/SetupPreview.cs new file mode 100644 index 000000000..bfd4d41d6 --- /dev/null +++ b/src/OpenClaw.SetupEngine.UI/SetupPreview.cs @@ -0,0 +1,38 @@ +using System; + +namespace OpenClaw.SetupEngine.UI; + +/// +/// Dev-only preview routing for the setup window. Lets OPENCLAW_SETUP_PREVIEW_PAGE +/// open a single page directly with sample content (no pipeline, no gateway) for visual +/// iteration during development. +/// +/// In Release builds this is fully inert: the environment variable is never read, +/// so the preview route can never bypass the setup run lock or the real install pipeline +/// in production. The gating lives here so call sites stay simple and there is exactly one +/// place that reads the variable. +/// +internal static class SetupPreview +{ + private const string EnvVar = "OPENCLAW_SETUP_PREVIEW_PAGE"; + + /// + /// The requested preview page (lower-cased, trimmed), or null when preview mode + /// is off. Always null in Release builds. + /// + public static string? RequestedPage + { +#if DEBUG + get + { + var page = Environment.GetEnvironmentVariable(EnvVar); + return string.IsNullOrWhiteSpace(page) ? null : page.Trim().ToLowerInvariant(); + } +#else + get => null; +#endif + } + + /// True when a preview page is active. Always false in Release builds. + public static bool IsActive => RequestedPage is not null; +} diff --git a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml index 3d9f4b147..9edaf01a6 100644 --- a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml +++ b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml @@ -14,7 +14,8 @@ - + diff --git a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs index 17872724a..c1c456dfd 100644 --- a/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs +++ b/src/OpenClaw.SetupEngine.UI/SetupWindow.xaml.cs @@ -3,6 +3,7 @@ using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Animation; using OpenClaw.SetupEngine.UI.Pages; using System.Runtime.InteropServices; @@ -15,6 +16,8 @@ public sealed partial class SetupWindow : Window private readonly TaskCompletionSource _initialContentReady = new(TaskCreationOptions.RunContinuationsAsynchronously); private bool _isClosed; + private bool _persistStartupPreferenceOnComplete = true; + private bool _showStartupPreferenceOnComplete = true; public static SetupWindow? Active { get; private set; } @@ -22,11 +25,15 @@ public sealed partial class SetupWindow : Window public event EventHandler? SetupCompleted; public bool IsClosed => _isClosed; public bool CanNavigateToWizard => !_isClosed && _setupLock is not null; + public bool CanNavigateToGatewayInstalledMilestone => + !_isClosed && + _setupLock is not null && + RootFrame.Content is not ProgressPage { IsPipelineRunning: true }; [DllImport("user32.dll")] private static extern uint GetDpiForWindow(IntPtr hwnd); - public SetupWindow(string? configPath = null) + public SetupWindow(string? configPath = null, bool startAtGatewayInstalledMilestone = false) { InitializeComponent(); Active = this; @@ -41,12 +48,13 @@ public SetupWindow(string? configPath = null) ExtendsContentIntoTitleBar = true; SetTitleBar(TitleBarDrag); - // Mica backdrop + // Mica backdrop — the signature Windows 11 material (native). SystemBackdrop = new MicaBackdrop(); // Load config: explicit --config arg, or bundled default-config.json (required) var args = Environment.GetCommandLineArgs(); - configPath ??= GetArg(args, "--config"); + var explicitConfigPath = configPath ?? GetArg(args, "--config"); + configPath = explicitConfigPath; if (configPath == null) { var defaultPath = Path.Combine(AppContext.BaseDirectory, "default-config.json"); @@ -68,9 +76,15 @@ public SetupWindow(string? configPath = null) } _config = SetupConfig.LoadFromFile(configPath); + _config.UsesBundledDefaultConfig = explicitConfigPath == null; _config = SetupConfig.FromEnvironment(_config); GatewayLkgVersion.ApplyToConfig(_config); _config.ApplyUiDefaults(rollbackOnFailure: !HasFlag(args, "--no-rollback-on-failure")); + if (startAtGatewayInstalledMilestone) + { + _persistStartupPreferenceOnComplete = false; + _showStartupPreferenceOnComplete = false; + } Closed += (_, _) => { @@ -82,34 +96,92 @@ public SetupWindow(string? configPath = null) Active = null; }; + var previewPage = SetupPreview.RequestedPage; + if (previewPage != null) + { + NavigatePreview(previewPage); + return; + } + if (!SetupRunLock.TryAcquire(SetupContext.ResolveDataDir(), out _setupLock, out var lockMessage)) { - RootFrame.Navigate(typeof(CompletePage), new CompletePageArgs(false, TimeSpan.Zero, null, lockMessage ?? "Another setup run is active.")); + NavigateTo(typeof(CompletePage), new CompletePageArgs(false, TimeSpan.Zero, null, lockMessage ?? "Another setup run is active.")); return; } - RootFrame.Navigate(typeof(WelcomePage), _config); + if (startAtGatewayInstalledMilestone) + NavigateToGatewayInstalledMilestone(); + else + NavigateTo(typeof(SecurityNoticePage), _config); } - public void NavigateToCapabilities() => RootFrame.Navigate(typeof(CapabilitiesPage), _config); - public void NavigateToProgress() => RootFrame.Navigate(typeof(ProgressPage), _config); - public bool TryNavigateToWizard() + public void NavigateToWelcome(bool back = false) => NavigateTo(typeof(WelcomePage), _config, back); + public void NavigateToAdvancedSetup() => NavigateTo(typeof(AdvancedSetupPage), _config); + public void NavigateToCapabilities() => NavigateTo(typeof(CapabilitiesPage), _config); + public void NavigateToProgress() => NavigateTo(typeof(ProgressPage), _config); + public void NavigateToGatewayInstalledMilestone() => + NavigateTo(typeof(ProgressPage), new ProgressPageArgs(_config, ShowMilestoneOnly: true)); + + public bool TryNavigateToGatewayInstalledMilestone() { - if (!CanNavigateToWizard) + if (!CanNavigateToGatewayInstalledMilestone) return false; - RootFrame.Navigate(typeof(WizardPage), _config); + _persistStartupPreferenceOnComplete = false; + _showStartupPreferenceOnComplete = false; + NavigateToGatewayInstalledMilestone(); return true; } - public void NavigateToWizard() + public bool TryNavigateToWizard(bool back = false) { - if (!TryNavigateToWizard()) - throw new InvalidOperationException("Setup window is not ready to navigate to the gateway wizard."); + if (!CanNavigateToWizard) + return false; + + NavigateTo(typeof(WizardPage), _config, back); + return true; } - public void NavigateToPermissions() => RootFrame.Navigate(typeof(PermissionsPage), _config); + public void NavigateToComplete(bool success, TimeSpan elapsed, string? logPath, string? errorMessage = null) - => RootFrame.Navigate(typeof(CompletePage), new CompletePageArgs(success, elapsed, logPath, errorMessage)); + => NavigateTo( + typeof(CompletePage), + new CompletePageArgs( + success, + elapsed, + logPath, + errorMessage, + DefaultAutoStart: true, + ShowStartupPreference: _showStartupPreferenceOnComplete, + ReviewSummary: SetupReviewSummaryBuilder.Build(_config))); + + // Directional page transition: forward steps slide in from the right, Back from the left. + private void NavigateTo(Type page, object? parameter, bool back = false) => + RootFrame.Navigate(page, parameter, new SlideNavigationTransitionInfo + { + Effect = back ? SlideNavigationTransitionEffect.FromLeft : SlideNavigationTransitionEffect.FromRight, + }); + + private void NavigatePreview(string page) => RootFrame.Navigate( + page switch + { + "welcome" => typeof(WelcomePage), + "advanced" => typeof(AdvancedSetupPage), + "capabilities" => typeof(CapabilitiesPage), + "progress" => typeof(ProgressPage), + "milestone" => typeof(ProgressPage), + "wizard" => typeof(WizardPage), + "wizard-error" => typeof(WizardPage), + "complete" => typeof(CompletePage), + "complete-error" => typeof(CompletePage), + _ => typeof(SecurityNoticePage), + }, + page switch + { + "complete" => new CompletePageArgs(true, TimeSpan.FromMinutes(3), null), + "complete-error" => new CompletePageArgs(false, TimeSpan.FromMinutes(3), null, "Setup could not finish. Review the details, then retry setup when you are ready."), + "milestone" => new ProgressPageArgs(_config, ShowMilestoneOnly: true), + _ => _config, + }); public void RequestAdvancedSetup() { @@ -122,6 +194,22 @@ public bool RequestSetupCompleted(bool enableAutoStart) if (handler == null) return false; + try + { + if (_persistStartupPreferenceOnComplete) + { + _config.Settings.AutoStart = enableAutoStart; + TraySettingsConfig.UpdateAutoStartInSettingsFile( + Path.Combine(SetupContext.ResolveDataDir(), "settings.json"), + enableAutoStart); + } + } + catch (Exception ex) + { + NavigateToComplete(false, TimeSpan.Zero, null, $"Setup completed, but saving your startup preference failed: {ex.Message}"); + return true; + } + handler.Invoke(this, new SetupCompletedEventArgs(enableAutoStart)); return true; } @@ -208,5 +296,12 @@ private static bool HasFlag(string[] args, string name) => args.Any(a => a.Equals(name, StringComparison.OrdinalIgnoreCase)); } -public sealed record CompletePageArgs(bool Success, TimeSpan Elapsed, string? LogPath, string? ErrorMessage = null); +public sealed record CompletePageArgs( + bool Success, + TimeSpan Elapsed, + string? LogPath, + string? ErrorMessage = null, + bool DefaultAutoStart = true, + bool ShowStartupPreference = true, + SetupReviewSummary? ReviewSummary = null); public sealed record SetupCompletedEventArgs(bool EnableAutoStart); diff --git a/src/OpenClaw.SetupEngine/SetupContext.cs b/src/OpenClaw.SetupEngine/SetupContext.cs index d6194c5b5..f4909375d 100644 --- a/src/OpenClaw.SetupEngine/SetupContext.cs +++ b/src/OpenClaw.SetupEngine/SetupContext.cs @@ -24,6 +24,8 @@ public sealed class SetupConfig public string? GatewayUrl { get; set; } public string? BootstrapToken { get; set; } public Dictionary? WizardAnswers { get; set; } + [JsonIgnore] + public bool UsesBundledDefaultConfig { get; set; } // Nested config sections — everything is configurable public WslConfig Wsl { get; set; } = new(); @@ -191,9 +193,7 @@ public void MergeIntoSettingsFile(string settingsPath) } catch (JsonException ex) { - var backupPath = settingsPath + $".corrupt-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}.bak"; - File.Copy(settingsPath, backupPath, overwrite: false); - throw new InvalidDataException($"settings.json is corrupt; backed up to {backupPath}", ex); + throw BackupCorruptSettingsFile(settingsPath, ex); } } @@ -201,10 +201,6 @@ public void MergeIntoSettingsFile(string settingsPath) { ["EnableNodeMode"] = EnableNodeMode, ["AutoStart"] = AutoStart, - }; - - var initialDefaults = new Dictionary - { ["NodeSystemRunEnabled"] = NodeSystemRunEnabled, ["NodeCanvasEnabled"] = NodeCanvasEnabled, ["NodeScreenEnabled"] = NodeScreenEnabled, @@ -225,13 +221,62 @@ public void MergeIntoSettingsFile(string settingsPath) foreach (var kvp in setupOwnedSettings) settings[kvp.Key] = kvp.Value; - foreach (var kvp in initialDefaults) - settings.TryAdd(kvp.Key, kvp.Value); + Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!); + var json = JsonSerializer.Serialize(settings, SetupConfig.JsonWriteOptions); + AtomicFile.WriteAllText(settingsPath, json); + } + + public static void UpdateAutoStartInSettingsFile(string settingsPath, bool autoStart) + { + Dictionary? existing = null; + + if (File.Exists(settingsPath)) + { + try + { + var content = File.ReadAllText(settingsPath); + existing = JsonSerializer.Deserialize>(content, SetupConfig.JsonOptions); + } + catch (JsonException ex) + { + throw BackupCorruptSettingsFile(settingsPath, ex); + } + } + + var settings = new Dictionary(); + if (existing != null) + { + foreach (var kvp in existing) + settings[kvp.Key] = kvp.Value; + } + + settings["AutoStart"] = autoStart; Directory.CreateDirectory(Path.GetDirectoryName(settingsPath)!); var json = JsonSerializer.Serialize(settings, SetupConfig.JsonWriteOptions); AtomicFile.WriteAllText(settingsPath, json); } + + public void ApplyCapabilities(CapabilitiesConfig capabilities) + { + // Device info has no independent runtime setting; it is always registered + // when node mode is enabled. + NodeSystemRunEnabled = capabilities.System; + NodeCanvasEnabled = capabilities.Canvas; + NodeScreenEnabled = capabilities.Screen; + NodeCameraEnabled = capabilities.Camera; + NodeLocationEnabled = capabilities.Location; + NodeBrowserProxyEnabled = capabilities.Browser; + NodeTtsEnabled = capabilities.Tts; + NodeSttEnabled = capabilities.Stt; + } + + private static InvalidDataException BackupCorruptSettingsFile(string settingsPath, JsonException ex) + { + var backupPath = settingsPath + $".corrupt-{DateTimeOffset.UtcNow:yyyyMMddHHmmssfffffff}-{Guid.NewGuid():N}.bak"; + File.Copy(settingsPath, backupPath, overwrite: false); + return new InvalidDataException($"settings.json is corrupt; backed up to {backupPath}", ex); + } } // ─── Pairing Configuration ─── diff --git a/src/OpenClaw.SetupEngine/SetupReviewSummary.cs b/src/OpenClaw.SetupEngine/SetupReviewSummary.cs new file mode 100644 index 000000000..9e67f8b2e --- /dev/null +++ b/src/OpenClaw.SetupEngine/SetupReviewSummary.cs @@ -0,0 +1,65 @@ +namespace OpenClaw.SetupEngine; + +public sealed record SetupReviewSummary( + string DistroTitle, + string DistroDescription, + string InstallerDescription, + string InstallerBadge, + string GatewayDescription, + string GatewayEndpoint, + string ExactCommands, + string CompletionGatewaySummary); + +public static class SetupReviewSummaryBuilder +{ + public static SetupReviewSummary Build(SetupConfig config) + { + var distroName = Display(config.DistroName, "OpenClawGateway"); + var baseDistro = Display(config.BaseDistro, "Ubuntu-24.04"); + var gatewayBind = Display(config.Gateway.Bind, "loopback"); + var gatewayPort = config.GatewayPort; + var installPath = Path.Combine(SetupContext.ResolveLocalDataDir(), "wsl", distroName); + var gatewayDataPath = Path.Combine(SetupContext.ResolveDataDir(), "gateways.json"); + var installUrl = config.Gateway.InstallUrl ?? GatewayLkgVersion.DefaultInstallUrl; + var installerHost = TryGetHttpsHost(installUrl); + var installerDescription = installerHost is null + ? "Installer URL is not HTTPS; setup will stop before downloading anything." + : $"Fetched over HTTPS from {installerHost}; runs as a non-root {Display(config.Wsl.User, "openclaw")} user inside the instance."; + var installerBadge = installerHost is null ? "Invalid URL" : "HTTPS"; + var isLanBind = gatewayBind.Equals("lan", StringComparison.OrdinalIgnoreCase); + var gatewayDescription = isLanBind + ? "LAN bind enabled — reachable from this PC and your local network according to Windows firewall/routing." + : "Loopback only — not reachable from your network or the internet."; + var gatewayEndpoint = isLanBind ? $"LAN:{gatewayPort}" : $"127.0.0.1:{gatewayPort}"; + var wslCommand = "wsl " + string.Join(' ', WslInstallSupport.BuildDirectInstallArgs(baseDistro, distroName, installPath)); + var installCommand = string.IsNullOrWhiteSpace(config.Gateway.Version) + ? "curl -fsSL --proto '=https' --tlsv1.2 | bash" + : $"curl -fsSL --proto '=https' --tlsv1.2 | bash -s -- --version {config.Gateway.Version.Trim()}"; + + return new SetupReviewSummary( + DistroTitle: $"Install an isolated {baseDistro} instance", + DistroDescription: $"WSL distro \"{distroName}\" at {installPath}. Separate from any Linux distributions you already have.", + InstallerDescription: installerDescription, + InstallerBadge: installerBadge, + GatewayDescription: gatewayDescription, + GatewayEndpoint: gatewayEndpoint, + ExactCommands: string.Join( + Environment.NewLine, + wslCommand, + installCommand, + $"openclaw config set gateway.bind {gatewayBind} · port {gatewayPort}", + "openclaw gateway install --force (systemd --user service)", + $"writes -> {installPath}", + $"writes -> {gatewayDataPath} + identity"), + CompletionGatewaySummary: $"{distroName} · {gatewayEndpoint}"); + } + + private static string Display(string? value, string fallback) + => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + + private static string? TryGetHttpsHost(string installUrl) + => Uri.TryCreate(installUrl, UriKind.Absolute, out var uri) + && uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + ? uri.Host + : null; +} diff --git a/src/OpenClaw.SetupEngine/SetupSteps.cs b/src/OpenClaw.SetupEngine/SetupSteps.cs index b2e832f82..bdc39afe4 100644 --- a/src/OpenClaw.SetupEngine/SetupSteps.cs +++ b/src/OpenClaw.SetupEngine/SetupSteps.cs @@ -2672,9 +2672,10 @@ private static async Task DrainPendingApprovalsAsync(SetupContext ct return StepResult.Ok("Pending approvals drained"); } - private static void WriteSettingsJson(SetupContext ctx) + internal static void WriteSettingsJson(SetupContext ctx) { var settingsPath = Path.Combine(ctx.DataDir, "settings.json"); + ctx.Config.Settings.ApplyCapabilities(ctx.Config.Capabilities); ctx.Config.Settings.MergeIntoSettingsFile(settingsPath); ctx.Logger.Info($"Wrote settings.json: EnableNodeMode={ctx.Config.Settings.EnableNodeMode}"); } diff --git a/src/OpenClaw.SetupPreview/App.xaml b/src/OpenClaw.SetupPreview/App.xaml deleted file mode 100644 index b9ce62801..000000000 --- a/src/OpenClaw.SetupPreview/App.xaml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - diff --git a/src/OpenClaw.SetupPreview/App.xaml.cs b/src/OpenClaw.SetupPreview/App.xaml.cs deleted file mode 100644 index 0ac1398d9..000000000 --- a/src/OpenClaw.SetupPreview/App.xaml.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.UI.Xaml; - -namespace OpenClaw.SetupPreview; - -/// -/// Minimal WinUI 3 Application bootstrap for the standalone V2 setup preview. -/// Constructs the single PreviewWindow and activates it. All capture-mode -/// behaviour (env-var gated headless rendering + PNG export + exit) lives -/// inside the window itself so this class stays trivially small. -/// -public partial class App : Application -{ - private PreviewWindow? _window; - - public App() - { - InitializeComponent(); - } - - protected override void OnLaunched(LaunchActivatedEventArgs args) - { - _window = new PreviewWindow(); - _window.Activate(); - } -} diff --git a/src/OpenClaw.SetupPreview/OpenClaw.SetupPreview.csproj b/src/OpenClaw.SetupPreview/OpenClaw.SetupPreview.csproj deleted file mode 100644 index 702f758fc..000000000 --- a/src/OpenClaw.SetupPreview/OpenClaw.SetupPreview.csproj +++ /dev/null @@ -1,58 +0,0 @@ - - - - WinExe - net10.0-windows10.0.22621.0 - true - enable - enable - OpenClaw.SetupPreview - OpenClaw.SetupPreview - x64;ARM64 - win-x64;win-arm64 - None - true - app.manifest - en-US - - $(DefineConstants);DISABLE_XAML_GENERATED_MAIN - - - - win-x64 - - - win-arm64 - - - - - - - - - - - - - - - - - - - - - - true - $(OutputPath)runtimes\win-arm64\native\WebView2Loader.dll - $(OutputPath)runtimes\win-x64\native\WebView2Loader.dll - - - - - diff --git a/src/OpenClaw.SetupPreview/PreviewWindow.cs b/src/OpenClaw.SetupPreview/PreviewWindow.cs deleted file mode 100644 index a535bf4ce..000000000 --- a/src/OpenClaw.SetupPreview/PreviewWindow.cs +++ /dev/null @@ -1,598 +0,0 @@ -using System.Globalization; -using System.Runtime.InteropServices; -using System.Runtime.InteropServices.WindowsRuntime; -using System.Threading.Tasks; -using Microsoft.UI; -using Microsoft.UI.Dispatching; -using Microsoft.UI.Windowing; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media; -using Microsoft.UI.Xaml.Media.Imaging; -using OpenClawTray.FunctionalUI; -using OpenClawTray.FunctionalUI.Hosting; -using OpenClawTray.Onboarding.V2; -using Windows.Graphics.Imaging; -using Windows.Storage.Streams; -using WinUIEx; - -namespace OpenClaw.SetupPreview; - -/// -/// Standalone preview window for the V2 onboarding redesign. -/// -/// Two modes of operation, selected by env vars: -/// -/// * Interactive (default): a normal window, intended for live design -/// iteration. Future work in the fake-services todo wires up the F1 -/// debug overlay (start page, locale, scenarios, replay). -/// -/// * Headless capture: when OPENCLAW_PREVIEW_CAPTURE=1, the window -/// appears at the requested size, mounts the V2 tree against the -/// requested page (OPENCLAW_PREVIEW_PAGE), waits for first composition -/// plus a quiescent frame, captures the root grid via -/// RenderTargetBitmap, writes the PNG to OPENCLAW_PREVIEW_CAPTURE_PATH, -/// and exits with code 0. On failure the exit code is 1 and a JSON -/// error file is written next to the requested PNG path. This is the -/// same RenderTargetBitmap mechanism the existing OnboardingWindow uses -/// for OPENCLAW_VISUAL_TEST=1, factored to fit a one-shot exe. -/// -/// The window is intentionally fixed-size so that the captured PNG always -/// has the same pixel dimensions for a given DPI — the visual-diff tool -/// relies on this stability. -/// -internal sealed class PreviewWindow : WindowEx -{ - /// - /// Logical preview window size in DIPs. Picked to closely match the - /// designer mocks (which are exported at 2010×2472; aspect 0.813). - /// 720 × 885 → aspect 0.813, identical to the design canvas, so the - /// rendered PNG can be diffed pixel-for-pixel against the references. - /// - private const int PreviewWidthDip = 720; - private const int PreviewHeightDip = 885; - - /// Height in DIPs of the custom XAML title bar (lobster + "OpenClaw Setup"). - private const int TitleBarHeight = 40; - - private readonly Grid _rootGrid; - private readonly FunctionalHostControl _host; - private readonly OnboardingV2State _state; - private readonly DispatcherQueue _dispatcherQueue; - - // Capture-mode configuration. - private readonly bool _captureMode; - private readonly string? _capturePath; - private bool _captureCompleted; - - // Theme-aware chrome elements (mutated by ApplyTheme when the user / system theme changes). - private Grid? _titleBar; - private TextBlock? _titleText; - - // System-theme tracking. Stored as fields (not locals) so the - // ColorValuesChanged subscription can be unhooked on window close — - // otherwise the COM event holds a strong reference to the lambda - // (and via it the window), preventing GC. - private Windows.UI.ViewManagement.UISettings? _themeUiSettings; - private Windows.Foundation.TypedEventHandler? _themeColorValuesChangedHandler; - - public PreviewWindow() - { - _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); - _state = new OnboardingV2State(); - - // Non-functional preview defaults: show the Node-Mode-Active warning - // on the All Set page (the design's default state). Env vars below - // can override this for capture scenarios that test the no-node variant. - _state.NodeModeActive = true; - - ApplyEnvOverrides(_state); - _captureMode = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_CAPTURE") == "1"; - _capturePath = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_CAPTURE_PATH"); - - // In headless capture mode, suppress all V2 entrance/idle animations - // so RenderTargetBitmap never snapshots an in-flight transform. - OpenClawTray.Onboarding.V2.V2Animations.DisableForCapture = _captureMode; - - Title = "OpenClaw Setup"; - ExtendsContentIntoTitleBar = true; - - // Use a flat dark background that matches the designer mocks - // (#202020) instead of MicaBackdrop. RenderTargetBitmap does not - // see Mica composition (it lives below the XAML layer), so the - // captures would otherwise show transparent/black behind the UI. - // A solid color guarantees byte-identical captures across runs. - SystemBackdrop = null; - - this.SetWindowSize(PreviewWidthDip, PreviewHeightDip); - this.CenterOnScreen(); - if (AppWindow.Presenter is OverlappedPresenter presenter) - { - presenter.IsResizable = false; - presenter.IsMaximizable = false; - } - - // Force Windows 11 rounded corners on the window. Setting - // SystemBackdrop = null above unhooks the default Mica path that - // normally rounds the frame, leaving square corners. DWM's - // WINDOW_CORNER_PREFERENCE attribute (Windows 11 build 22000+) - // restores the rounded look without bringing back the Mica fill. - TryApplyRoundedCorners(); - - // Make the system min/max/close buttons follow the current theme below; - // the actual button colours are applied in ApplyTheme(). - - _host = new FunctionalHostControl(); - _host.Mount(ctx => - { - var (s, _) = ctx.UseState(_state); - return Factories.Component(s); - }); - - _rootGrid = new Grid - { - Background = V2Theme.WindowBackground(_state.EffectiveTheme) - }; - _rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(TitleBarHeight) }); - _rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); - - // Custom title bar: small lobster icon + "OpenClaw Setup" - // text. Reserve the right-hand inset for the system caption - // buttons. AppWindow.TitleBar.RightInset is in physical pixels; - // convert to DIPs using XamlRoot.RasterizationScale (set after - // the host has loaded). Fall back to a sensible default at 100% - // DPI (~138 DIP) until the first SizeChanged. - _titleBar = new Grid { Padding = new Thickness(14, 0, 138, 0) }; - Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(_titleBar, "OpenClaw Setup title bar"); - - var titleBar = _titleBar; - void UpdateTitleBarPadding() - { - try - { - var rightInsetPx = AppWindow?.TitleBar?.RightInset ?? 0; - var scale = _host?.XamlRoot?.RasterizationScale ?? 1.0; - if (scale <= 0) scale = 1.0; - var rightInsetDip = rightInsetPx > 0 ? rightInsetPx / scale : 138; - titleBar.Padding = new Thickness(14, 0, rightInsetDip, 0); - } - // slopwatch-ignore: SW003 Audited non-critical fallback is intentional and the caller preserves safe behavior without this work. - catch - { - // Non-fatal: leave the fallback padding. - } - } - AppWindow.Changed += (_, _) => UpdateTitleBarPadding(); - var lobster = new Image - { - Source = new BitmapImage(new Uri("ms-appx:///Assets/Setup/Lobster.png")), - Width = 14, - Height = 14, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 8, 0), - Stretch = Stretch.Uniform - }; - Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(lobster, "OpenClaw"); - _titleText = new TextBlock - { - Text = Title, - FontSize = 12, - VerticalAlignment = VerticalAlignment.Center, - }; - Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(_titleText, "OpenClaw Setup"); - var titleStack = new StackPanel { Orientation = Orientation.Horizontal }; - titleStack.Children.Add(lobster); - titleStack.Children.Add(_titleText); - _titleBar.Children.Add(titleStack); - Grid.SetRow(_titleBar, 0); - _rootGrid.Children.Add(_titleBar); - SetTitleBar(_titleBar); - - Grid.SetRow(_host, 1); - _rootGrid.Children.Add(_host); - Content = _rootGrid; - - _host.Loaded += (_, _) => UpdateTitleBarPadding(); - - // Wire theme resolution and re-application. The state's ThemeMode - // (System / Light / Dark) is the user's preference; EffectiveTheme - // is what the V2 pages actually render against. ApplyResolvedTheme - // reads the preference, picks Light or Dark (System => follow the - // host Application.RequestedTheme), and pushes the result back - // onto the state + the chrome. - ApplyResolvedTheme(); - if (Application.Current is { } app) - { - // No app-level RequestedTheme change event is reliably surfaced - // in unpackaged WinUI 3 apps, but UISettings raises ColorValuesChanged - // when Windows app-mode flips. Forward it. - // - // Both the UISettings instance and the handler delegate are - // stored as fields so we can unhook in Closed — without this, - // the COM event keeps a strong reference to the lambda (and - // via it, this window), preventing GC of the window when it - // closes. - try - { - _themeUiSettings = new Windows.UI.ViewManagement.UISettings(); - _themeColorValuesChangedHandler = (_, _) => - _dispatcherQueue.TryEnqueue(() => ApplyResolvedTheme()); - _themeUiSettings.ColorValuesChanged += _themeColorValuesChangedHandler; - } - // slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved. - catch { /* non-fatal */ } - } - - Closed += (_, _) => - { - if (_themeUiSettings is not null && _themeColorValuesChangedHandler is not null) - { - try { _themeUiSettings.ColorValuesChanged -= _themeColorValuesChangedHandler; } - // slopwatch-ignore: SW003 Optional persisted state fallback is intentional; caller continues with defaults or prior state. - catch { /* ignore */ } - } - _themeColorValuesChangedHandler = null; - _themeUiSettings = null; - }; - - // F2 cycles theme mode (System -> Light -> Dark -> System) for live design feedback. - // Only honoured in interactive mode; capture mode never sees keyboard input. - if (!_captureMode) - { - _host.KeyDown += (_, e) => - { - if (e.Key == Windows.System.VirtualKey.F2) - { - _state.ThemeMode = (V2ThemeMode)(((int)_state.ThemeMode + 1) % 3); - ApplyResolvedTheme(); - e.Handled = true; - } - }; - } - - if (_captureMode) - { - _host.Loaded += async (_, _) => - { - await CaptureAndExitAsync(); - }; - } - else - { - // Interactive preview: drive a fake stage progression so designers - // can walk through the LocalSetupProgress checklist without a real - // gateway install. Skipped if a frozen / failed stage env override - // is set (those are deterministic capture scenarios). - var hasFrozen = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_PROGRESS_FROZEN_STAGE")); - var hasFail = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_FAIL_STAGE")); - if (!hasFrozen && !hasFail) - { - _host.Loaded += (_, _) => StartFakeStageProgression(); - } - } - } - - /// - /// Resolves to a concrete - /// and pushes it onto state + chrome. - /// Called on startup, on F2 cycle, and on Windows app-mode change. - /// - private void ApplyResolvedTheme() - { - var resolved = ResolveEffectiveTheme(_state.ThemeMode); - _state.EffectiveTheme = resolved; - - // Window background — V2 pages re-render through StateChanged, but the - // root Grid background is owned here so update it directly too. - _rootGrid.Background = V2Theme.WindowBackground(resolved); - - // Push the same theme into the visual tree so WinUI's built-in controls - // (ToggleSwitch thumb, ProgressRing, focus visuals) pick up matching defaults. - _rootGrid.RequestedTheme = resolved; - - if (_titleText is not null) - { - _titleText.Foreground = V2Theme.TextPrimary(resolved); - } - - if (AppWindow?.TitleBar is { } systemTitleBar) - { - // The system caption buttons (min/max/close) live above our XAML - // and need their colours set explicitly. Match the window bg so - // they blend into the chrome rather than showing a dark bar above - // a light window (or vice versa). - var bg = ((SolidColorBrush)V2Theme.WindowBackground(resolved)).Color; - var hover = ((SolidColorBrush)V2Theme.CardBackground(resolved)).Color; - var pressed = ((SolidColorBrush)V2Theme.CardBackgroundPressed(resolved)).Color; - var fg = ((SolidColorBrush)V2Theme.TextPrimary(resolved)).Color; - var inactiveFg = ((SolidColorBrush)V2Theme.TextSubtle(resolved)).Color; - systemTitleBar.ButtonBackgroundColor = bg; - systemTitleBar.ButtonInactiveBackgroundColor = bg; - systemTitleBar.ButtonForegroundColor = fg; - systemTitleBar.ButtonInactiveForegroundColor = inactiveFg; - systemTitleBar.ButtonHoverBackgroundColor = hover; - systemTitleBar.ButtonHoverForegroundColor = fg; - systemTitleBar.ButtonPressedBackgroundColor = pressed; - systemTitleBar.ButtonPressedForegroundColor = fg; - } - } - - /// - /// Resolves a user theme preference to a concrete . - /// uses - /// (UISettings-based) since - /// returns Light on unpackaged WinUI 3 apps regardless of system setting. - /// - private static ElementTheme ResolveEffectiveTheme(V2ThemeMode mode) => V2SystemTheme.Resolve(mode); - - /// - /// Apply Windows 11 rounded-corner preference via DWM. No-op (and silent) - /// on Windows 10 — the attribute simply isn't recognised. - /// - private void TryApplyRoundedCorners() - { - try - { - var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); - int pref = DWMWCP_ROUND; - _ = DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ref pref, sizeof(int)); - } - // slopwatch-ignore: SW003 UI helper action is best-effort and failure should not break the owning UI flow. - catch - { - // Non-fatal: square corners are an acceptable fallback. - } - } - - private const int DWMWA_WINDOW_CORNER_PREFERENCE = 33; - private const int DWMWCP_ROUND = 2; - - [DllImport("dwmapi.dll", PreserveSig = true)] - private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int pvAttribute, int cbAttribute); - - private DispatcherQueueTimer? _fakeStageTimer; - - /// - /// Walk the LocalSetupProgress checklist by promoting one row at a time: - /// the current spinner row turns into a checkmark, then the next idle - /// row spins. Loops at the end so a designer can keep watching without - /// restarting the app. ~900 ms per transition feels close to a real WSL - /// install but fast enough for design feedback. - /// - private void StartFakeStageProgression() - { - if (_fakeStageTimer is not null) return; - - // Seed: first stage spinning, the rest idle. - var stages = Enum.GetValues(); - var seed = new Dictionary(); - for (int i = 0; i < stages.Length; i++) - { - seed[stages[i]] = i == 0 - ? OnboardingV2State.LocalSetupRowState.Running - : OnboardingV2State.LocalSetupRowState.Idle; - } - _state.LocalSetupRows = seed; - - _fakeStageTimer = _dispatcherQueue.CreateTimer(); - _fakeStageTimer.Interval = TimeSpan.FromMilliseconds(900); - _fakeStageTimer.IsRepeating = true; - _fakeStageTimer.Tick += (_, _) => AdvanceFakeStage(); - _fakeStageTimer.Start(); - } - - private void AdvanceFakeStage() - { - var stages = Enum.GetValues(); - var rows = new Dictionary(_state.LocalSetupRows); - - // Find the currently-running row (if any) and promote it to Done; then - // start the next idle row spinning. If there's no idle row left, loop - // by resetting back to "stage 0 spinning, rest idle" after a brief pause. - int runningIndex = -1; - for (int i = 0; i < stages.Length; i++) - { - if (rows[stages[i]] == OnboardingV2State.LocalSetupRowState.Running) - { - runningIndex = i; - break; - } - } - - if (runningIndex == -1) - { - // Loop back to the start so the demo keeps going. - for (int i = 0; i < stages.Length; i++) - { - rows[stages[i]] = i == 0 - ? OnboardingV2State.LocalSetupRowState.Running - : OnboardingV2State.LocalSetupRowState.Idle; - } - _state.LocalSetupRows = rows; - return; - } - - rows[stages[runningIndex]] = OnboardingV2State.LocalSetupRowState.Done; - var nextIndex = runningIndex + 1; - if (nextIndex < stages.Length) - { - rows[stages[nextIndex]] = OnboardingV2State.LocalSetupRowState.Running; - } - _state.LocalSetupRows = rows; - } - - private static void ApplyEnvOverrides(OnboardingV2State state) - { - var page = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_PAGE"); - if (!string.IsNullOrWhiteSpace(page) && - Enum.TryParse(page, ignoreCase: true, out var route)) - { - state.CurrentRoute = route; - } - - var theme = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_THEME"); - if (!string.IsNullOrWhiteSpace(theme) && - Enum.TryParse(theme, ignoreCase: true, out var mode)) - { - state.ThemeMode = mode; - } - - var nodeMode = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_NODE_MODE"); - if (!string.IsNullOrWhiteSpace(nodeMode)) - { - state.NodeModeActive = - nodeMode.Equals("1", StringComparison.OrdinalIgnoreCase) || - nodeMode.Equals("true", StringComparison.OrdinalIgnoreCase); - } - - var existingGateway = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_EXISTING_GATEWAY_KIND"); - if (!string.IsNullOrWhiteSpace(existingGateway) && - Enum.TryParse(existingGateway, ignoreCase: true, out var gatewayKind)) - { - state.ExistingGateway = gatewayKind; - } - - // OPENCLAW_PREVIEW_PROGRESS_FROZEN_STAGE freezes the LocalSetupProgress - // page on a specific stage: every stage strictly before this one is - // marked Done, the named stage is Running (spinner), and every stage - // strictly after is Idle. - // - // OPENCLAW_PREVIEW_FAIL_STAGE additionally marks the named stage as - // Failed (overrides the Running marking) and populates - // LocalSetupErrorMessage so the inline error card renders. - var frozen = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_PROGRESS_FROZEN_STAGE"); - var failStage = Environment.GetEnvironmentVariable("OPENCLAW_PREVIEW_FAIL_STAGE"); - - if (!string.IsNullOrWhiteSpace(frozen) && - Enum.TryParse(frozen, ignoreCase: true, out var frozenStage)) - { - var rows = new Dictionary(); - foreach (var stage in Enum.GetValues()) - { - if (stage < frozenStage) - { - rows[stage] = OnboardingV2State.LocalSetupRowState.Done; - } - else if (stage == frozenStage) - { - rows[stage] = OnboardingV2State.LocalSetupRowState.Running; - } - else - { - rows[stage] = OnboardingV2State.LocalSetupRowState.Idle; - } - } - state.LocalSetupRows = rows; - } - - if (!string.IsNullOrWhiteSpace(failStage) && - Enum.TryParse(failStage, ignoreCase: true, out var fStage)) - { - var rows = new Dictionary(state.LocalSetupRows); - // Mark every stage strictly before the failed one Done (in case - // the frozen stage env var was unset or set to the same stage). - foreach (var stage in Enum.GetValues()) - { - if (stage < fStage) rows[stage] = OnboardingV2State.LocalSetupRowState.Done; - else if (stage == fStage) rows[stage] = OnboardingV2State.LocalSetupRowState.Failed; - else rows[stage] = OnboardingV2State.LocalSetupRowState.Idle; - } - state.LocalSetupRows = rows; - state.LocalSetupErrorMessage = - "The OpenClaw gateway service started, but did not report ready status. Follow aka.ms/wsllogs for WSL diagnostic collection instructions."; - } - } - - private async Task CaptureAndExitAsync() - { - if (_captureCompleted) return; - _captureCompleted = true; - - try - { - // Two layout passes + a short delay so any first-render UseEffect - // mutations have time to land before we snapshot. - await Task.Yield(); - await Task.Delay(250); - - // Clear keyboard focus so the system focus visual (cyan ring) - // doesn't leak into deterministic captures. Re-enabling - // UseSystemFocusVisuals on V2 buttons (a11y improvement) means - // the first focusable in tab order would otherwise carry an - // initial focus ring. Park focus on a hidden, zero-size sentinel - // and let it settle for one more frame. - var sentinel = new ContentControl - { - IsTabStop = true, - Width = 0, - Height = 0, - Opacity = 0, - IsHitTestVisible = false, - }; - _rootGrid.Children.Add(sentinel); - sentinel.Focus(FocusState.Programmatic); - await Task.Delay(50); - - var rtb = new RenderTargetBitmap(); - await rtb.RenderAsync(_rootGrid); - var pixels = await rtb.GetPixelsAsync(); - var pixelBytes = pixels.ToArray(); - - _rootGrid.Children.Remove(sentinel); - - using var stream = new InMemoryRandomAccessStream(); - var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, stream); - encoder.SetPixelData( - BitmapPixelFormat.Bgra8, - BitmapAlphaMode.Premultiplied, - (uint)rtb.PixelWidth, - (uint)rtb.PixelHeight, - 96, 96, - pixelBytes); - await encoder.FlushAsync(); - - stream.Seek(0); - var reader = new DataReader(stream); - await reader.LoadAsync((uint)stream.Size); - var bytes = new byte[stream.Size]; - reader.ReadBytes(bytes); - - var path = !string.IsNullOrWhiteSpace(_capturePath) - ? _capturePath - : Path.Combine(Path.GetTempPath(), "openclaw-preview-capture.png"); - Directory.CreateDirectory(Path.GetDirectoryName(path)!); - await File.WriteAllBytesAsync(path, bytes); - - Console.Out.WriteLine($"[preview] captured {rtb.PixelWidth}x{rtb.PixelHeight} -> {path}"); - ExitWithCode(0); - } - catch (Exception ex) - { - try - { - var errPath = (_capturePath ?? Path.Combine(Path.GetTempPath(), "openclaw-preview-capture.png")) + ".error.json"; - Directory.CreateDirectory(Path.GetDirectoryName(errPath)!); - var json = $"{{\"error\":{System.Text.Json.JsonSerializer.Serialize(ex.Message)},\"type\":{System.Text.Json.JsonSerializer.Serialize(ex.GetType().FullName ?? "")}}}"; - File.WriteAllText(errPath, json); - } - // slopwatch-ignore: SW003 Diagnostic logging fallback is best-effort and logging failure must not cascade. - catch { /* best effort */ } - Console.Error.WriteLine($"[preview] capture failed: {ex}"); - ExitWithCode(1); - } - } - - private void ExitWithCode(int code) - { - // WinUI doesn't expose a clean Application.Exit(int) — the Win32 - // ExitProcess avoids racing with the dispatcher loop teardown that - // a managed Application.Exit() can leave hanging. - // slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved. - try { Close(); } catch { /* ignore */ } - ExitProcess((uint)code); - } - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern void ExitProcess(uint uExitCode); - -} diff --git a/src/OpenClaw.SetupPreview/Program.cs b/src/OpenClaw.SetupPreview/Program.cs deleted file mode 100644 index 29a74a282..000000000 --- a/src/OpenClaw.SetupPreview/Program.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Threading; -using Microsoft.UI.Dispatching; -using Microsoft.UI.Xaml; - -namespace OpenClaw.SetupPreview; - -/// -/// Explicit entry point for the unpackaged WinUI 3 preview exe. -/// -/// Mirrors the pattern auto-generated by EnableMsixTooling=true in MSIX -/// builds, but without requiring a Package.appxmanifest. Initializes COM -/// wrappers, installs a DispatcherQueue-backed SynchronizationContext on -/// the UI thread, then starts the WinUI Application loop with our App. -/// -internal static class Program -{ - [STAThread] - private static int Main(string[] args) - { - WinRT.ComWrappersSupport.InitializeComWrappers(); - Application.Start(p => - { - var dispatcher = DispatcherQueue.GetForCurrentThread(); - var context = new DispatcherQueueSynchronizationContext(dispatcher); - SynchronizationContext.SetSynchronizationContext(context); - _ = new App(); - }); - return 0; - } -} diff --git a/src/OpenClaw.SetupPreview/Properties/launchSettings.json b/src/OpenClaw.SetupPreview/Properties/launchSettings.json deleted file mode 100644 index de193eeb0..000000000 --- a/src/OpenClaw.SetupPreview/Properties/launchSettings.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "profiles": { - "OpenClaw.SetupPreview": { - "commandName": "Project", - "nativeDebugging": false - }, - "Capture-Welcome": { - "commandName": "Project", - "environmentVariables": { - "OPENCLAW_PREVIEW_CAPTURE": "1", - "OPENCLAW_PREVIEW_PAGE": "Welcome", - "OPENCLAW_PREVIEW_CAPTURE_PATH": "$(MSBuildProjectDirectory)\\..\\..\\out\\v2-visual\\welcome\\actual.png" - } - } - } -} diff --git a/src/OpenClaw.SetupPreview/app.manifest b/src/OpenClaw.SetupPreview/app.manifest deleted file mode 100644 index ebb1a314b..000000000 --- a/src/OpenClaw.SetupPreview/app.manifest +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - true/pm - PerMonitorV2 - - - - - - - - diff --git a/src/OpenClaw.Shared/Capabilities/AppCapability.cs b/src/OpenClaw.Shared/Capabilities/AppCapability.cs index ed4f45926..0746523c3 100644 --- a/src/OpenClaw.Shared/Capabilities/AppCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/AppCapability.cs @@ -144,7 +144,34 @@ private NodeInvokeResponse HandleSettingsSet(NodeInvokeRequest request) return Error("Missing required arg: value"); if (SettingsSetHandler == null) return Error("Settings handler not registered"); - return Success(SettingsSetHandler(name, value)); + + var result = SettingsSetHandler(name, value); + if (TryGetErrorPayload(result, out var error)) + return Error(error); + + return Success(result); + } + + private static bool TryGetErrorPayload(object? result, out string error) + { + error = ""; + if (result == null) + return false; + + var property = result.GetType().GetProperty( + "error", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.IgnoreCase); + + if (property?.GetValue(result) is not string message || + string.IsNullOrWhiteSpace(message)) + { + return false; + } + + error = message; + return true; } private NodeInvokeResponse HandleMenu() diff --git a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs index ab9bf4b72..12f9eae31 100644 --- a/src/OpenClaw.Shared/Mcp/McpToolBridge.cs +++ b/src/OpenClaw.Shared/Mcp/McpToolBridge.cs @@ -255,7 +255,7 @@ private object HandleToolsList() ["app.navigate"] = "Navigate the companion app to a specific page (e.g., 'home', 'sessions', 'settings'). Args: page (string, required). Returns { navigated, page }.", ["app.status"] = - "Get current connection status, node state, and gateway info. Returns { connectionStatus, nodeConnected, nodePaired, nodePendingApproval, gatewayVersion, sessionCount, nodeCount }.", + "Get current connection status, manager-owned overall/operator/node state, and gateway info. Returns { connectionStatus, overallState, operatorState, nodeState, nodeConnected, nodePaired, nodePendingApproval, nodeError, gatewayVersion, sessionCount, nodeCount }.", ["app.sessions"] = "List active sessions with optional agent filter. Args: agentId (string, optional). Returns array of { Key, Status, Model, AgeText, tokens }.", ["app.agents"] = @@ -267,9 +267,9 @@ private object HandleToolsList() ["app.settings.get"] = "Read a local app setting by name. Args: name (string, required). Returns the setting value.", ["app.settings.set"] = - "Set a local app setting (name and value). Args: name (string, required), value (string, required). Returns { name, value }.", + "Set a local app setting (name and value), persist it, and apply the same reconnect/reload behavior as saving settings in the app UI. Args: name (string, required), value (string, required). Returns { name, value }; runtime apply failures surface as tool errors.", ["app.menu"] = - "Get tray menu state (status, session count, node count). Returns array of menu items.", + "Get tray menu state (status including overallState/nodeState/nodeError, session count, node count). Returns array of menu items.", ["app.search"] = "Search the command palette and return matching commands. Args: query (string, required). Returns array of { Title, Subtitle, Icon }.", ["app.dashboard.url"] = diff --git a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs index d2cc98ce3..ff15d7ed2 100644 --- a/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs +++ b/src/OpenClaw.Tray.WinUI/App.CapabilityHandlers.cs @@ -47,17 +47,25 @@ private void WireAppCapabilityHandlers() return await tcs.Task; }; - app.StatusHandler = () => new + app.StatusHandler = () => { - connectionStatus = _appState!.Status.ToString(), - nodeConnected = _nodeService?.IsConnected ?? false, - nodePaired = _nodeService?.IsPaired ?? false, - nodePendingApproval = _nodeService?.IsPendingApproval ?? false, - gatewayVersion = _appState!.GatewaySelf?.ServerVersion, - sessionCount = _appState!.Sessions?.Length ?? 0, - nodeCount = _appState!.Nodes?.Length ?? 0, - operatorScopes = _connectionManager?.OperatorClient?.GrantedOperatorScopes.ToArray() ?? Array.Empty(), - operatorDeviceId = _connectionManager?.CurrentSnapshot.OperatorDeviceId, + var snapshot = _connectionManager?.CurrentSnapshot; + return new + { + connectionStatus = _appState!.Status.ToString(), + overallState = snapshot?.OverallState.ToString(), + operatorState = snapshot?.OperatorState.ToString(), + nodeState = snapshot?.NodeState.ToString(), + nodeConnected = snapshot?.NodeState == RoleConnectionState.Connected, + nodePaired = snapshot?.NodePairingStatus == PairingStatus.Paired, + nodePendingApproval = snapshot?.NodeState == RoleConnectionState.PairingRequired, + nodeError = snapshot?.NodeError, + gatewayVersion = _appState!.GatewaySelf?.ServerVersion, + sessionCount = _appState!.Sessions?.Length ?? 0, + nodeCount = _appState!.Nodes?.Length ?? 0, + operatorScopes = _connectionManager?.OperatorClient?.GrantedOperatorScopes.ToArray() ?? Array.Empty(), + operatorDeviceId = snapshot?.OperatorDeviceId, + }; }; app.SessionsHandler = async (agentId) => @@ -149,6 +157,15 @@ private void WireAppCapabilityHandlers() var converted = Convert.ChangeType(value, prop.PropertyType); prop.SetValue(_settings, converted); _settings.Save(); + OnSettingsSaved(this, EventArgs.Empty); + var runtimeError = McpRuntimeStatePolicy.GetSettingsSetError( + name, + converted, + _nodeService?.IsMcpRunning == true, + _nodeService?.McpStartupError); + if (!string.IsNullOrWhiteSpace(runtimeError)) + return new { error = runtimeError }; + return new { name, value = prop.GetValue(_settings) }; } catch (Exception ex) @@ -160,9 +177,17 @@ private void WireAppCapabilityHandlers() app.MenuHandler = () => { + var snapshot = _connectionManager?.CurrentSnapshot; var items = new List { - new { type = "status", status = _appState!.Status.ToString() }, + new + { + type = "status", + status = _appState!.Status.ToString(), + overallState = snapshot?.OverallState.ToString(), + nodeState = snapshot?.NodeState.ToString(), + nodeError = snapshot?.NodeError + }, new { type = "sessions", count = _appState!.Sessions?.Length ?? 0 }, new { type = "nodes", count = _appState!.Nodes?.Length ?? 0 }, }; diff --git a/src/OpenClaw.Tray.WinUI/App.xaml b/src/OpenClaw.Tray.WinUI/App.xaml index 6a8c281be..b65c584ac 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml +++ b/src/OpenClaw.Tray.WinUI/App.xaml @@ -10,22 +10,30 @@ - + + + + + + + + + + + + + + + + + - - - diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 2096f12fb..855e19fa2 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -172,10 +172,10 @@ public IntPtr GetHubWindowHandle() /// /// Cached connection status — sole writer is OnManagerStateChanged. /// Reads are safe from any thread; derives from the connection manager's state machine. - /// SSH tunnel errors in EnsureSshTunnelConfigured also write this temporarily (Phase 3 moves tunnel to manager). /// private WeakReference? _connectionToggleRef; private bool _suspendConnectionToggleEvent; + private string? _lastManagerConnectedSideEffectsKey; // FrozenDictionary for O(1) case-insensitive notification type → setting lookup — no per-call allocation. private static readonly System.Collections.Frozen.FrozenDictionary> s_notifTypeMap = @@ -212,6 +212,8 @@ public IntPtr GetHubWindowHandle() private const string ConnectionIssueNotificationId = "connection:issue"; private const string ConnectionIssueNotificationDedupeKey = "connection:issue"; + private const string McpStartupNotificationId = "mcp:startup"; + private const string McpStartupNotificationDedupeKey = "mcp:startup"; private const string SandboxRiskNotificationId = "sandbox:risk"; private const string SandboxRiskNotificationDedupeKey = "sandbox:risk"; private static readonly TimeSpan SandboxRiskProbeRefreshInterval = TimeSpan.FromMinutes(5); @@ -1351,6 +1353,7 @@ private TrayMenuSnapshot CaptureTrayMenuSnapshot() return new TrayMenuSnapshot { CurrentStatus = _appState!.Status, + OverallState = _connectionManager?.CurrentSnapshot.OverallState, AuthFailureMessage = _appState?.AuthFailureMessage, GatewayUrl = _gatewayRegistry?.GetActive()?.Url ?? _settings?.GetEffectiveGatewayUrl(), GatewaySelf = _appState?.GatewaySelf, @@ -1370,6 +1373,8 @@ private TrayMenuSnapshot CaptureTrayMenuSnapshot() SetupMenuLabel = setupMenuLabel, ShowSetupMenuEntry = !hasSetupManagedLocalWslGateway, LastUpdated = _appState?.LastCheckTime, + IsMcpRunning = _nodeService?.IsMcpRunning == true, + McpStartupError = _nodeService?.McpStartupError, }; } @@ -1793,13 +1798,31 @@ private bool TryStartLocalMcpOnlyNode() try { nodeService.StartLocalOnlyAsync().GetAwaiter().GetResult(); + var notificationPlan = McpRuntimeStatePolicy.PlanStartupNotification( + _settings.EnableMcpServer, + nodeService.IsMcpRunning, + nodeService.McpStartupError); + if (notificationPlan.ShouldShow) + { + Logger.Error($"Failed to start MCP-only node service: {notificationPlan.Message}"); + ApplyMcpStartupNotificationPlan(notificationPlan); + return false; + } + WireAppCapabilityHandlers(); + ApplyMcpStartupNotificationPlan(notificationPlan); Logger.Info("Started MCP-only node service without gateway connection"); return true; } catch (Exception ex) { Logger.Error($"Failed to start MCP-only node service: {ex}"); + nodeService.SetMcpStartupError($"MCP server startup failed: {ex.Message}"); + ApplyMcpStartupNotificationPlan( + McpRuntimeStatePolicy.PlanStartupNotification( + _settings.EnableMcpServer, + nodeService.IsMcpRunning, + nodeService.McpStartupError)); return false; } } @@ -1854,30 +1877,20 @@ private void RaiseChatProviderChanged() /// /// Handles the connection manager's StateChanged event. /// Maps the snapshot to the existing tray icon / UI status system. - /// Authoritative writer of gateway lifecycle status. Local prerequisite - /// failures can still mark the app Error before the manager can connect. + /// Authoritative writer of gateway lifecycle status. /// private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot snap) { - // Map OverallConnectionState to the existing ConnectionStatus enum - // for backward compat with tray icon and hub window - var mapped = snap.OverallState switch - { - OverallConnectionState.Idle => ConnectionStatus.Disconnected, - OverallConnectionState.Connecting => ConnectionStatus.Connecting, - OverallConnectionState.Connected => ConnectionStatus.Connected, - OverallConnectionState.Ready => ConnectionStatus.Connected, - OverallConnectionState.Degraded => ConnectionStatus.Connected, - OverallConnectionState.PairingRequired => ConnectionStatus.Connecting, - OverallConnectionState.Error => ConnectionStatus.Error, - OverallConnectionState.Disconnecting => ConnectionStatus.Disconnected, - _ => ConnectionStatus.Disconnected - }; + var mapped = ConnectionStatusPresenter.ToLegacyStatus(snap); + var connectedSideEffectsKey = snap.OperatorState == RoleConnectionState.Connected + ? $"{snap.GatewayId ?? snap.GatewayUrl ?? "unknown"}|{snap.OperatorDeviceId ?? "unknown"}" + : null; OnUiThread(() => { if (_appState != null) _appState.Status = mapped; + _hubWindow?.UpdateTitleBarStatus(snap, mapped); UpdateTrayIcon(); - SyncConnectionToggle(mapped); + SyncConnectionToggle(mapped, snap.OverallState); UpdateConnectionIssueNotification(snap); if (mapped is ConnectionStatus.Connected or ConnectionStatus.Disconnected or ConnectionStatus.Error) { @@ -1885,6 +1898,20 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna _trayMenuWindow?.HideCascade(); } }); + + if (connectedSideEffectsKey != null) + { + if (!string.Equals(_lastManagerConnectedSideEffectsKey, connectedSideEffectsKey, StringComparison.Ordinal)) + { + _lastManagerConnectedSideEffectsKey = connectedSideEffectsKey; + _ = RunHealthCheckAsync(); + _ = TryConnectLocalNodeServiceAsync(); + } + } + else + { + _lastManagerConnectedSideEffectsKey = null; + } } private NodeService? EnsureNodeService(SettingsManager settings) @@ -2222,6 +2249,35 @@ private void UpdateConnectionIssueNotification(GatewayConnectionSnapshot snapsho id: ConnectionIssueNotificationId); } + private void ShowMcpStartupFailureNotification(string message) + { + AppNotificationPublisher.Show( + _appNotificationService, + "Local MCP failed", + message, + "connection", + "mcp", + AppNotificationSeverity.Error, + McpStartupNotificationDedupeKey, + "connection", + LocalizationHelper.GetString("AppNotification_ActionOpenConnection"), + id: McpStartupNotificationId); + } + + private void ApplyMcpStartupNotificationPlan(McpStartupNotificationPlan plan) + { + if (plan.ShouldShow && !string.IsNullOrWhiteSpace(plan.Message)) + { + ShowMcpStartupFailureNotification(plan.Message); + } + else if (plan.ShouldDismiss) + { + _appNotificationService?.Dismiss(McpStartupNotificationId); + } + + UpdateTrayIcon(); + } + private static bool TryBuildConnectionIssueNotification( GatewayConnectionSnapshot snapshot, out string title, @@ -2236,6 +2292,32 @@ private static bool TryBuildConnectionIssueNotification( category = "lifecycle"; key = ""; + if (snapshot.OperatorPairingRequired) + { + title = LocalizationHelper.GetString("AppNotification_GatewayPairingRequired_Title"); + message = string.IsNullOrWhiteSpace(snapshot.OperatorDeviceId) + ? LocalizationHelper.GetString("AppNotification_GatewayPairingRequired_GenericMessage") + : LocalizationHelper.Format( + "AppNotification_GatewayPairingRequired_DeviceMessageFormat", + DeviceIdForLog(snapshot.OperatorDeviceId)); + category = "pairing"; + key = $"operator-pairing:{snapshot.OperatorDeviceId ?? "unknown"}"; + return true; + } + + if (snapshot.OverallState == OverallConnectionState.PairingRequired && + snapshot.NodeState == RoleConnectionState.PairingRequired) + { + title = LocalizationHelper.GetString("AppNotification_GatewayPairingRequired_Title"); + message = "Approve the Windows node pairing request on the gateway host."; + category = "pairing"; + key = $"node-pairing:{snapshot.NodeDeviceId ?? snapshot.NodePairingRequestId ?? "unknown"}"; + return true; + } + + if (TryBuildNodeConnectionIssueNotification(snapshot, out title, out message, out severity, out category, out key)) + return true; + if (snapshot.OverallState == OverallConnectionState.Error) { title = LocalizationHelper.GetString("AppNotification_GatewayConnectionFailed_Title"); @@ -2248,39 +2330,42 @@ private static bool TryBuildConnectionIssueNotification( return true; } - if (snapshot.OperatorPairingRequired) + return false; + } + + private static bool TryBuildNodeConnectionIssueNotification( + GatewayConnectionSnapshot snapshot, + out string title, + out string message, + out AppNotificationSeverity severity, + out string category, + out string key) + { + title = ""; + message = ""; + severity = AppNotificationSeverity.Warning; + category = "node"; + key = ""; + + if (snapshot.OperatorState == RoleConnectionState.Error) + return false; + + if (snapshot.NodeState == RoleConnectionState.RateLimited) { - title = LocalizationHelper.GetString("AppNotification_GatewayPairingRequired_Title"); - message = string.IsNullOrWhiteSpace(snapshot.OperatorDeviceId) - ? LocalizationHelper.GetString("AppNotification_GatewayPairingRequired_GenericMessage") - : LocalizationHelper.Format( - "AppNotification_GatewayPairingRequired_DeviceMessageFormat", - DeviceIdForLog(snapshot.OperatorDeviceId)); - category = "pairing"; - key = $"operator-pairing:{snapshot.OperatorDeviceId ?? "unknown"}"; + title = LocalizationHelper.GetString("AppNotification_WindowsNodeRateLimited_Title"); + message = snapshot.NodeError ?? LocalizationHelper.GetString("AppNotification_WindowsNodeRateLimited_DefaultMessage"); + key = $"node-rate-limited:{message}"; return true; } - if (snapshot.OverallState == OverallConnectionState.Degraded) + if (snapshot.NodeState is RoleConnectionState.Error or RoleConnectionState.PairingRejected || + !string.IsNullOrWhiteSpace(snapshot.NodeError)) { - if (snapshot.NodeState == RoleConnectionState.Error) - { - title = LocalizationHelper.GetString("AppNotification_WindowsNodeConnectionFailed_Title"); - message = snapshot.NodeError ?? LocalizationHelper.GetString("AppNotification_WindowsNodeConnectionFailed_DefaultMessage"); - severity = AppNotificationSeverity.Error; - category = "node"; - key = $"node-error:{message}"; - return true; - } - - if (snapshot.NodeState == RoleConnectionState.RateLimited) - { - title = LocalizationHelper.GetString("AppNotification_WindowsNodeRateLimited_Title"); - message = snapshot.NodeError ?? LocalizationHelper.GetString("AppNotification_WindowsNodeRateLimited_DefaultMessage"); - category = "node"; - key = $"node-rate-limited:{message}"; - return true; - } + title = LocalizationHelper.GetString("AppNotification_WindowsNodeConnectionFailed_Title"); + message = snapshot.NodeError ?? LocalizationHelper.GetString("AppNotification_WindowsNodeConnectionFailed_DefaultMessage"); + severity = AppNotificationSeverity.Error; + key = $"node-error:{message}"; + return true; } return false; @@ -2546,25 +2631,10 @@ private void OnGatewayConnectionStatusChanged(object? sender, ConnectionStatus s _appState.AuthFailureMessage = null; } - UpdateTrayIcon(); OnUiThread(() => { UpdateStatusDetailWindow(); - SyncConnectionToggle(status); - if (status is ConnectionStatus.Connected or ConnectionStatus.Disconnected or ConnectionStatus.Error) - { - // Dismiss the tray menu on state change — it will capture fresh data on next open - _trayMenuWindow?.HideCascade(); - } }); - - if (status == ConnectionStatus.Connected) - { - _ = RunHealthCheckAsync(); - // Gateway-node mode connects the NodeService after operator auth; MCP-only - // mode keeps serving local tools and must not escalate into node pairing. - _ = TryConnectLocalNodeServiceAsync(); - } } /// @@ -2977,7 +3047,7 @@ private void ClearSandboxRiskNotification() } - private void SyncConnectionToggle(ConnectionStatus status) + private void SyncConnectionToggle(ConnectionStatus status, OverallConnectionState? overallState = null) { if (_connectionToggleRef == null) return; @@ -2991,16 +3061,22 @@ private void SyncConnectionToggle(ConnectionStatus status) return; } - var shouldBeOn = status == ConnectionStatus.Connected; - var canToggle = status is ConnectionStatus.Connected or ConnectionStatus.Disconnected or ConnectionStatus.Error; + var shouldBeOn = ConnectionStatusPresenter.IsLiveOrPending(overallState, status); + var canToggle = overallState switch + { + OverallConnectionState.Connecting or OverallConnectionState.Disconnecting => false, + null => status is ConnectionStatus.Connected or ConnectionStatus.Disconnected or ConnectionStatus.Error, + _ => true + }; + var statusText = ConnectionStatusPresenter.PlainText(overallState, status); _suspendConnectionToggleEvent = true; try { TrayMenuWindow.SetMenuToggleSwitchState(toggle, shouldBeOn, canToggle); ToolTipService.SetToolTip(toggle, - shouldBeOn ? "Connected - toggle off to disconnect" + shouldBeOn ? $"{statusText} - toggle off to disconnect" : status == ConnectionStatus.Connecting ? "Connecting..." - : "Disconnected - toggle on to connect"); + : $"{statusText} - toggle on to connect"); } finally { @@ -3103,17 +3179,23 @@ private async Task RunHealthCheckAsync(bool userInitiated = false) private string BuildTrayTooltip() => new TrayTooltipBuilder(CaptureTraySnapshot()).Build(); - private TrayStateSnapshot CaptureTraySnapshot() => new TrayStateSnapshot + private TrayStateSnapshot CaptureTraySnapshot() { - Status = _appState!.Status, - CurrentActivity = _appState!.CurrentActivity, - Channels = _appState!.Channels, - Nodes = _appState!.Nodes, - LocalNodeFallback = _nodeService?.GetLocalNodeInfo(), - AuthFailureMessage = _appState!.AuthFailureMessage, - LastCheckTime = _appState!.LastCheckTime, - Settings = _settings - }; + return new TrayStateSnapshot + { + Status = _appState!.Status, + OverallState = _connectionManager?.CurrentSnapshot.OverallState, + CurrentActivity = _appState!.CurrentActivity, + Channels = _appState!.Channels, + Nodes = _appState!.Nodes, + LocalNodeFallback = _nodeService?.GetLocalNodeInfo(), + AuthFailureMessage = _appState!.AuthFailureMessage, + LastCheckTime = _appState!.LastCheckTime, + Settings = _settings, + IsMcpRunning = _nodeService?.IsMcpRunning == true, + McpStartupError = _nodeService?.McpStartupError + }; + } #endregion @@ -3277,6 +3359,14 @@ private void OnSettingsSaved(object? sender, EventArgs e) { var nodeService = EnsureNodeService(_settings); nodeService?.SetMcpEnabled(_settings.EnableMcpServer); + if (nodeService != null) + { + ApplyMcpStartupNotificationPlan( + McpRuntimeStatePolicy.PlanStartupNotification( + _settings.EnableMcpServer, + nodeService.IsMcpRunning, + nodeService.McpStartupError)); + } WireAppCapabilityHandlers(); } @@ -3564,6 +3654,7 @@ private AppStateSnapshot CaptureSnapshot() return new AppStateSnapshot { Status = _appState!.Status, + OverallState = _connectionManager?.CurrentSnapshot.OverallState, LastCheckTime = _appState!.LastCheckTime, Channels = _appState!.Channels, Sessions = _appState!.Sessions, @@ -3576,6 +3667,8 @@ private AppStateSnapshot CaptureSnapshot() LastUpdateInfo = _appState!.UpdateInfo, Settings = _settings, NodeService = _nodeService, + IsMcpRunning = _nodeService?.IsMcpRunning == true, + McpStartupError = _nodeService?.McpStartupError, NodePairingApprovalKind = _connectionManager?.CurrentSnapshot.NodePairingApprovalKind ?? PairingApprovalKind.Unknown, NodePairingRequestId = _connectionManager?.CurrentSnapshot.NodePairingRequestId, @@ -3606,7 +3699,7 @@ private async Task ShowOnboardingAsync() await EnsureSetupWindowAsync(); } - private async Task<(SetupWindow? Window, bool CreatedNew)> EnsureSetupWindowAsync() + private async Task<(SetupWindow? Window, bool CreatedNew)> EnsureSetupWindowAsync(bool startAtGatewayInstalledMilestone = false) { if (_settings == null) return (null, false); @@ -3622,7 +3715,7 @@ private async Task ShowOnboardingAsync() try { - var setupWindow = new SetupWindow(); + var setupWindow = new SetupWindow(startAtGatewayInstalledMilestone: startAtGatewayInstalledMilestone); _setupWindow = setupWindow; setupWindow.AdvancedSetupRequested += OnSetupAdvancedSetupRequested; setupWindow.SetupCompleted += OnSetupCompleted; @@ -3648,27 +3741,20 @@ private async Task ShowOnboardingAsync() private async Task ShowGatewayWizardAsync() { - var (setupWindow, createdNew) = await EnsureSetupWindowAsync(); + var (setupWindow, createdNew) = await EnsureSetupWindowAsync(startAtGatewayInstalledMilestone: true); if (setupWindow == null) return; - // Only steer a freshly created setup window to the gateway wizard. An - // already-open setup window may be mid-install on ProgressPage, whose - // Unloaded handler cancels the running setup pipeline — navigating it - // away would abort an in-progress install. In that case leave the - // existing window on its current page (already brought to the front). if (!createdNew) { - Logger.Info("Setup window already open; skipping direct gateway wizard navigation to avoid interrupting active setup"); + if (setupWindow.TryNavigateToGatewayInstalledMilestone()) + Logger.Info("Setup window already open; switched to direct OpenClaw onboard handoff"); + else + Logger.Info("Setup window already open; leaving current setup page visible to avoid interrupting active setup"); return; } await setupWindow.WaitForInitialContentReadyAsync(); - if (ReferenceEquals(_setupWindow, setupWindow) && !setupWindow.IsClosed) - { - if (!setupWindow.TryNavigateToWizard()) - Logger.Warn("Setup window is not ready for direct gateway wizard navigation; leaving current setup page visible"); - } } private void OnSetupAdvancedSetupRequested(object? sender, EventArgs e) @@ -3829,7 +3915,13 @@ private bool TryResolveChatCredentials( private void OpenDashboard(string? path = null) { if (_settings == null) return; - if (!EnsureSshTunnelConfigured()) return; + if (!EnsureSshTunnelConfigured()) + { + _toastService?.ShowToast(new ToastContentBuilder() + .AddText("SSH tunnel") + .AddText(_sshTunnelService?.LastError ?? "Check SSH tunnel settings and logs.")); + return; + } if (!TryResolveChatCredentials(out var gatewayUrl, out var token, out var credentialSource, out var isBootstrapToken)) { @@ -4429,7 +4521,6 @@ _settings.SshTunnelRemotePort is < 1 or > 65535 || _settings.SshTunnelLocalPort is < 1 or > 65535) { Logger.Warn("SSH tunnel is enabled but settings are incomplete"); - _appState!.Status = ConnectionStatus.Error; UpdateTrayIcon(); return false; } @@ -4459,7 +4550,6 @@ _settings.SshTunnelRemotePort is < 1 or > 65535 || catch (Exception ex) { Logger.Error($"Failed to start SSH tunnel: {ex.Message}"); - _appState!.Status = ConnectionStatus.Error; UpdateTrayIcon(); return false; } diff --git a/src/OpenClaw.Tray.WinUI/Assets/LockScreenLogo.png b/src/OpenClaw.Tray.WinUI/Assets/LockScreenLogo.png index 07bfcfabf..38bfc9ef6 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/LockScreenLogo.png and b/src/OpenClaw.Tray.WinUI/Assets/LockScreenLogo.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Setup/Lobster.png b/src/OpenClaw.Tray.WinUI/Assets/Setup/Lobster.png deleted file mode 100644 index 2b9a2dafb..000000000 Binary files a/src/OpenClaw.Tray.WinUI/Assets/Setup/Lobster.png and /dev/null differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Setup/OpenClawMascot.png b/src/OpenClaw.Tray.WinUI/Assets/Setup/OpenClawMascot.png new file mode 100644 index 000000000..0baa169f0 Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Setup/OpenClawMascot.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/SplashScreen.png b/src/OpenClaw.Tray.WinUI/Assets/SplashScreen.png index 0737627d2..e15d1e532 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/SplashScreen.png and b/src/OpenClaw.Tray.WinUI/Assets/SplashScreen.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square150x150Logo.png b/src/OpenClaw.Tray.WinUI/Assets/Square150x150Logo.png index a2e043217..2b00b9ebe 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/Square150x150Logo.png and b/src/OpenClaw.Tray.WinUI/Assets/Square150x150Logo.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.png b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.png index e467c2dbc..96e1518e9 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.png and b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png index 07bfcfabf..fe849f7af 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png and b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-256_altform-unplated.png b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-256_altform-unplated.png index 319469b78..caac31759 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-256_altform-unplated.png and b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-256_altform-unplated.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-32_altform-unplated.png b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-32_altform-unplated.png index f0e4ae8a1..e112742b9 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-32_altform-unplated.png and b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-32_altform-unplated.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-48_altform-unplated.png b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-48_altform-unplated.png index c492bc25d..c0155168b 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-48_altform-unplated.png and b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-48_altform-unplated.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/StoreLogo.png b/src/OpenClaw.Tray.WinUI/Assets/StoreLogo.png index 13556a3b8..d8400bef0 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/StoreLogo.png and b/src/OpenClaw.Tray.WinUI/Assets/StoreLogo.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Wide310x150Logo.png b/src/OpenClaw.Tray.WinUI/Assets/Wide310x150Logo.png index 0217ab425..92d81377f 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/Wide310x150Logo.png and b/src/OpenClaw.Tray.WinUI/Assets/Wide310x150Logo.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/openclaw.ico b/src/OpenClaw.Tray.WinUI/Assets/openclaw.ico index 30dc915b2..19f51459c 100644 Binary files a/src/OpenClaw.Tray.WinUI/Assets/openclaw.ico and b/src/OpenClaw.Tray.WinUI/Assets/openclaw.ico differ diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/WelcomeDialog.cs b/src/OpenClaw.Tray.WinUI/Dialogs/WelcomeDialog.cs index 2449ec862..2971a7acf 100644 --- a/src/OpenClaw.Tray.WinUI/Dialogs/WelcomeDialog.cs +++ b/src/OpenClaw.Tray.WinUI/Dialogs/WelcomeDialog.cs @@ -38,7 +38,7 @@ public WelcomeDialog() root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - // Lobster header + // OpenClaw header var headerPanel = new StackPanel { Orientation = Orientation.Horizontal, diff --git a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs index 2728062d1..608c646ff 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/FluentIconCatalog.cs @@ -73,7 +73,7 @@ public static class FluentIconCatalog public const string ChevronR = "\uE76C"; // ChevronRight public const string Check = "\uE73E"; // CheckMark - // ── Brand placeholder (lobster emoji currently retained) ─────── + // ── Brand placeholder ────────────────────────────────────────── public const string Brand = "🦞"; // ── Diagnostics page glyphs ──────────────────────────────────── diff --git a/src/OpenClaw.Tray.WinUI/Helpers/IconHelper.cs b/src/OpenClaw.Tray.WinUI/Helpers/IconHelper.cs index d2181cd06..feba9b734 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/IconHelper.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/IconHelper.cs @@ -8,7 +8,7 @@ namespace OpenClawTray.Helpers; /// /// Provides icon resources for the tray application. -/// Creates dynamic status icons with lobster pixel art. +/// Creates fallback dynamic status icons when packaged assets are unavailable. /// public static class IconHelper { @@ -65,7 +65,7 @@ public static Icon GetAppIcon() } else { - _appIcon = CreateLobsterIcon(Color.FromArgb(255, 99, 71)); // Lobster red + _appIcon = CreateFallbackStatusIcon(Color.FromArgb(255, 99, 71)); } return _appIcon; @@ -90,17 +90,16 @@ private static Icon GetOrCreateIcon(ref Icon? cached, ConnectionStatus status) ConnectionStatus.Error => Color.FromArgb(244, 67, 54), // Red _ => Color.FromArgb(158, 158, 158) // Gray }; - cached = CreateLobsterIcon(color); + cached = CreateFallbackStatusIcon(color); } return cached; } /// - /// Creates a simple colored lobster icon programmatically. - /// Uses pixel art style matching the original WinForms version. + /// Creates a simple colored fallback status icon. /// - public static Icon CreateLobsterIcon(Color color) + public static Icon CreateFallbackStatusIcon(Color color) { const int size = 16; using var bitmap = new Bitmap(size, size); @@ -108,13 +107,11 @@ public static Icon CreateLobsterIcon(Color color) g.Clear(Color.Transparent); - // Simple lobster silhouette (pixel art style) using var brush = new SolidBrush(color); // Body g.FillRectangle(brush, 6, 6, 4, 6); - // Claws g.FillRectangle(brush, 3, 4, 2, 2); g.FillRectangle(brush, 11, 4, 2, 2); g.FillRectangle(brush, 4, 6, 2, 2); diff --git a/src/OpenClaw.Tray.WinUI/Helpers/ThemeHelper.cs b/src/OpenClaw.Tray.WinUI/Helpers/ThemeHelper.cs index 63448a333..b5165e078 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/ThemeHelper.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/ThemeHelper.cs @@ -46,10 +46,9 @@ public static void ApplyTheme(Window? window, string? preference) public static Color GetAccentColor() { - // Returns the user's Windows accent color (previously hard-coded to - // lobster red). Reads HKCU\Software\Microsoft\Windows\DWM\AccentColor - // which is stored as ABGR DWORD, falls back to the WinUI default - // blue if the registry key is missing. + // Returns the user's Windows accent color. Reads + // HKCU\Software\Microsoft\Windows\DWM\AccentColor, which is stored as + // ABGR DWORD, and falls back to the WinUI default blue if missing. try { using var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\DWM"); diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs index e08310a59..905a77537 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPagePlan.cs @@ -359,6 +359,7 @@ private static ConnectionPagePlan BuildCockpitDegraded( RoleConnectionState.PairingRejected => "Node pairing was rejected.", RoleConnectionState.RateLimited => "Node is rate-limited by the gateway.", RoleConnectionState.Error => "Node reported an error.", + RoleConnectionState.Idle when snap.NodeConnectionIntended => "Node mode is enabled, but the node has not connected.", _ => "Connection is impaired.", }; @@ -710,6 +711,7 @@ private static NodeCardState BuildNodeCardState(GatewayConnectionSnapshot snap, RoleConnectionState.PairingRejected => NodeCardState.OnNodeRejected, RoleConnectionState.RateLimited => NodeCardState.OnNodeRateLimited, RoleConnectionState.Error => NodeCardState.OnNodeError, + RoleConnectionState.Idle when snap.NodeConnectionIntended => NodeCardState.OnNodeError, _ when CountEnabledCapabilities(settings) == 0 => NodeCardState.OnPermissionsIncomplete, _ => NodeCardState.OnHealthy, }; diff --git a/src/OpenClaw.Tray.WinUI/Services/AppStateSnapshot.cs b/src/OpenClaw.Tray.WinUI/Services/AppStateSnapshot.cs index 979a2b1a9..58c9efec5 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AppStateSnapshot.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AppStateSnapshot.cs @@ -9,6 +9,7 @@ namespace OpenClawTray.Services; internal sealed record AppStateSnapshot { public ConnectionStatus Status { get; init; } + public OverallConnectionState? OverallState { get; init; } public DateTime LastCheckTime { get; init; } public ChannelHealth[] Channels { get; init; } = []; public SessionInfo[] Sessions { get; init; } = []; @@ -21,6 +22,8 @@ internal sealed record AppStateSnapshot public UpdateCommandCenterInfo LastUpdateInfo { get; init; } = new(); public SettingsManager? Settings { get; init; } public NodeService? NodeService { get; init; } + public bool IsMcpRunning { get; init; } + public string? McpStartupError { get; init; } public PairingApprovalKind NodePairingApprovalKind { get; init; } public string? NodePairingRequestId { get; init; } public SshTunnelSnapshot? SshTunnelSnapshot { get; init; } diff --git a/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs index 47ceb495e..4ad1aeedd 100644 --- a/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Services/CommandCenterStateBuilder.cs @@ -81,6 +81,34 @@ node.ApprovalState is GatewayNodeApprovalState.PendingApproval or }); } + var overallState = _snapshot.OverallState; + var mcpStartupError = _snapshot.McpStartupError; + + if (_snapshot.Settings?.EnableMcpServer == true && + !string.IsNullOrWhiteSpace(mcpStartupError)) + { + warnings.Insert(0, new GatewayDiagnosticWarning + { + Severity = GatewayDiagnosticSeverity.Critical, + Category = "mcp", + Title = "Local MCP failed", + Detail = mcpStartupError + }); + } + + if (_snapshot.Settings?.EnableMcpServer == true && + (_snapshot.Settings?.EnableNodeMode ?? false) == false && + _snapshot.IsMcpRunning) + { + warnings.Add(new GatewayDiagnosticWarning + { + Severity = GatewayDiagnosticSeverity.Info, + Category = "mcp", + Title = "Local MCP only", + Detail = "Local MCP tools are listening on this PC without a gateway node connection." + }); + } + if (shouldShowPendingLocalNodeApproval && _snapshot.NodeService?.IsPendingApproval == true && !string.IsNullOrWhiteSpace(_snapshot.NodeService.FullDeviceId)) @@ -103,7 +131,27 @@ node.ApprovalState is GatewayNodeApprovalState.PendingApproval or }); } - if (_snapshot.Status == ConnectionStatus.Error) + if (overallState == OpenClaw.Connection.OverallConnectionState.Degraded) + { + warnings.Insert(0, new GatewayDiagnosticWarning + { + Severity = GatewayDiagnosticSeverity.Warning, + Category = "gateway", + Title = "Connection degraded", + Detail = "The operator connection is available, but one required role is blocked." + }); + } + else if (overallState == OpenClaw.Connection.OverallConnectionState.PairingRequired) + { + warnings.Insert(0, new GatewayDiagnosticWarning + { + Severity = GatewayDiagnosticSeverity.Warning, + Category = "pairing", + Title = "Pairing required", + Detail = "Approve the pending operator or node pairing request to finish connecting." + }); + } + else if (_snapshot.Status == ConnectionStatus.Error) { warnings.Insert(0, new GatewayDiagnosticWarning { diff --git a/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs b/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs index 2bea86d1d..bd4da850d 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConnectionStatusPresenter.cs @@ -1,4 +1,5 @@ using OpenClaw.Connection; +using OpenClaw.Shared; namespace OpenClawTray.Services; @@ -12,6 +13,57 @@ internal enum ConnectionStatusAccent internal static class ConnectionStatusPresenter { + public static ConnectionStatus ToLegacyStatus(OverallConnectionState overall) => overall switch + { + OverallConnectionState.Connected or OverallConnectionState.Ready or OverallConnectionState.Degraded => ConnectionStatus.Connected, + OverallConnectionState.Connecting => ConnectionStatus.Connecting, + OverallConnectionState.Idle or OverallConnectionState.Disconnecting => ConnectionStatus.Disconnected, + OverallConnectionState.PairingRequired or + OverallConnectionState.Error => ConnectionStatus.Error, + _ => ConnectionStatus.Disconnected, + }; + + public static ConnectionStatus ToLegacyStatus(GatewayConnectionSnapshot snapshot) + { + if (snapshot.OperatorState == RoleConnectionState.Connected) + return ConnectionStatus.Connected; + + return ToLegacyStatus(snapshot.OverallState); + } + + public static bool IsHealthy(OverallConnectionState? overall, ConnectionStatus fallback) => + overall is OverallConnectionState.Connected or OverallConnectionState.Ready || + (overall is null && fallback == ConnectionStatus.Connected); + + public static bool IsLiveOrPending(OverallConnectionState? overall, ConnectionStatus fallback) => + overall is OverallConnectionState.Connected + or OverallConnectionState.Ready + or OverallConnectionState.Degraded + or OverallConnectionState.PairingRequired + or OverallConnectionState.Connecting || + (overall is null && fallback is ConnectionStatus.Connected or ConnectionStatus.Connecting); + + public static bool IsOperatorChannelLive(GatewayConnectionSnapshot snapshot) => + snapshot.OperatorState == RoleConnectionState.Connected; + + public static string PlainText(OverallConnectionState? overall, ConnectionStatus fallback) => overall switch + { + OverallConnectionState.Connected or OverallConnectionState.Ready => "Connected", + OverallConnectionState.Connecting => "Connecting", + OverallConnectionState.Degraded => "Degraded", + OverallConnectionState.PairingRequired => "Pairing required", + OverallConnectionState.Error => "Connection error", + OverallConnectionState.Disconnecting => "Disconnecting", + OverallConnectionState.Idle => "Disconnected", + _ => fallback switch + { + ConnectionStatus.Connected => "Connected", + ConnectionStatus.Connecting => "Connecting", + ConnectionStatus.Error => "Connection error", + _ => "Disconnected", + }, + }; + public static (string LabelKey, ConnectionStatusAccent Accent) Pill(OverallConnectionState overall) => overall switch { OverallConnectionState.Connected or OverallConnectionState.Ready => diff --git a/src/OpenClaw.Tray.WinUI/Services/McpRuntimeStatePolicy.cs b/src/OpenClaw.Tray.WinUI/Services/McpRuntimeStatePolicy.cs new file mode 100644 index 000000000..bbc494930 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/McpRuntimeStatePolicy.cs @@ -0,0 +1,46 @@ +using System; + +namespace OpenClawTray.Services; + +internal readonly record struct McpStartupNotificationPlan(bool ShouldShow, string? Message) +{ + public bool ShouldDismiss => !ShouldShow; +} + +internal static class McpRuntimeStatePolicy +{ + public const string DefaultStartupError = "Local MCP server did not start."; + + public static McpStartupNotificationPlan PlanStartupNotification( + bool enableMcpServer, + bool isMcpRunning, + string? startupError) + { + if (!enableMcpServer) + return new McpStartupNotificationPlan(false, null); + + if (!string.IsNullOrWhiteSpace(startupError)) + return new McpStartupNotificationPlan(true, startupError); + + return isMcpRunning + ? new McpStartupNotificationPlan(false, null) + : new McpStartupNotificationPlan(true, DefaultStartupError); + } + + public static string? GetSettingsSetError( + string settingName, + object? convertedValue, + bool isMcpRunning, + string? startupError) + { + if (!string.Equals(settingName, nameof(SettingsManager.EnableMcpServer), StringComparison.OrdinalIgnoreCase) || + convertedValue is not bool enableMcpServer || + !enableMcpServer) + { + return null; + } + + var plan = PlanStartupNotification(enableMcpServer, isMcpRunning, startupError); + return plan.ShouldShow ? plan.Message : null; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index b344183af..ea939a464 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -161,6 +161,7 @@ public sealed class NodeService : IDisposable, IAsyncDisposable public string McpEndpoint => McpServerUrl; /// Last MCP server startup error, or null if it started cleanly. Surfaced by Settings UI. public string? McpStartupError => _mcpStartupError; + public void SetMcpStartupError(string? error) => _mcpStartupError = string.IsNullOrWhiteSpace(error) ? null : error; // Events public event EventHandler? StatusChanged; @@ -243,8 +244,16 @@ public Task StartLocalOnlyAsync() // and are consumed by the MCP bridge directly. _logger.Info("Starting Windows Node in MCP-only mode (no gateway)"); _token = null; + _mcpStartupError = null; - RegisterCapabilities(); + try + { + RegisterCapabilities(); + } + catch (Exception ex) + { + SetMcpStartupFailure(ex, "capability registration"); + } return Task.CompletedTask; } @@ -760,10 +769,10 @@ private void InvalidateMxcAvailability() } } - private void StartMcpServer() + private bool StartMcpServer() { - if (!_enableMcpServer) return; - if (_mcpServer != null) return; + if (!_enableMcpServer) return true; + if (_mcpServer != null) return true; McpHttpServer? attempt = null; try { @@ -807,6 +816,7 @@ private void StartMcpServer() attempt.Start(); _mcpServer = attempt; _mcpStartupError = null; + return true; } catch (Exception ex) { @@ -822,6 +832,7 @@ private void StartMcpServer() _logger.Debug($"[MCP] Cleanup of half-started listener failed: {cleanupEx.Message}"); } _mcpServer = null; + return false; } } @@ -838,9 +849,16 @@ private void StartMcpServer() 32 or 183 => $"Port {port} is already in use. Stop the other process or change the MCP port.", _ => $"HTTP listener error {hle.ErrorCode}: {hle.Message}", }, - _ => ex.Message, + InvalidOperationException => $"Configuration error: {ex.Message}", + _ => $"MCP server startup failed: {ex.Message}", }; + private void SetMcpStartupFailure(Exception ex, string phase) + { + _mcpStartupError = DescribeMcpStartupFailure(ex, McpPort); + _logger.Error($"[MCP] Failed during {phase}: {_mcpStartupError}", ex); + } + private void StopMcpServer() { ObserveBackgroundFault(StopMcpServerAsync(), "[MCP] Dispose error"); @@ -889,16 +907,30 @@ public void SetMcpEnabled(bool enabled) if (_mcpServer != null) return; // already running _logger.Info("[MCP] SetMcpEnabled(true) — starting MCP server"); + _mcpStartupError = null; bool needsCapabilities; lock (_capabilitiesLock) { needsCapabilities = _capabilities.Count == 0; } - if (needsCapabilities) + try + { + if (needsCapabilities) + { + RegisterCapabilities(); + } + else + { + StartMcpServer(); + } + } + catch (Exception ex) { - RegisterCapabilities(); + SetMcpStartupFailure(ex, "MCP enable"); } - else + + if (_mcpServer == null && string.IsNullOrWhiteSpace(_mcpStartupError)) { - StartMcpServer(); + _mcpStartupError = "MCP server startup failed: listener did not start."; + _logger.Error($"[MCP] {_mcpStartupError}"); } } else diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs index 7a5a254d9..760d63dbd 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayDashboardSummary.cs @@ -51,7 +51,8 @@ internal TrayDashboardSummaryBuilder(TrayMenuSnapshot snapshot, DateTime? nowUtc internal TrayDashboardSummary Build() { - var isConnected = _snapshot.CurrentStatus == ConnectionStatus.Connected; + var overallState = _snapshot.OverallState; + var isConnected = ConnectionStatusPresenter.IsHealthy(overallState, _snapshot.CurrentStatus); var (severity, headline) = ClassifyHealth(); @@ -78,11 +79,33 @@ internal TrayDashboardSummary Build() if (!string.IsNullOrEmpty(_snapshot.AuthFailureMessage)) return (TrayHealthSeverity.Critical, "Authentication failed"); + if (HasRelevantMcpStartupError()) + return (TrayHealthSeverity.Critical, "Local MCP failed"); + + if (IsStandaloneMcpOnly()) + { + return (TrayHealthSeverity.Ok, "Local MCP only"); + } + var pending = (_snapshot.NodePairList?.Pending.Count ?? 0) + (_snapshot.DevicePairList?.Pending.Count ?? 0); if (pending > 0) return (TrayHealthSeverity.Caution, $"Pairing approval pending ({pending})"); + if (_snapshot.OverallState is { } overall) + { + return overall switch + { + OpenClaw.Connection.OverallConnectionState.Connected or + OpenClaw.Connection.OverallConnectionState.Ready => (TrayHealthSeverity.Ok, "Connected"), + OpenClaw.Connection.OverallConnectionState.Connecting => (TrayHealthSeverity.Caution, "Connecting…"), + OpenClaw.Connection.OverallConnectionState.Degraded => (TrayHealthSeverity.Caution, "Connection degraded"), + OpenClaw.Connection.OverallConnectionState.PairingRequired => (TrayHealthSeverity.Caution, "Pairing required"), + OpenClaw.Connection.OverallConnectionState.Error => (TrayHealthSeverity.Critical, "Connection error"), + _ => (TrayHealthSeverity.Neutral, "Disconnected"), + }; + } + return _snapshot.CurrentStatus switch { ConnectionStatus.Connected => (TrayHealthSeverity.Ok, "Connected"), @@ -92,6 +115,17 @@ internal TrayDashboardSummary Build() }; } + private bool IsStandaloneMcpOnly() => + _snapshot.Settings?.EnableMcpServer == true && + (_snapshot.Settings?.EnableNodeMode ?? false) == false && + _snapshot.IsMcpRunning && + (_snapshot.OverallState is null or OpenClaw.Connection.OverallConnectionState.Idle) && + _snapshot.CurrentStatus != ConnectionStatus.Connected; + + private bool HasRelevantMcpStartupError() => + _snapshot.Settings?.EnableMcpServer == true && + !string.IsNullOrWhiteSpace(_snapshot.McpStartupError); + private string? BuildHeartbeat() { if (_snapshot.LastUpdated is not { } updated) diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs index 5a818bc4c..808db3dfa 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuSnapshot.cs @@ -1,3 +1,4 @@ +using OpenClaw.Connection; using OpenClaw.Shared; using OpenClawTray.Services; using System; @@ -8,6 +9,7 @@ internal sealed record TrayMenuSnapshot { // ── Conexión ── internal required ConnectionStatus CurrentStatus { get; init; } + internal OverallConnectionState? OverallState { get; init; } internal required string? AuthFailureMessage { get; init; } internal required string? GatewayUrl { get; init; } internal required GatewaySelfInfo? GatewaySelf { get; init; } @@ -37,4 +39,6 @@ internal sealed record TrayMenuSnapshot // ── Dashboard glance ── internal DateTime? LastUpdated { get; init; } + internal bool IsMcpRunning { get; init; } + internal string? McpStartupError { get; init; } } diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs index aa63cf6b5..705503761 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs @@ -44,8 +44,10 @@ internal void Build(TrayMenuWindow menu) // ToggleAction delegates; recreate the lookup each rebuild. _permToggleActions.Clear(); - var isConnected = _snapshot.CurrentStatus == ConnectionStatus.Connected; - var statusText = LocalizationHelper.GetConnectionStatusText(_snapshot.CurrentStatus); + var overallState = _snapshot.OverallState; + var isConnected = ConnectionStatusPresenter.IsHealthy(overallState, _snapshot.CurrentStatus); + var isLiveOrPending = ConnectionStatusPresenter.IsLiveOrPending(overallState, _snapshot.CurrentStatus); + var statusText = ConnectionStatusPresenter.PlainText(overallState, _snapshot.CurrentStatus); // Cache theme brushes once per build so cells don't each do a // resource lookup. The previous implementation looked up @@ -97,13 +99,19 @@ internal void Build(TrayMenuWindow menu) Grid.SetColumn(brandRow, 0); brandGrid.Children.Add(brandRow); - var canToggleConnection = _snapshot.CurrentStatus == ConnectionStatus.Connected - || _snapshot.CurrentStatus == ConnectionStatus.Disconnected - || _snapshot.CurrentStatus == ConnectionStatus.Error; - var connectionToggle = menu.CreateMenuToggleSwitch(isConnected, "Gateway connection", canToggleConnection); + var canToggleConnection = overallState switch + { + null => _snapshot.CurrentStatus == ConnectionStatus.Connected + || _snapshot.CurrentStatus == ConnectionStatus.Disconnected + || _snapshot.CurrentStatus == ConnectionStatus.Error, + OpenClaw.Connection.OverallConnectionState.Connecting or + OpenClaw.Connection.OverallConnectionState.Disconnecting => false, + _ => true + }; + var connectionToggle = menu.CreateMenuToggleSwitch(isLiveOrPending, "Gateway connection", canToggleConnection); connectionToggle.Margin = new Thickness(0); ToolTipService.SetToolTip(connectionToggle, - isConnected ? "Connected - toggle off to disconnect" : "Disconnected - toggle on to connect"); + isLiveOrPending ? $"{statusText} - toggle off to disconnect" : $"{statusText} - toggle on to connect"); connectionToggle.Toggled += (s, ev) => { if (_callbacks.IsConnectionToggleSuspended()) @@ -162,7 +170,12 @@ internal void Build(TrayMenuWindow menu) Width = 8, Height = 8, VerticalAlignment = VerticalAlignment.Center, Fill = isConnected ? successBrush - : _snapshot.CurrentStatus == ConnectionStatus.Connecting ? cautionBrush + : (overallState is OpenClaw.Connection.OverallConnectionState.Error || + (overallState is null && _snapshot.CurrentStatus == ConnectionStatus.Error)) ? criticalBrush + : ((overallState is OpenClaw.Connection.OverallConnectionState.Connecting + or OpenClaw.Connection.OverallConnectionState.Degraded + or OpenClaw.Connection.OverallConnectionState.PairingRequired) || + (overallState is null && _snapshot.CurrentStatus == ConnectionStatus.Connecting)) ? cautionBrush : neutralBrush }); gwNameRow.Children.Add(new TextBlock diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayStateSnapshot.cs b/src/OpenClaw.Tray.WinUI/Services/TrayStateSnapshot.cs index 458c39cb8..b6b71f005 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayStateSnapshot.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayStateSnapshot.cs @@ -1,3 +1,4 @@ +using OpenClaw.Connection; using OpenClaw.Shared; using System; @@ -6,6 +7,7 @@ namespace OpenClawTray.Services; internal sealed record TrayStateSnapshot { public ConnectionStatus Status { get; init; } + public OverallConnectionState? OverallState { get; init; } public AgentActivity? CurrentActivity { get; init; } public ChannelHealth[] Channels { get; init; } = []; public GatewayNodeInfo[] Nodes { get; init; } = []; @@ -13,4 +15,6 @@ internal sealed record TrayStateSnapshot public string? AuthFailureMessage { get; init; } public DateTime LastCheckTime { get; init; } public SettingsManager? Settings { get; init; } + public bool IsMcpRunning { get; init; } + public string? McpStartupError { get; init; } } diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayTooltipBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayTooltipBuilder.cs index 5eec68029..6b82c73e0 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayTooltipBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayTooltipBuilder.cs @@ -31,12 +31,16 @@ internal string Build() nodeOnline = localNode.IsOnline ? 1 : 0; } + var statusText = BuildStatusText(); + var overallState = _snapshot.OverallState; + var isHealthy = ConnectionStatusPresenter.IsHealthy(overallState, _snapshot.Status); var warningCount = 0; - if (_snapshot.Status != ConnectionStatus.Connected) warningCount++; + if (!isHealthy) warningCount++; if (_snapshot.AuthFailureMessage != null) warningCount++; - if (_snapshot.Channels.Length == 0 && _snapshot.Status == ConnectionStatus.Connected) warningCount++; + if (HasRelevantMcpStartupError()) warningCount++; + if (_snapshot.Channels.Length == 0 && isHealthy) warningCount++; - var tooltip = $"OpenClaw Tray - {_snapshot.Status}; " + + var tooltip = $"OpenClaw Tray - {statusText}; " + $"{topology.DisplayName}; " + $"Channels {channelReady}/{_snapshot.Channels.Length}; " + $"Nodes {nodeOnline}/{nodeTotal}; " + @@ -45,9 +49,33 @@ internal string Build() if (_snapshot.CurrentActivity != null && !string.IsNullOrEmpty(_snapshot.CurrentActivity.DisplayText)) { - tooltip = $"OpenClaw Tray - {_snapshot.CurrentActivity.DisplayText}; {_snapshot.Status}"; + tooltip = $"OpenClaw Tray - {_snapshot.CurrentActivity.DisplayText}; {statusText}"; } return TrayTooltipFormatter.FitShellTooltip(tooltip); } + + private string BuildStatusText() + { + if (HasRelevantMcpStartupError()) + return "Local MCP failed"; + + if (IsStandaloneMcpOnly()) + { + return "Local MCP only"; + } + + return ConnectionStatusPresenter.PlainText(_snapshot.OverallState, _snapshot.Status); + } + + private bool IsStandaloneMcpOnly() => + _snapshot.Settings?.EnableMcpServer == true && + _snapshot.Settings?.EnableNodeMode == false && + _snapshot.IsMcpRunning && + (_snapshot.OverallState is null or OpenClaw.Connection.OverallConnectionState.Idle) && + _snapshot.Status != ConnectionStatus.Connected; + + private bool HasRelevantMcpStartupError() => + _snapshot.Settings?.EnableMcpServer == true && + !string.IsNullOrWhiteSpace(_snapshot.McpStartupError); } diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 7d994a0a6..4729ff9ac 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -1,4 +1,4 @@ - + @@ -887,157 +887,6 @@ Use one of these options: Open Activity Stream - - - OpenClaw Setup - - - Step 1 of 3 — Connect - - - Connect to your gateway - - - On your gateway host (Mac/Linux), run this to get a setup code: - - - Setup Code - - - Paste the setup code from your gateway dashboard - - - Paste setup code / QR - - - Import QR image... - - - ✅ QR image decoded — press Test Connection - - - ❌ Could not find a valid setup QR code in that image. - - - ❌ Clipboard does not contain setup code text or a QR image. - - - Or enter URL and token manually ▾ - - - Hide manual entry ▴ - - - Gateway URL - - - ws://192.168.1.x:18789 - - - 💡 Accepts ws://, wss://, http://, or https:// - - - Gateway Token - - - Paste your token here - - - Test Connection - - - ⏳ Connecting to gateway... - - - ✅ Connected! - - - ✅ Gateway reached! Device needs pairing approval. - -On your gateway host (Mac/Linux), run: - - openclaw devices approve {0} - - - ❌ Token doesn't match. - -💡 Check gateway auth token: - cat ~/.openclaw/openclaw.json | grep token - - - ❌ Origin not allowed. - -💡 Add this machine to gateway.controlUi.allowedOrigins. - - - ❌ Rate-limited. Wait a minute and try again. - - - ❌ Timed out. Check the URL and gateway is running. - - - ❌ Please enter a token - - - ⚠️ Please test the connection first - - - ✅ Setup code decoded — press Test Connection - - - Enable Node Mode (optional) - - - Node Mode lets your Windows machine run tasks for OpenClaw — like screen capture, camera access, and canvas drawing. - - - Only enable Node Mode for gateways and agents you trust - - - Approved agents can run local commands and may access enabled device surfaces such as screen, camera, location, browser, and canvas. You can disable capability groups later in Settings. - - - Enable Node Mode - - - Device ID: loading... - - - Device ID: (will be generated on first connect) - - - 📋 Copy Device ID - - - ✅ Copied! - - - To approve this node, run on your gateway host: - - - 💡 You can finish setup now — pairing will continue in the background. You'll get a notification when approved. - - - 🎉 You're all set! - - - OpenClaw Tray will connect to your gateway and start monitoring. - - - Back - - - Next - - - Finish - - - Step 2 of 3 — Node Mode - - - Step 3 of 3 — Done - DEVELOPER MODE diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 8d4f89ea0..ad695fc2a 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -1,4 +1,4 @@ - + @@ -844,156 +844,6 @@ Utilisez l'une de ces options : Créer un nouveau flux d'activités - - Configuration d'OpenClaw - - - Étape 1 sur 3 — Connexion - - - Connectez-vous à votre passerelle - - - Sur votre hôte passerelle (Mac/Linux), exécutez cette commande pour obtenir un code de configuration : - - - Code de configuration - - - Collez le code de configuration depuis le tableau de bord de votre passerelle - - - Coller le code / QR - - - Importer une image QR... - - - ✅ Image QR décodée — cliquez sur Tester la connexion - - - ❌ Aucun code QR de configuration valide trouvé dans cette image. - - - ❌ Le presse-papiers ne contient pas de code de configuration texte ni d'image QR. - - - Ou entrez l'URL et le jeton manuellement ▾ - - - Masquer la saisie manuelle ▴ - - - URL de la passerelle - - - ws://192.168.1.x:18789 - - - 💡 Accepte ws://, wss://, http:// ou https:// - - - Jeton de la passerelle - - - Collez votre jeton ici - - - Tester la connexion - - - ⏳ Connexion à la passerelle... - - - ✅ Connecté ! - - - ✅ Passerelle atteinte ! L'appareil nécessite une approbation d'appariement. - -Sur votre hôte passerelle (Mac/Linux), exécutez : - - openclaw devices approve {0} - - - ❌ Le jeton ne correspond pas. - -💡 Vérifiez le jeton d'authentification de la passerelle : - cat ~/.openclaw/openclaw.json | grep token - - - ❌ Origine non autorisée. - -💡 Ajoutez cette machine à gateway.controlUi.allowedOrigins. - - - ❌ Trop de tentatives. Attendez une minute et réessayez. - - - ❌ Délai expiré. Vérifiez l'URL et que la passerelle fonctionne. - - - ❌ Veuillez entrer un jeton - - - ⚠️ Veuillez d'abord tester la connexion - - - ✅ Code de configuration décodé — appuyez sur Tester la connexion - - - Activer le mode nœud (optionnel) - - - Le mode nœud permet à votre machine Windows d'exécuter des tâches pour OpenClaw — comme la capture d'écran, l'accès caméra et le dessin sur canevas. - - - Activez le mode nœud uniquement pour les passerelles et agents de confiance - - - Les agents approuvés peuvent exécuter des commandes locales et accéder aux surfaces activées comme l'écran, la caméra, la localisation, le navigateur et le canevas. Vous pouvez désactiver des groupes de capacités plus tard dans Paramètres. - - - Activer le mode nœud - - - ID de l'appareil : chargement... - - - ID de l'appareil : (sera généré lors de la première connexion) - - - 📋 Copier l'ID de l'appareil - - - ✅ Copié ! - - - Pour approuver ce nœud, exécutez sur votre hôte passerelle : - - - 💡 Vous pouvez terminer la configuration maintenant — l'appariement continuera en arrière-plan. Vous recevrez une notification une fois approuvé. - - - 🎉 Tout est prêt ! - - - OpenClaw Tray se connectera à votre passerelle et commencera la surveillance. - - - Retour - - - Suivant - - - Terminer - - - Étape 2 sur 3 — Mode nœud - - - Étape 3 sur 3 — Terminé - MODE DÉVELOPPEUR diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index c86af0c5b..835cb9e59 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -1,4 +1,4 @@ - + @@ -845,156 +845,6 @@ Gebruik een van deze opties: Activiteitenstroom openen - - OpenClaw Instellen - - - Stap 1 van 3 — Verbinden - - - Verbind met uw gateway - - - Voer op uw gateway-host (Mac/Linux) deze opdracht uit om een installatiecode te krijgen: - - - Installatiecode - - - Plak de installatiecode van uw gateway-dashboard - - - Setupcode / QR plakken - - - QR-afbeelding importeren... - - - ✅ QR-afbeelding gedecodeerd — klik op Verbinding testen - - - ❌ Geen geldige setup-QR-code gevonden in die afbeelding. - - - ❌ Het klembord bevat geen setupcodetekst of QR-afbeelding. - - - Of voer URL en token handmatig in ▾ - - - Handmatige invoer verbergen ▴ - - - Gateway-URL - - - ws://192.168.1.x:18789 - - - 💡 Accepteert ws://, wss://, http:// of https:// - - - Gateway-token - - - Plak hier uw token - - - Verbinding testen - - - ⏳ Verbinden met gateway... - - - ✅ Verbonden! - - - ✅ Gateway bereikt! Apparaat moet worden goedgekeurd voor koppeling. - -Voer op uw gateway-host (Mac/Linux) uit: - - openclaw devices approve {0} - - - ❌ Token komt niet overeen. - -💡 Controleer het gateway-verificatietoken: - cat ~/.openclaw/openclaw.json | grep token - - - ❌ Oorsprong niet toegestaan. - -💡 Voeg deze machine toe aan gateway.controlUi.allowedOrigins. - - - ❌ Te veel verzoeken. Wacht een minuut en probeer opnieuw. - - - ❌ Time-out. Controleer de URL en of de gateway draait. - - - ❌ Voer een token in - - - ⚠️ Test eerst de verbinding - - - ✅ Installatiecode gedecodeerd — druk op Verbinding testen - - - Knooppuntmodus inschakelen (optioneel) - - - Knooppuntmodus laat uw Windows-machine taken uitvoeren voor OpenClaw — zoals schermopname, cameratoegang en canvas-tekenen. - - - Schakel knooppuntmodus alleen in voor gateways en agents die u vertrouwt - - - Goedgekeurde agents kunnen lokale opdrachten uitvoeren en toegang krijgen tot ingeschakelde oppervlakken zoals scherm, camera, locatie, browser en canvas. U kunt capaciteitsgroepen later uitschakelen in Instellingen. - - - Knooppuntmodus inschakelen - - - Apparaat-ID: laden... - - - Apparaat-ID: (wordt gegenereerd bij eerste verbinding) - - - 📋 Apparaat-ID kopiëren - - - ✅ Gekopieerd! - - - Om dit knooppunt goed te keuren, voer uit op uw gateway-host: - - - 💡 U kunt de installatie nu voltooien — koppeling gaat op de achtergrond door. U ontvangt een melding wanneer goedgekeurd. - - - 🎉 Alles is klaar! - - - OpenClaw Tray zal verbinding maken met uw gateway en beginnen met monitoren. - - - Vorige - - - Volgende - - - Voltooien - - - Stap 2 van 3 — Knooppuntmodus - - - Stap 3 van 3 — Gereed - ONTWIKKELAARSMODUS diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 1231c9def..79eb97af8 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -1,4 +1,4 @@ - + @@ -844,156 +844,6 @@ 打开活动流 - - OpenClaw 设置 - - - 第 1 步(共 3 步)— 连接 - - - 连接到您的网关 - - - 在您的网关主机(Mac/Linux)上运行以下命令获取设置码: - - - 设置码 - - - 粘贴来自网关仪表板的设置码 - - - 粘贴设置码 / QR - - - 导入 QR 图片... - - - ✅ QR 图片已解码 — 请点击"测试连接" - - - ❌ 无法在该图片中找到有效的设置 QR 码。 - - - ❌ 剪贴板不包含设置码文本或 QR 图片。 - - - 或手动输入 URL 和令牌 ▾ - - - 隐藏手动输入 ▴ - - - 网关地址 - - - ws://192.168.1.x:18789 - - - 💡 支持 ws://、wss://、http:// 或 https:// - - - 网关令牌 - - - 在此粘贴您的令牌 - - - 测试连接 - - - ⏳ 正在连接网关... - - - ✅ 连接成功! - - - ✅ 已连接到网关!设备需要配对批准。 - -在您的网关主机(Mac/Linux)上运行: - - openclaw devices approve {0} - - - ❌ 令牌不匹配。 - -💡 检查网关认证令牌: - cat ~/.openclaw/openclaw.json | grep token - - - ❌ 来源不被允许。 - -💡 将此机器添加到 gateway.controlUi.allowedOrigins。 - - - ❌ 请求过于频繁。请等待一分钟后重试。 - - - ❌ 连接超时。请检查 URL 和网关是否正在运行。 - - - ❌ 请输入令牌 - - - ⚠️ 请先测试连接 - - - ✅ 设置码已解码 — 请点击"测试连接" - - - 启用节点模式(可选) - - - 节点模式允许您的 Windows 电脑为 OpenClaw 执行任务 — 如屏幕截图、摄像头访问和画布绘制。 - - - 仅为您信任的网关和代理启用节点模式 - - - 已批准的代理可以运行本地命令,并可能访问已启用的设备表面,例如屏幕、摄像头、位置、浏览器和画布。您稍后可以在设置中禁用能力组。 - - - 启用节点模式 - - - 设备 ID:加载中... - - - 设备 ID:(将在首次连接时生成) - - - 📋 复制设备 ID - - - ✅ 已复制! - - - 要批准此节点,请在网关主机上运行: - - - 💡 您可以现在完成设置 — 配对将在后台继续。获得批准后会收到通知。 - - - 🎉 一切就绪! - - - OpenClaw 托盘将连接到您的网关并开始监控。 - - - 上一步 - - - 下一步 - - - 完成 - - - 第 2 步(共 3 步)— 节点模式 - - - 第 3 步(共 3 步)— 完成 - 开发者模式 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 2365a32b4..ac56e8775 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -1,4 +1,4 @@ - + @@ -844,156 +844,6 @@ 打開串流活動 - - OpenClaw 設定 - - - 第 1 步(共 3 步)— 連線 - - - 連線到您的閘道器 - - - 在您的閘道器主機(Mac/Linux)上執行以下命令取得設定碼: - - - 設定碼 - - - 貼上來自閘道器儀表板的設定碼 - - - 貼上設定碼 / QR - - - 匯入 QR 圖片... - - - ✅ QR 圖片已解碼 — 請按「測試連線」 - - - ❌ 無法在該圖片中找到有效的設定 QR 碼。 - - - ❌ 剪貼簿不包含設定碼文字或 QR 圖片。 - - - 或手動輸入 URL 和權杖 ▾ - - - 隱藏手動輸入 ▴ - - - 閘道器網址 - - - ws://192.168.1.x:18789 - - - 💡 支援 ws://、wss://、http:// 或 https:// - - - 閘道器權杖 - - - 在此貼上您的權杖 - - - 測試連線 - - - ⏳ 正在連線到閘道器... - - - ✅ 已連線! - - - ✅ 已連線到閘道器!裝置需要配對核准。 - -在您的閘道器主機(Mac/Linux)上執行: - - openclaw devices approve {0} - - - ❌ 權杖不符。 - -💡 檢查閘道器認證權杖: - cat ~/.openclaw/openclaw.json | grep token - - - ❌ 來源不被允許。 - -💡 將此機器加入 gateway.controlUi.allowedOrigins。 - - - ❌ 請求過於頻繁。請等待一分鐘後重試。 - - - ❌ 連線逾時。請檢查網址和閘道器是否正在執行。 - - - ❌ 請輸入權杖 - - - ⚠️ 請先測試連線 - - - ✅ 設定碼已解碼 — 請點擊「測試連線」 - - - 啟用節點模式(選用) - - - 節點模式允許您的 Windows 電腦為 OpenClaw 執行任務 — 如螢幕擷取、攝影機存取和畫布繪製。 - - - 只為您信任的閘道和代理程式啟用節點模式 - - - 已核准的代理程式可以執行本機命令,並可能存取已啟用的裝置介面,例如螢幕、攝影機、位置、瀏覽器和畫布。您稍後可以在設定中停用能力群組。 - - - 啟用節點模式 - - - 裝置 ID:載入中... - - - 裝置 ID:(將在首次連線時產生) - - - 📋 複製裝置 ID - - - ✅ 已複製! - - - 要核准此節點,請在閘道器主機上執行: - - - 💡 您可以現在完成設定 — 配對將在背景繼續。獲得核准後會收到通知。 - - - 🎉 一切就緒! - - - OpenClaw 系統匣將連線到您的閘道器並開始監控。 - - - 上一步 - - - 下一步 - - - 完成 - - - 第 2 步(共 3 步)— 節點模式 - - - 第 3 步(共 3 步)— 完成 - 開發人員模式 diff --git a/src/OpenClaw.Tray.WinUI/THIRD_PARTY_NOTICES.md b/src/OpenClaw.Tray.WinUI/THIRD_PARTY_NOTICES.md index 75e0eb92e..c7158b0db 100644 --- a/src/OpenClaw.Tray.WinUI/THIRD_PARTY_NOTICES.md +++ b/src/OpenClaw.Tray.WinUI/THIRD_PARTY_NOTICES.md @@ -4,14 +4,9 @@ The colorful sidebar SVG files in `Assets/SidebarIcons/` are derived from the **Fluent UI System Icons** project by Microsoft, color variant (20 selected icons from the full `fluent-color` set in the Iconify ecosystem). -The app/tray/window lobster icon (`Assets/openclaw.ico`, plus the -`Square*` / `StoreLogo` / `LockScreenLogo` PNG family) is derived from the -**Fluent Emoji "Lobster"** 3D source already shipping at `Assets/Setup/Lobster.png`. - - Upstream repositories: - (sidebar icons) - - (lobster) -- License: MIT (both) +- License: MIT - Attribution: not required by MIT; this file documents derivation for compliance. ## Mapping (sidebar item → upstream icon name) diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs index b6495fcb8..c188731c2 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs @@ -772,6 +772,13 @@ private void UpdateTitleBarStatus(ConnectionStatus status) StatusPillDot.Fill = AccentBrush(accent); } + internal void UpdateTitleBarStatus(GatewayConnectionSnapshot snapshot, ConnectionStatus status) + { + var (text, accent) = ComputePillState(status, snapshot); + StatusPillText.Text = text; + StatusPillDot.Fill = AccentBrush(accent); + } + private static (string Text, ConnectionStatusAccent Accent) ComputePillState( ConnectionStatus status, GatewayConnectionSnapshot? snapshot) { diff --git a/src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs b/src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs deleted file mode 100644 index c2ae343c1..000000000 --- a/src/OpenClaw.Tray.WinUI/Windows/SetupWizardWindow.cs +++ /dev/null @@ -1,975 +0,0 @@ -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Automation; -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.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Runtime.InteropServices; -using System.Runtime.InteropServices.WindowsRuntime; -using System.Text.Json; -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; - -/// -/// 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. -/// -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 string _draftBootstrapToken = ""; - 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 TextBox _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; - private bool _hasStoredDeviceToken; - - // 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 = ""; - _draftBootstrapToken = ""; - _draftEnableNodeMode = settings.EnableNodeMode; - - Title = LocalizationHelper.GetString("Setup_Title"); - ExtendsContentIntoTitleBar = true; - this.SetWindowSize(720, 900); - 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 = LocalizationHelper.GetString("Setup_Title"), - Style = (Style)Application.Current.Resources["TitleTextBlockStyle"], - VerticalAlignment = VerticalAlignment.Center - }); - Grid.SetRow(header, 0); - root.Children.Add(header); - - // Step indicator - _stepIndicator = new TextBlock - { - Text = LocalizationHelper.GetString("Setup_StepConnect"), - Foreground = (SolidColorBrush)Application.Current.Resources["TextFillColorSecondaryBrush"], - 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 = LocalizationHelper.GetString("Setup_ConnectTitle"), - FontWeight = FontWeights.SemiBold, - Style = (Style)Application.Current.Resources["SubtitleTextBlockStyle"] - }); - _stepPanels[0].Children.Add(new TextBlock - { - Text = LocalizationHelper.GetString("Setup_ConnectDescription"), - TextWrapping = TextWrapping.Wrap, - Foreground = (SolidColorBrush)Application.Current.Resources["TextFillColorSecondaryBrush"] - }); - 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 = (SolidColorBrush)Application.Current.Resources["CardBackgroundFillColorDefaultBrush"], - Foreground = (SolidColorBrush)Application.Current.Resources["SystemFillColorSuccessBrush"], - Padding = new Thickness(12, 8, 12, 8) - }; - _stepPanels[0].Children.Add(cmdHint); - _setupCodeBox = new TextBox - { - Header = LocalizationHelper.GetString("Setup_SetupCodeHeader"), - PlaceholderText = LocalizationHelper.GetString("Setup_SetupCodePlaceholder"), - TextWrapping = TextWrapping.Wrap, - AcceptsReturn = false - }; - AutomationProperties.SetAutomationId(_setupCodeBox, "SetupCodeBox"); - _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"); - _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 - ? LocalizationHelper.GetString("Setup_ManualEntryToggleHide") : LocalizationHelper.GetString("Setup_ManualEntryToggle"); - }; - _stepPanels[0].Children.Add(manualToggle); - - _gatewayUrlBox = new TextBox - { - Header = LocalizationHelper.GetString("Setup_GatewayUrlHeader"), - PlaceholderText = LocalizationHelper.GetString("Setup_GatewayUrlPlaceholder"), - Text = _draftGatewayUrl - }; - AutomationProperties.SetAutomationId(_gatewayUrlBox, "GatewayUrlBox"); - _gatewayUrlBox.TextChanged += (s, e) => _connectionTested = false; - _manualEntryPanel.Children.Add(_gatewayUrlBox); - _manualEntryPanel.Children.Add(new TextBlock - { - Text = LocalizationHelper.GetString("Setup_GatewayUrlHint"), - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], - Foreground = (SolidColorBrush)Application.Current.Resources["TextFillColorSecondaryBrush"] - }); - _tokenBox = new TextBox - { - Header = LocalizationHelper.GetString("Setup_TokenHeader"), - PlaceholderText = LocalizationHelper.GetString("Setup_TokenPlaceholder"), - Text = _draftToken - }; - AutomationProperties.SetAutomationId(_tokenBox, "TokenBox"); - _tokenBox.TextChanged += (s, e) => _connectionTested = false; - _tokenBox.TextChanged += (s, e) => - { - _draftToken = _tokenBox.Text; - UpdatePairingStatusText(); - }; - _manualEntryPanel.Children.Add(_tokenBox); - _stepPanels[0].Children.Add(_manualEntryPanel); - - // Test connection - _testButton = new Button { Content = LocalizationHelper.GetString("Setup_TestButton") }; - AutomationProperties.SetAutomationId(_testButton, "TestConnectionButton"); - _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 = LocalizationHelper.GetString("Setup_NodeModeTitle"), - FontWeight = FontWeights.SemiBold, - Style = (Style)Application.Current.Resources["SubtitleTextBlockStyle"] - }); - _stepPanels[1].Children.Add(new TextBlock - { - Text = LocalizationHelper.GetString("Setup_NodeModeDescription"), - TextWrapping = TextWrapping.Wrap, - Foreground = (SolidColorBrush)Application.Current.Resources["TextFillColorSecondaryBrush"] - }); - var securityWarning = new InfoBar - { - Title = LocalizationHelper.GetString("Setup_NodeModeSecurityTitle"), - Message = LocalizationHelper.GetString("Setup_NodeModeSecurityMessage"), - Severity = InfoBarSeverity.Warning, - IsOpen = true, - IsClosable = false - }; - AutomationProperties.SetAutomationId(securityWarning, "SetupNodeModeSecurityWarning"); - _stepPanels[1].Children.Add(securityWarning); - _nodeModeToggle = new ToggleSwitch - { - Header = LocalizationHelper.GetString("Setup_NodeModeToggle"), - IsOn = _draftEnableNodeMode - }; - AutomationProperties.SetAutomationId(_nodeModeToggle, "NodeModeToggle"); - _nodeModeToggle.Toggled += (s, e) => - { - UpdateNodeModePairingVisibility(_nodeModeToggle.IsOn); - }; - _stepPanels[1].Children.Add(_nodeModeToggle); - - _deviceIdText = new TextBlock - { - Text = LocalizationHelper.GetString("Setup_DeviceIdLoading"), - 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 = LocalizationHelper.GetString("Setup_CopyDeviceId"), - Visibility = _draftEnableNodeMode ? Visibility.Visible : Visibility.Collapsed - }; - AutomationProperties.SetAutomationId(_copyDeviceIdButton, "CopyDeviceIdButton"); - _copyDeviceIdButton.Click += OnCopyDeviceId; - _stepPanels[1].Children.Add(_copyDeviceIdButton); - - _pairingStatusText = new TextBlock - { - TextWrapping = TextWrapping.Wrap, - Visibility = _draftEnableNodeMode ? Visibility.Visible : Visibility.Collapsed - }; - AutomationProperties.SetAutomationId(_pairingStatusText, "SetupPairingStatusText"); - _stepPanels[1].Children.Add(_pairingStatusText); - - var pairingInstructions = new StackPanel - { - Spacing = 4, - Margin = new Thickness(0, 8, 0, 0) - }; - pairingInstructions.Children.Add(new TextBlock - { - Text = LocalizationHelper.GetString("Setup_ApproveInstructions"), - TextWrapping = TextWrapping.Wrap, - Foreground = (SolidColorBrush)Application.Current.Resources["TextFillColorSecondaryBrush"] - }); - var approveCmd = new TextBox - { - Text = "openclaw devices list\nopenclaw devices approve ", - IsReadOnly = true, - FontFamily = new FontFamily("Cascadia Mono, Consolas"), - BorderThickness = new Thickness(1), - Background = (SolidColorBrush)Application.Current.Resources["CardBackgroundFillColorDefaultBrush"], - Foreground = (SolidColorBrush)Application.Current.Resources["SystemFillColorSuccessBrush"], - Padding = new Thickness(12, 8, 12, 8), - AcceptsReturn = true, - TextWrapping = TextWrapping.Wrap - }; - pairingInstructions.Children.Add(approveCmd); - pairingInstructions.Children.Add(new TextBlock - { - Text = LocalizationHelper.GetString("Setup_ApproveHint"), - TextWrapping = TextWrapping.Wrap, - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], - Foreground = (SolidColorBrush)Application.Current.Resources["TextFillColorSecondaryBrush"] - }); - _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 = LocalizationHelper.GetString("Setup_DoneTitle"), - FontWeight = FontWeights.SemiBold, - Style = (Style)Application.Current.Resources["SubtitleTextBlockStyle"] - }); - _stepPanels[2].Children.Add(new TextBlock - { - Text = LocalizationHelper.GetString("Setup_DoneDescription"), - TextWrapping = TextWrapping.Wrap, - Foreground = (SolidColorBrush)Application.Current.Resources["TextFillColorSecondaryBrush"] - }); - 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 = LocalizationHelper.GetString("Setup_BackButton"), Visibility = Visibility.Collapsed }; - AutomationProperties.SetAutomationId(_backButton, "BackButton"); - _backButton.Click += (s, e) => GoToStep(_currentStep - 1); - navPanel.Children.Add(_backButton); - - _nextButton = new Button - { - Content = LocalizationHelper.GetString("Setup_NextButton"), - Style = (Style)Application.Current.Resources["AccentButtonStyle"] - }; - AutomationProperties.SetAutomationId(_nextButton, "NextButton"); - _nextButton.Click += OnNextClicked; - navPanel.Children.Add(_nextButton); - - Grid.SetRow(navPanel, 3); - root.Children.Add(navPanel); - - // Wrap content in a container with custom titlebar - var outerGrid = new Grid(); - outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(48) }); - outerGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); - - var titleBar = new Grid { Padding = new Thickness(16, 0, 140, 0) }; - var titleIcon = new TextBlock - { - Text = "🦞", - FontSize = 20, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 10, 0) - }; - var titleText = new TextBlock - { - Text = LocalizationHelper.GetString("Setup_Title"), - FontSize = 13, - Style = (Style)Application.Current.Resources["CaptionTextBlockStyle"], - VerticalAlignment = VerticalAlignment.Center - }; - var titleStack = new StackPanel { Orientation = Orientation.Horizontal }; - titleStack.Children.Add(titleIcon); - titleStack.Children.Add(titleText); - titleBar.Children.Add(titleStack); - Grid.SetRow(titleBar, 0); - outerGrid.Children.Add(titleBar); - - Grid.SetRow(root, 1); - outerGrid.Children.Add(root); - Content = outerGrid; - SetTitleBar(titleBar); - 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 stepKeys = new[] { "Setup_StepConnect", "Setup_StepNodeMode", "Setup_StepDone" }; - _stepIndicator.Text = LocalizationHelper.GetString(stepKeys[_currentStep]); - - if (_currentStep == TotalSteps - 1) - { - _nextButton.Content = LocalizationHelper.GetString("Setup_FinishButton"); - } - else - { - _nextButton.Content = LocalizationHelper.GetString("Setup_NextButton"); - } - } - - private void OnNextClicked(object sender, RoutedEventArgs e) - { - switch (_currentStep) - { - case 0: // Connection — must have tested successfully - if (!_connectionTested) - { - _testStatusLabel.Text = LocalizationHelper.GetString("Setup_TestFirst"); - 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)) - { - _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.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)); - using 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)) - { - _draftBootstrapToken = tokenProp.GetString() ?? ""; - _draftEnableNodeMode = !string.IsNullOrWhiteSpace(_draftBootstrapToken); - _nodeModeToggle.IsOn = _draftEnableNodeMode; - UpdateNodeModePairingVisibility(_draftEnableNodeMode); - UpdatePairingStatusText(); - } - - if (TryGetSetupCodeExpiry(doc.RootElement, out var expiresAt) && - expiresAt <= DateTimeOffset.UtcNow) - { - _draftBootstrapToken = ""; - _connectionTested = false; - _testStatusLabel.Text = "❌ Setup code expired. Generate a fresh QR/setup code from the gateway and try again."; - Logger.Warn($"[Setup] Setup code expired at {expiresAt:O}"); - return false; - } - - 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 = successMessage; - _connectionTested = GatewayUrlHelper.IsValidGatewayUrl(_draftGatewayUrl); - Logger.Info($"[Setup] Setup code decoded: gateway={GatewayUrlHelper.SanitizeForDisplay(_draftGatewayUrl)}"); - return true; - } - catch (System.FormatException) - { - return false; - } - catch (System.Text.Json.JsonException) - { - return false; - } - } - - internal static bool TryGetSetupCodeExpiry(JsonElement payload, out DateTimeOffset expiresAt) - { - foreach (var propertyName in new[] { "expiresAt", "expires_at", "expires", "expiry", "exp" }) - { - if (payload.TryGetProperty(propertyName, out var value) && - TryParseSetupCodeExpiryValue(value, out expiresAt)) - { - return true; - } - } - - expiresAt = default; - return false; - } - - private static bool TryParseSetupCodeExpiryValue(JsonElement value, out DateTimeOffset expiresAt) - { - if (value.ValueKind == JsonValueKind.String) - { - var text = value.GetString(); - if (DateTimeOffset.TryParse( - text, - CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, - out expiresAt)) - { - return true; - } - - if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var unixFromString)) - { - expiresAt = UnixTimeToDateTimeOffset(unixFromString); - return true; - } - } - else if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var unix)) - { - expiresAt = UnixTimeToDateTimeOffset(unix); - return true; - } - - expiresAt = default; - return false; - } - - private static DateTimeOffset UnixTimeToDateTimeOffset(long value) => - value > 10_000_000_000 - ? DateTimeOffset.FromUnixTimeMilliseconds(value) - : DateTimeOffset.FromUnixTimeSeconds(value); - - private void OnPasteSetupFromClipboard(object sender, RoutedEventArgs e) => - AsyncEventHandlerGuard.Run( - OnPasteSetupFromClipboardAsync, - new OpenClawTray.AppLogger(), - nameof(OnPasteSetupFromClipboard)); - - private async Task OnPasteSetupFromClipboardAsync() - { - 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 void OnImportQrImage(object sender, RoutedEventArgs e) => - AsyncEventHandlerGuard.Run( - OnImportQrImageAsync, - new OpenClawTray.AppLogger(), - nameof(OnImportQrImage)); - - private async Task OnImportQrImageAsync() - { - 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.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; - _draftEnableNodeMode = showPairing; - UpdatePairingStatusText(); - } - - private void OnTestConnection(object sender, RoutedEventArgs e) => - AsyncEventHandlerGuard.Run( - OnTestConnectionAsync, - new OpenClawTray.AppLogger(), - nameof(OnTestConnection)); - - private async Task OnTestConnectionAsync() - { - _draftGatewayUrl = _gatewayUrlBox.Text.Trim(); - _draftToken = _tokenBox.Text; - UpdatePairingStatusText(); - - if (!GatewayUrlHelper.IsValidGatewayUrl(_draftGatewayUrl)) - { - _testStatusLabel.Text = $"❌ {GatewayUrlHelper.ValidationMessage}"; - return; - } - - 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; - - 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(); - - 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 = LocalizationHelper.GetString("Setup_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 = string.Format(LocalizationHelper.GetString("Setup_PairingRequired"), deviceId); - _connectionTested = true; - } - else if (lastError.Contains("token mismatch", StringComparison.OrdinalIgnoreCase)) - { - _testStatusLabel.Text = LocalizationHelper.GetString("Setup_TokenMismatch"); - } - else if (lastError.Contains("origin not allowed", StringComparison.OrdinalIgnoreCase)) - { - _testStatusLabel.Text = LocalizationHelper.GetString("Setup_OriginNotAllowed"); - } - else if (lastError.Contains("too many failed", StringComparison.OrdinalIgnoreCase)) - { - _testStatusLabel.Text = LocalizationHelper.GetString("Setup_RateLimited"); - } - else if (!string.IsNullOrEmpty(lastError)) - { - _testStatusLabel.Text = $"❌ {lastError}"; - } - else - { - _testStatusLabel.Text = LocalizationHelper.GetString("Setup_TimedOut"); - } - } - 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.EnableNodeMode = _draftEnableNodeMode; - _existingSettings.Save(); - - Completed = true; - SetupCompleted?.Invoke(this, EventArgs.Empty); - Logger.Info("[Setup] Wizard completed"); - Close(); - } - - private void LoadDeviceIdentity() - { - try - { - var dataPath = Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR") is { Length: > 0 } overridePath - ? overridePath - : 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; - _hasStoredDeviceToken = !string.IsNullOrWhiteSpace(identity.DeviceToken); - UpdatePairingStatusText(); - } - catch (Exception ex) - { - Logger.Warn($"[Setup] Could not load device identity: {ex.Message}"); - _deviceIdText.Text = LocalizationHelper.GetString("Setup_DeviceIdFallback"); - _hasStoredDeviceToken = false; - UpdatePairingStatusText(); - } - } - - private void UpdatePairingStatusText() - { - if (_pairingStatusText == null) - { - return; - } - - _pairingStatusText.Text = BuildPairingExpectationText( - _draftEnableNodeMode, - _hasStoredDeviceToken, - !string.IsNullOrWhiteSpace(_draftBootstrapToken), - !string.IsNullOrWhiteSpace(_draftToken)); - } - - internal static string BuildPairingExpectationText( - bool nodeModeEnabled, - bool hasStoredDeviceToken, - bool hasBootstrapToken, - bool hasGatewayToken) - { - if (!nodeModeEnabled) - { - return "Node Mode is off; this tray will only act as an operator UI."; - } - - if (hasStoredDeviceToken) - { - return "Already paired: this device has a saved gateway device token and should reconnect without manual approval."; - } - - if (hasBootstrapToken) - { - return "Auto-pairing expected: this setup code includes a bootstrap token. Finish setup and the gateway should approve this node automatically. If the bootstrap token expired or was already used, Command Center will show a waiting-for-approval repair command."; - } - - if (hasGatewayToken) - { - return "Manual approval expected: this setup uses a gateway token, not a bootstrap token. Finish setup, then approve the device from the gateway CLI if Command Center reports that the node is waiting for approval."; - } - - return "Pairing method unknown: enter a setup code for auto-pairing or a gateway token for manual approval."; - } - - private void OnCopyDeviceId(object sender, RoutedEventArgs e) - { - try - { - var fullId = _copyDeviceIdButton.Tag?.ToString(); - if (string.IsNullOrEmpty(fullId)) return; - - ClipboardHelper.CopyText(fullId); - _copyDeviceIdButton.Content = LocalizationHelper.GetString("Setup_DeviceIdCopied"); - Logger.Info("[Setup] Device ID copied to clipboard"); - - // Reset button text after 2 seconds - _ = Task.Delay(2000).ContinueWith(_ => - { - DispatcherQueue.TryEnqueue(() => _copyDeviceIdButton.Content = LocalizationHelper.GetString("Setup_CopyDeviceId")); - }); - } - 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}"); - } - } -} diff --git a/src/OpenClaw.WinNode.Cli/skill.md b/src/OpenClaw.WinNode.Cli/skill.md index e72445241..bf61068cd 100644 --- a/src/OpenClaw.WinNode.Cli/skill.md +++ b/src/OpenClaw.WinNode.Cli/skill.md @@ -308,7 +308,7 @@ Returns `{ navigated, page }`. ### app.status Current connection / node state. -No params. Returns `{ connectionStatus, nodeConnected, nodePaired, nodePendingApproval, gatewayVersion, sessionCount, nodeCount }`. +No params. Returns `{ connectionStatus, overallState, operatorState, nodeState, nodeConnected, nodePaired, nodePendingApproval, nodeError, gatewayVersion, sessionCount, nodeCount }`. ### app.sessions Active sessions, optionally filtered by agent. @@ -340,15 +340,15 @@ Read a local app setting by name. Returns the setting value (type depends on the setting). ### app.settings.set -Set a local app setting. +Set a local app setting, persist it, and apply the same reconnect/reload behavior as saving settings in the app UI. ``` {"name": "string", "value": "string"} // both required ``` -Returns `{ name, value }`. +Returns `{ name, value }`; runtime apply failures surface as tool errors. ### app.menu Get tray menu state (status, session count, node count). No params. -Returns array of menu items. +Returns array of menu items; the status item includes `status`, `overallState`, `nodeState`, and `nodeError`. ### app.search Search the command palette and return matching commands. diff --git a/tests/OpenClaw.Connection.Tests/ConnectionStateMachineTests.cs b/tests/OpenClaw.Connection.Tests/ConnectionStateMachineTests.cs index 29b401b69..dcf71a49f 100644 --- a/tests/OpenClaw.Connection.Tests/ConnectionStateMachineTests.cs +++ b/tests/OpenClaw.Connection.Tests/ConnectionStateMachineTests.cs @@ -278,6 +278,22 @@ public void NodePairingRequired_WithOperatorConnected_DerivesPairingRequired() Assert.Equal(OpenClaw.Shared.PairingStatus.Pending, _sm.Current.NodePairingStatus); } + [Fact] + public void NodePairingRequired_FromNodeError_ClearsStaleNodeError() + { + _sm.SetNodeEnabled(true); + GoToConnected(); + _sm.StartNodeConnecting(); + Assert.True(_sm.TryTransition(ConnectionTrigger.NodeError, "transport failed")); + + Assert.True(_sm.TryTransition(ConnectionTrigger.NodePairingRequired)); + + Assert.Equal(OverallConnectionState.PairingRequired, _sm.Current.OverallState); + Assert.Equal(RoleConnectionState.PairingRequired, _sm.Current.NodeState); + Assert.Null(_sm.Current.NodeError); + Assert.Equal(OpenClaw.Shared.PairingStatus.Pending, _sm.Current.NodePairingStatus); + } + [Fact] public void SetNodeInfo_PendingWithoutRequestId_ClearsStaleRequestIdAndKind() { @@ -337,15 +353,17 @@ public void NodePairingRejected_DerivesDegraded() } [Fact] - public void NodeDisconnected_FromConnected_DerivesConnected() + public void NodeDisconnected_FromConnected_DerivesDegradedWhenNodeStillIntended() { _sm.SetNodeEnabled(true); GoToConnected(); _sm.StartNodeConnecting(); _sm.TryTransition(ConnectionTrigger.NodeConnected); Assert.True(_sm.TryTransition(ConnectionTrigger.NodeDisconnected)); - // Operator still connected, node idle → Connected (not Ready) + // Operator still connected, node mode still intended, node idle → Degraded (not healthy). Assert.Equal(RoleConnectionState.Idle, _sm.Current.NodeState); + Assert.Equal(OverallConnectionState.Degraded, _sm.Current.OverallState); + Assert.True(_sm.Current.NodeConnectionIntended); } [Fact] @@ -378,6 +396,7 @@ public void SetNodeEnabled_True_SetsNodeToIdle() { _sm.SetNodeEnabled(true); Assert.Equal(RoleConnectionState.Idle, _sm.Current.NodeState); + Assert.True(_sm.Current.NodeConnectionIntended); } [Fact] @@ -385,6 +404,32 @@ public void SetNodeEnabled_False_SetsNodeToDisabled() { _sm.SetNodeEnabled(false); Assert.Equal(RoleConnectionState.Disabled, _sm.Current.NodeState); + Assert.False(_sm.Current.NodeConnectionIntended); + } + + [Fact] + public void BlockNodeStart_WithOperatorConnected_DerivesDegradedAndKeepsReason() + { + _sm.SetNodeEnabled(true); + GoToConnected(); + + _sm.BlockNodeStart("No node credential available"); + + Assert.Equal(OverallConnectionState.Degraded, _sm.Current.OverallState); + Assert.Equal(RoleConnectionState.Error, _sm.Current.NodeState); + Assert.Equal("No node credential available", _sm.Current.NodeError); + Assert.True(_sm.Current.NodeConnectionIntended); + } + + [Fact] + public void BlockNodeStart_WithoutOperatorConnected_DerivesErrorAndKeepsReason() + { + _sm.BlockNodeStart("No node credential available"); + + Assert.Equal(OverallConnectionState.Error, _sm.Current.OverallState); + Assert.Equal(RoleConnectionState.Error, _sm.Current.NodeState); + Assert.Equal("No node credential available", _sm.Current.NodeError); + Assert.True(_sm.Current.NodeConnectionIntended); } // ─── Reset ─── @@ -420,10 +465,14 @@ public void Reset_ReturnsToIdle() [InlineData(RoleConnectionState.Connected, RoleConnectionState.RateLimited, false, OverallConnectionState.Ready)] // Node connecting is ignored when node mode is disabled → Ready (not Connecting). [InlineData(RoleConnectionState.Connected, RoleConnectionState.Connecting, false, OverallConnectionState.Ready)] - // Operator connected, node idle, node enabled → operator-only connected (fallthrough). - [InlineData(RoleConnectionState.Connected, RoleConnectionState.Idle, true, OverallConnectionState.Connected)] + // Operator connected, node idle, node enabled → intended node is blocked/degraded. + [InlineData(RoleConnectionState.Connected, RoleConnectionState.Idle, true, OverallConnectionState.Degraded)] // Node PairingRequired is reported regardless of nodeEnabled. [InlineData(RoleConnectionState.Connected, RoleConnectionState.PairingRequired, false, OverallConnectionState.PairingRequired)] + [InlineData(RoleConnectionState.Idle, RoleConnectionState.Connecting, true, OverallConnectionState.Connecting)] + [InlineData(RoleConnectionState.Idle, RoleConnectionState.Error, true, OverallConnectionState.Error)] + [InlineData(RoleConnectionState.Idle, RoleConnectionState.PairingRequired, true, OverallConnectionState.PairingRequired)] + [InlineData(RoleConnectionState.Idle, RoleConnectionState.Connected, true, OverallConnectionState.Connected)] public void DeriveOverall_ReturnsCorrectState( RoleConnectionState op, RoleConnectionState node, bool nodeEnabled, OverallConnectionState expected) { diff --git a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs index 8e82da628..d1e36d08d 100644 --- a/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs +++ b/tests/OpenClaw.Connection.Tests/GatewayConnectionManagerTests.cs @@ -308,6 +308,256 @@ public async Task HandshakeSucceeded_StartsManagerNodeConnector_WhenGateAllows() Assert.Equal("wss://remote.example", nodeConnector.LastGatewayUrl); } + [Fact] + public async Task HandshakeSucceeded_NodeModeEnabledMarksNodeConnectingBeforeEmitting() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-tok", false, "test"); + var nodeConnector = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: nodeConnector, + isNodeEnabled: () => true); + var snapshots = new List(); + manager.StateChanged += (_, snapshot) => snapshots.Add(snapshot); + + await manager.ConnectAsync("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.Contains(snapshots, snapshot => + snapshot.OperatorState == RoleConnectionState.Connected && + snapshot.NodeState == RoleConnectionState.Connecting && + snapshot.OverallState == OverallConnectionState.Connecting); + Assert.DoesNotContain(snapshots, snapshot => + snapshot.OperatorState == RoleConnectionState.Connected && + snapshot.NodeState == RoleConnectionState.Idle && + snapshot.OverallState == OverallConnectionState.Degraded); + } + + [Fact] + public async Task HandshakeSucceeded_NodeModeEnabledMissingGatewayRecord_ReportsBlockedNode() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-tok", false, "test"); + var nodeConnector = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: nodeConnector, + isNodeEnabled: () => true); + + await manager.ConnectAsync("gw-remote"); + _registry.Remove("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.Equal(0, nodeConnector.ConnectCount); + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); + Assert.Equal(OverallConnectionState.Degraded, manager.CurrentSnapshot.OverallState); + Assert.Contains("gateway record", manager.CurrentSnapshot.NodeError, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task HandshakeSucceeded_NodeModeEnabledMissingGatewayRecord_EmitsNoReadySnapshot() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-tok", false, "test"); + var nodeConnector = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: nodeConnector, + isNodeEnabled: () => true); + var snapshots = new List(); + manager.StateChanged += (_, snapshot) => snapshots.Add(snapshot); + + await manager.ConnectAsync("gw-remote"); + _registry.Remove("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.DoesNotContain(snapshots, snapshot => + snapshot.OperatorState == RoleConnectionState.Connected && + snapshot.OverallState == OverallConnectionState.Ready); + Assert.Contains(snapshots, snapshot => + snapshot.NodeState == RoleConnectionState.Error && + snapshot.NodeError?.Contains("gateway record", StringComparison.OrdinalIgnoreCase) == true); + } + + [Fact] + public async Task HandshakeSucceeded_NodeModeEnabledMissingConnector_EmitsNoReadySnapshot() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + isNodeEnabled: () => true); + var snapshots = new List(); + manager.StateChanged += (_, snapshot) => snapshots.Add(snapshot); + + await manager.ConnectAsync("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.DoesNotContain(snapshots, snapshot => + snapshot.OperatorState == RoleConnectionState.Connected && + snapshot.OverallState == OverallConnectionState.Ready); + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.Contains("no node connector", manager.CurrentSnapshot.NodeError, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ReconnectAfterNodeModeDisabled_ClearsNodeIntentAndDoesNotDeriveDegraded() + { + var nodeEnabled = true; + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-tok", false, "test"); + var nodeConnector = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: nodeConnector, + isNodeEnabled: () => nodeEnabled); + + await manager.ConnectAsync("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); + + nodeEnabled = false; + await manager.ReconnectAsync(); + await InvokeHandshakeSucceededAsync(manager); + + Assert.False(manager.CurrentSnapshot.NodeConnectionIntended); + Assert.Equal(RoleConnectionState.Disabled, manager.CurrentSnapshot.NodeState); + Assert.Equal(OverallConnectionState.Ready, manager.CurrentSnapshot.OverallState); + } + + [Fact] + public async Task HandshakeSucceeded_NodeModeEnabledWithoutNodeCredential_DerivesDegradedBlockedNode() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = null; + var nodeConnector = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: nodeConnector, + isNodeEnabled: () => true); + + await manager.ConnectAsync("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.Equal(0, nodeConnector.ConnectCount); + Assert.Equal(RoleConnectionState.Connected, manager.CurrentSnapshot.OperatorState); + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); + Assert.Equal(OverallConnectionState.Degraded, manager.CurrentSnapshot.OverallState); + Assert.Contains("No node credential", manager.CurrentSnapshot.NodeError); + Assert.Null(manager.CurrentSnapshot.NodeCredentialSource); + } + + [Fact] + public async Task HandshakeSucceeded_NodeConnectorThrows_ReportsBlockedNode() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-tok", false, "test"); + var nodeConnector = new ScriptedNodeConnector + { + ConnectAction = (_, _) => throw new InvalidOperationException("connector boom") + }; + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: nodeConnector, + isNodeEnabled: () => true); + var snapshots = new List(); + manager.StateChanged += (_, snapshot) => snapshots.Add(snapshot); + + await manager.ConnectAsync("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.Equal(1, nodeConnector.ConnectCount); + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.Equal(OverallConnectionState.Degraded, manager.CurrentSnapshot.OverallState); + Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); + Assert.Contains("connector boom", manager.CurrentSnapshot.NodeError, StringComparison.OrdinalIgnoreCase); + Assert.Contains(snapshots, snapshot => + snapshot.NodeState == RoleConnectionState.Error && + snapshot.NodeError?.Contains("connector boom", StringComparison.OrdinalIgnoreCase) == true); + Assert.NotEqual(RoleConnectionState.Connecting, snapshots.Last().NodeState); + } + + [Fact] + public async Task HandshakeSucceeded_PreviousNodeDisconnectThrows_ReportsBlockedNode() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-tok", false, "test"); + var nodeConnector = new ThrowingNodeDisconnectConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: nodeConnector, + isNodeEnabled: () => true); + var snapshots = new List(); + manager.StateChanged += (_, snapshot) => snapshots.Add(snapshot); + + await manager.ConnectAsync("gw-remote"); + await InvokeHandshakeSucceededAsync(manager); + + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.Equal(OverallConnectionState.Degraded, manager.CurrentSnapshot.OverallState); + Assert.Contains("disconnect failed", manager.CurrentSnapshot.NodeError, StringComparison.OrdinalIgnoreCase); + Assert.Contains(snapshots, snapshot => + snapshot.NodeState == RoleConnectionState.Error && + snapshot.NodeError?.Contains("disconnect failed", StringComparison.OrdinalIgnoreCase) == true); + Assert.NotEqual(RoleConnectionState.Connecting, snapshots.Last().NodeState); + } + + [Fact] + public async Task BlockNodeStartAsync_StaleLifecycleGeneration_DoesNotOverwriteCurrentSnapshot() + { + SetupGateway("gw-remote", "wss://remote.example", isLocal: false); + _resolver.OperatorCredential = new GatewayCredential("op-tok", false, "test"); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance); + + await manager.ConnectAsync("gw-remote"); + var before = manager.CurrentSnapshot; + + await InvokeBlockNodeStartAsync( + manager, + "stale blocker", + expectedLifecycleGeneration: GetPrivateLong(manager, "_generation") + 1); + + Assert.Equal(before, manager.CurrentSnapshot); + } + [Fact] public async Task ConnectAsync_WithPersistedV2Requirement_SetsClientUseV2Signature() { @@ -481,10 +731,56 @@ private static async Task InvokeHandshakeSucceededAsync(GatewayConnectionManager "HandleHandshakeSucceededAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); Assert.NotNull(method); - var task = (Task)method!.Invoke(manager, [1L])!; + var task = (Task)method!.Invoke(manager, [GetPrivateLong(manager, "_generation")])!; + await task; + } + + private static async Task InvokeStartNodeConnectionCoreAsync( + GatewayConnectionManager manager, + long nodeGeneration) + { + var method = typeof(GatewayConnectionManager).GetMethod( + "StartNodeConnectionCoreAsync", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(method); + var task = (Task)method!.Invoke(manager, [GetPrivateLong(manager, "_generation"), nodeGeneration, CancellationToken.None])!; + return await task; + } + + private static async Task InvokeBlockNodeStartAsync( + GatewayConnectionManager manager, + string detail, + long? expectedLifecycleGeneration = null, + long? expectedNodeGeneration = null) + { + var method = typeof(GatewayConnectionManager).GetMethod( + "BlockNodeStartAsync", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(method); + var task = (Task)method!.Invoke( + manager, + [detail, CancellationToken.None, expectedLifecycleGeneration, expectedNodeGeneration])!; await task; } + private static void SetPrivateField(GatewayConnectionManager manager, string fieldName, object? value) + { + var field = typeof(GatewayConnectionManager).GetField( + fieldName, + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(field); + field!.SetValue(manager, value); + } + + private static long GetPrivateLong(GatewayConnectionManager manager, string fieldName) + { + var field = typeof(GatewayConnectionManager).GetField( + fieldName, + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(field); + return (long)field!.GetValue(manager)!; + } + private static async Task WaitUntilAsync(Func condition) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2); @@ -587,6 +883,62 @@ public async Task ConnectNodeOnlyAsync_UsesNodeCredential_WhenOperatorCredential Assert.Equal(CredentialResolver.SourceNodeDeviceToken, manager.CurrentSnapshot.NodeCredentialSource); } + [Fact] + public async Task ConnectNodeOnlyAsync_MissingNodeCredential_ReportsBlockedNode() + { + SetupGateway("gw-1", "wss://test"); + _resolver.OperatorCredential = null; + _resolver.NodeCredential = null; + var node = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: node); + + await manager.ConnectNodeOnlyAsync("gw-1"); + + Assert.Equal(0, node.ConnectCount); + Assert.Empty(_factory.CreatedCredentials); + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); + Assert.Equal(OverallConnectionState.Error, manager.CurrentSnapshot.OverallState); + Assert.Contains("No node credential", manager.CurrentSnapshot.NodeError); + Assert.Null(manager.CurrentSnapshot.NodeCredentialSource); + } + + [Fact] + public async Task StartNodeConnectionCoreAsync_MissingActiveGatewayContext_ReportsBlockedNode() + { + SetupGateway("gw-1", "wss://test"); + _resolver.OperatorCredential = new GatewayCredential("operator-token", false, "test"); + _resolver.NodeCredential = new GatewayCredential("node-token", false, "test"); + var node = new CountingNodeConnector(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: node, + shouldStartNodeConnection: (_, _) => false); + + await manager.ConnectAsync("gw-1"); + await InvokeHandshakeSucceededAsync(manager); + SetPrivateField(manager, "_activeGatewayRecordId", null); + + var started = await InvokeStartNodeConnectionCoreAsync( + manager, + GetPrivateLong(manager, "_nodeConnectionGeneration")); + + Assert.False(started); + Assert.Equal(0, node.ConnectCount); + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); + Assert.Equal(OverallConnectionState.Degraded, manager.CurrentSnapshot.OverallState); + Assert.Contains("no active gateway context", manager.CurrentSnapshot.NodeError, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task ConnectNodeOnlyAsync_PreservesConnectedOperatorForNodeListRefresh() { @@ -728,6 +1080,44 @@ public async Task ConnectNodeOnlyAsync_StartsSshTunnel_WhenGatewayUsesTunnel() Assert.Equal(CredentialResolver.SourceNodeDeviceToken, manager.CurrentSnapshot.NodeCredentialSource); } + [Fact] + public async Task ConnectNodeOnlyAsync_TunnelStartFailure_ReportsBlockedNode() + { + _registry.AddOrUpdate(new GatewayRecord + { + Id = "gw-ssh", + Url = "wss://remote.example", + SshTunnel = new SshTunnelConfig("user", "host.example", 18789, 45678) + }); + _registry.SetActive("gw-ssh"); + _resolver.OperatorCredential = null; + _resolver.NodeCredential = new GatewayCredential( + "node-token", + IsBootstrapToken: false, + Source: CredentialResolver.SourceNodeDeviceToken); + var node = new CountingNodeConnector(); + var tunnel = new FailingTunnelManager(); + using var manager = new GatewayConnectionManager( + _resolver, + _factory, + _registry, + NullLogger.Instance, + nodeConnector: node, + tunnelManager: tunnel); + var snapshots = new List(); + manager.StateChanged += (_, snapshot) => snapshots.Add(snapshot); + + await manager.ConnectNodeOnlyAsync("gw-ssh"); + + Assert.Equal(0, node.ConnectCount); + Assert.Equal(RoleConnectionState.Error, manager.CurrentSnapshot.NodeState); + Assert.True(manager.CurrentSnapshot.NodeConnectionIntended); + Assert.Equal(OverallConnectionState.Error, manager.CurrentSnapshot.OverallState); + Assert.Contains("SSH tunnel", manager.CurrentSnapshot.NodeError, StringComparison.OrdinalIgnoreCase); + Assert.Contains(snapshots, snapshot => snapshot.NodeState == RoleConnectionState.Error); + Assert.NotEqual(RoleConnectionState.Connecting, snapshots.Last().NodeState); + } + [Fact] public async Task ConnectAsync_StartsSshTunnelAndUsesTunnelUrl_WhenGatewayUsesTunnel() { @@ -1347,6 +1737,39 @@ public async Task DisconnectAsync() public void Dispose() { } } + private sealed class ThrowingNodeDisconnectConnector : INodeConnector + { + public bool IsConnected => true; + public PairingStatus PairingStatus => PairingStatus.Paired; + public string? NodeDeviceId => "throwing-disconnect-node"; + public NodeConnectionMode Mode => NodeConnectionMode.Gateway; + +#pragma warning disable CS0067 // Events required by interface but not fired in tests + public event EventHandler? StatusChanged; + public event EventHandler? PairingStatusChanged; + public event EventHandler? DeviceTokenReceived; + public event EventHandler? ClientCreated; +#pragma warning restore CS0067 + + public Task ConnectAsync(string gatewayUrl, GatewayCredential credential, string identityPath, bool useV2Signature = false) + => Task.CompletedTask; + + public Task ConnectAsync( + string gatewayUrl, + GatewayCredential credential, + string identityPath, + bool useV2Signature, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return ConnectAsync(gatewayUrl, credential, identityPath, useV2Signature); + } + + public Task DisconnectAsync() => throw new InvalidOperationException("disconnect failed"); + + public void Dispose() { } + } + private sealed class CountingTunnelManager : ISshTunnelManager { public int StartCount { get; private set; } @@ -1372,6 +1795,19 @@ public Task StopAsync() public void Dispose() { } } + private sealed class FailingTunnelManager : ISshTunnelManager + { + public bool IsActive => false; + public string? LocalTunnelUrl => null; + + public Task StartAsync(SshTunnelConfig config, CancellationToken ct) => + throw new InvalidOperationException("tunnel failed"); + + public Task StopAsync() => Task.CompletedTask; + + public void Dispose() { } + } + /// /// Test connector that fires StatusChanged / PairingStatusChanged events synchronously /// so tests can drive the manager's state machine through realistic transitions. diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs index 932307bd2..bab5a27c9 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupConfigTests.cs @@ -3,6 +3,7 @@ namespace OpenClaw.SetupEngine.Tests; +[Collection(EnvironmentVariableCollection.Name)] public class SetupConfigTests : IDisposable { private readonly string _tempDir; @@ -205,7 +206,7 @@ public void TraySettingsConfig_MergesIntoFile_OverwritesSetupKeysAndPreservesUnk var settingsPath = Path.Combine(_tempDir, "settings.json"); File.WriteAllText(settingsPath, """{"CustomKey": "custom_value", "EnableNodeMode": false, "AutoStart": true, "NodeCameraEnabled": false}"""); - var traySettings = new TraySettingsConfig { EnableNodeMode = true, AutoStart = false }; + var traySettings = new TraySettingsConfig { EnableNodeMode = true, AutoStart = false, NodeCameraEnabled = false }; traySettings.MergeIntoSettingsFile(settingsPath); var result = JsonDocument.Parse(File.ReadAllText(settingsPath)); @@ -215,6 +216,96 @@ public void TraySettingsConfig_MergesIntoFile_OverwritesSetupKeysAndPreservesUnk Assert.Equal("custom_value", result.RootElement.GetProperty("CustomKey").GetString()); } + [Fact] + public void TraySettingsConfig_ApplyCapabilities_MapsSetupCapabilitiesToRuntimeNodeSettings() + { + var caps = new CapabilitiesConfig + { + System = false, + Canvas = true, + Screen = true, + Camera = false, + Location = false, + Browser = false, + Device = true, + Tts = true, + Stt = false, + }; + + var traySettings = new TraySettingsConfig(); + traySettings.ApplyCapabilities(caps); + + Assert.False(traySettings.NodeSystemRunEnabled); + Assert.True(traySettings.NodeCanvasEnabled); + Assert.True(traySettings.NodeScreenEnabled); + Assert.False(traySettings.NodeCameraEnabled); + Assert.False(traySettings.NodeLocationEnabled); + Assert.False(traySettings.NodeBrowserProxyEnabled); + Assert.True(traySettings.NodeTtsEnabled); + Assert.False(traySettings.NodeSttEnabled); + } + + [Fact] + public void SetupReviewSummary_UsesActiveSetupConfig() + { + var oldData = Environment.GetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR"); + var oldLocalData = Environment.GetEnvironmentVariable("OPENCLAW_TRAY_LOCAL_DATA_DIR"); + try + { + Environment.SetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR", Path.Combine(_tempDir, "roaming")); + Environment.SetEnvironmentVariable("OPENCLAW_TRAY_LOCAL_DATA_DIR", Path.Combine(_tempDir, "local")); + var config = new SetupConfig + { + DistroName = "CustomClaw", + BaseDistro = "Debian", + GatewayPort = 19999, + Gateway = { Bind = "lan", InstallUrl = "https://example.test/install.sh" } + }; + + var summary = SetupReviewSummaryBuilder.Build(config); + + Assert.Contains("Debian", summary.DistroTitle); + Assert.Contains("CustomClaw", summary.DistroDescription); + Assert.Contains("19999", summary.GatewayEndpoint); + Assert.Contains("LAN bind enabled", summary.GatewayDescription); + Assert.Contains("example.test", summary.InstallerDescription); + Assert.Contains("CustomClaw", summary.ExactCommands); + Assert.Contains("19999", summary.ExactCommands); + Assert.Equal("CustomClaw · LAN:19999", summary.CompletionGatewaySummary); + } + finally + { + Environment.SetEnvironmentVariable("OPENCLAW_TRAY_DATA_DIR", oldData); + Environment.SetEnvironmentVariable("OPENCLAW_TRAY_LOCAL_DATA_DIR", oldLocalData); + } + } + + [Fact] + public void SetupConfig_UsesBundledDefaultConfig_IsRuntimeOnly() + { + var config = new SetupConfig { UsesBundledDefaultConfig = true }; + var path = Path.Combine(_tempDir, "config.json"); + + File.WriteAllText(path, JsonSerializer.Serialize(config, SetupConfig.JsonWriteOptions)); + var roundTripped = SetupConfig.LoadFromFile(path); + + Assert.False(roundTripped.UsesBundledDefaultConfig); + } + + [Fact] + public void TraySettingsConfig_UpdateAutoStartInSettingsFile_PreservesCapabilitySettings() + { + var settingsPath = Path.Combine(_tempDir, "settings.json"); + File.WriteAllText(settingsPath, """{"AutoStart": false, "NodeCameraEnabled": false, "NodeSystemRunEnabled": false}"""); + + TraySettingsConfig.UpdateAutoStartInSettingsFile(settingsPath, autoStart: true); + + var result = JsonDocument.Parse(File.ReadAllText(settingsPath)); + Assert.True(result.RootElement.GetProperty("AutoStart").GetBoolean()); + Assert.False(result.RootElement.GetProperty("NodeCameraEnabled").GetBoolean()); + Assert.False(result.RootElement.GetProperty("NodeSystemRunEnabled").GetBoolean()); + } + [Fact] public void TraySettingsConfig_CorruptExistingFile_BacksUpAndThrows() { @@ -228,6 +319,18 @@ public void TraySettingsConfig_CorruptExistingFile_BacksUpAndThrows() Assert.Single(Directory.EnumerateFiles(_tempDir, "settings.json.corrupt-*.bak")); } + [Fact] + public void TraySettingsConfig_CorruptExistingFile_BackupNamesDoNotCollide() + { + var settingsPath = Path.Combine(_tempDir, "settings.json"); + File.WriteAllText(settingsPath, "{not json"); + + Assert.Throws(() => new TraySettingsConfig().MergeIntoSettingsFile(settingsPath)); + Assert.Throws(() => TraySettingsConfig.UpdateAutoStartInSettingsFile(settingsPath, autoStart: true)); + + Assert.Equal(2, Directory.EnumerateFiles(_tempDir, "settings.json.corrupt-*.bak").Count()); + } + [Fact] public void TraySettingsConfig_CreatesNewFile_WhenMissing() { diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 8b4fbd410..3a13ff8c4 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Sockets; using System.Runtime.InteropServices; +using System.Text.Json; namespace OpenClaw.SetupEngine.Tests; @@ -45,6 +46,39 @@ private SetupContext CreateContext(SetupConfig? config = null, ICommandRunner? c return new SetupContext(cfg, logger, journal, commands ?? new CommandRunner(logger), CancellationToken.None); } + [Fact] + public void WriteSettingsJson_AppliesConfiguredCapabilitiesBeforePersisting() + { + var config = new SetupConfig + { + Capabilities = new CapabilitiesConfig + { + System = false, + Canvas = true, + Screen = true, + Camera = false, + Location = false, + Browser = false, + Device = true, + Tts = true, + Stt = false, + }, + }; + var ctx = CreateContext(config); + + VerifyEndToEndStep.WriteSettingsJson(ctx); + + using var result = JsonDocument.Parse(File.ReadAllText(Path.Combine(_tempDir, "settings.json"))); + Assert.False(result.RootElement.GetProperty("NodeSystemRunEnabled").GetBoolean()); + Assert.True(result.RootElement.GetProperty("NodeCanvasEnabled").GetBoolean()); + Assert.True(result.RootElement.GetProperty("NodeScreenEnabled").GetBoolean()); + Assert.False(result.RootElement.GetProperty("NodeCameraEnabled").GetBoolean()); + Assert.False(result.RootElement.GetProperty("NodeLocationEnabled").GetBoolean()); + Assert.False(result.RootElement.GetProperty("NodeBrowserProxyEnabled").GetBoolean()); + Assert.True(result.RootElement.GetProperty("NodeTtsEnabled").GetBoolean()); + Assert.False(result.RootElement.GetProperty("NodeSttEnabled").GetBoolean()); + } + // ─── CleanupStaleGatewayStep: Preserve non-local records ─── [Fact] diff --git a/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs b/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs index 47719cdad..1d2642ab4 100644 --- a/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs +++ b/tests/OpenClaw.Shared.Tests/AppCapabilityTests.cs @@ -84,6 +84,45 @@ public async Task SettingsGet_WithNoHandler_ReturnsError() Assert.False(res.Ok); } + [Fact] + public async Task SettingsSet_WithHandlerErrorPayload_ReturnsCommandError() + { + var cap = new AppCapability(NullLogger.Instance) + { + SettingsSetHandler = (_, _) => new { error = "MCP server startup failed" } + }; + var req = new NodeInvokeRequest + { + Id = "1", + Command = "app.settings.set", + Args = ParseArgs("{\"name\":\"EnableMcpServer\",\"value\":\"true\"}") + }; + + var res = await cap.ExecuteAsync(req); + + Assert.False(res.Ok); + Assert.Equal("MCP server startup failed", res.Error); + } + + [Fact] + public async Task SettingsSet_WithHandlerSuccessPayload_ReturnsData() + { + var cap = new AppCapability(NullLogger.Instance) + { + SettingsSetHandler = (name, _) => new { name, value = true } + }; + var req = new NodeInvokeRequest + { + Id = "1", + Command = "app.settings.set", + Args = ParseArgs("{\"name\":\"EnableMcpServer\",\"value\":\"true\"}") + }; + + var res = await cap.ExecuteAsync(req); + + Assert.True(res.Ok); + } + [Fact] public async Task UnknownCommand_ReturnsError() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index d8992295f..73cf7542a 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -86,7 +86,11 @@ public void McpOnlyStartup_DoesNotRequireGatewayCredentials() Assert.Contains("!_settings.EnableMcpServer || _settings.EnableNodeMode", method); Assert.Contains("EnsureNodeService(_settings)", method); Assert.Contains("StartLocalOnlyAsync()", method); + Assert.Contains("McpRuntimeStatePolicy.PlanStartupNotification", method); + Assert.Contains("ApplyMcpStartupNotificationPlan", method); Assert.Contains("WireAppCapabilityHandlers()", method); + AssertInOrder(method, "nodeService.StartLocalOnlyAsync()", "WireAppCapabilityHandlers()"); + AssertInOrder(method, "WireAppCapabilityHandlers()", "Started MCP-only node service without gateway connection"); var init = ExtractMethod(source, "InitializeGatewayClient"); AssertInOrder(init, "TryStartLocalMcpOnlyNode();", "Gateway URL not configured"); @@ -108,6 +112,124 @@ public void LegacyCredentialMigration_StaysRegistryBacked() Assert.DoesNotContain("BootstrapToken =", method); } + [Fact] + public void LifecycleStatus_IsWrittenFromManagerSnapshotOnly() + { + var source = ReadAppSources(); + var managerHandler = ExtractMethod(source, "OnManagerStateChanged"); + var rawHandler = ExtractMethod(source, "OnGatewayConnectionStatusChanged"); + + Assert.Contains("ConnectionStatusPresenter.ToLegacyStatus(snap)", managerHandler); + Assert.Contains("SyncConnectionToggle(mapped, snap.OverallState)", managerHandler); + Assert.Contains("_hubWindow?.UpdateTitleBarStatus(snap, mapped)", managerHandler); + Assert.Contains("_appState.Status = mapped", managerHandler); + Assert.DoesNotContain("_appState.Status =", rawHandler); + Assert.DoesNotContain("SyncConnectionToggle(status)", rawHandler); + Assert.DoesNotContain("RunHealthCheckAsync()", rawHandler); + Assert.DoesNotContain("TryConnectLocalNodeServiceAsync()", rawHandler); + } + + [Fact] + public void Dashboard_SurfacesSshTunnelConfigurationFailure() + { + var source = ReadAppSources(); + var method = ExtractMethod(source, "OpenDashboard"); + + Assert.Contains("if (!EnsureSshTunnelConfigured())", method); + Assert.Contains("_toastService?.ShowToast", method); + Assert.Contains("Check SSH tunnel settings and logs.", method); + } + + [Fact] + public void ConnectionIssueNotification_PrefersNodeOwnedFailuresBeforeGenericGatewayError() + { + var source = ReadAppSources(); + + AssertInOrder( + source, + "snapshot.NodeState == RoleConnectionState.PairingRequired", + "TryBuildNodeConnectionIssueNotification(snapshot", + "if (snapshot.OverallState == OverallConnectionState.Error)"); + Assert.Contains("TryBuildNodeConnectionIssueNotification", source); + Assert.Contains("snapshot.OperatorState == RoleConnectionState.Error", source); + } + + [Fact] + public void CommandCenter_UsesOverallStateBeforeLegacyStatus() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var source = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "Services", "CommandCenterStateBuilder.cs")); + + AssertInOrder( + source, + "if (overallState == OpenClaw.Connection.OverallConnectionState.Degraded)", + "else if (_snapshot.Status == ConnectionStatus.Error)"); + Assert.Contains("_snapshot.Settings?.EnableMcpServer == true", source); + Assert.Contains("!string.IsNullOrWhiteSpace(mcpStartupError)", source); + } + + [Fact] + public void AppSettingsSet_AppliesSettingsSavedLifecycle() + { + var source = ReadAppSources(); + var method = ExtractMethod(source, "WireAppCapabilityHandlers"); + + AssertInOrder( + method, + "app.SettingsSetHandler = (name, value) =>", + "_settings.Save();", + "OnSettingsSaved(this, EventArgs.Empty);", + "McpRuntimeStatePolicy.GetSettingsSetError", + "return new { error = runtimeError };", + "return new { name, value = prop.GetValue(_settings) };"); + } + + [Fact] + public void OnSettingsSaved_AppliesMcpStartupNotificationPlan() + { + var source = ReadAppSources(); + var method = ExtractMethod(source, "OnSettingsSaved"); + + Assert.Contains("nodeService?.SetMcpEnabled(_settings.EnableMcpServer)", method); + Assert.Contains("McpRuntimeStatePolicy.PlanStartupNotification", method); + Assert.Contains("ApplyMcpStartupNotificationPlan", method); + AssertInOrder( + method, + "nodeService?.SetMcpEnabled(_settings.EnableMcpServer)", + "ApplyMcpStartupNotificationPlan", + "McpRuntimeStatePolicy.PlanStartupNotification"); + } + + [Fact] + public void AppStatus_ReportsNodeStateFromManagerSnapshot() + { + var source = ReadAppSources(); + var method = ExtractMethod(source, "WireAppCapabilityHandlers"); + + Assert.Contains("var snapshot = _connectionManager?.CurrentSnapshot;", method); + Assert.Contains("overallState = snapshot?.OverallState.ToString()", method); + Assert.Contains("operatorState = snapshot?.OperatorState.ToString()", method); + Assert.Contains("nodeState = snapshot?.NodeState.ToString()", method); + Assert.Contains("nodeConnected = snapshot?.NodeState == RoleConnectionState.Connected", method); + Assert.Contains("nodePaired = snapshot?.NodePairingStatus == PairingStatus.Paired", method); + Assert.Contains("nodePendingApproval = snapshot?.NodeState == RoleConnectionState.PairingRequired", method); + Assert.Contains("nodeError = snapshot?.NodeError", method); + Assert.Contains("operatorDeviceId = snapshot?.OperatorDeviceId", method); + } + + [Fact] + public void AppMenu_StatusItemIncludesManagerSnapshotState() + { + var source = ReadAppSources(); + var method = ExtractMethod(source, "WireAppCapabilityHandlers"); + + Assert.Contains("app.MenuHandler = () =>", method); + Assert.Contains("overallState = snapshot?.OverallState.ToString()", method); + Assert.Contains("nodeState = snapshot?.NodeState.ToString()", method); + Assert.Contains("nodeError = snapshot?.NodeError", method); + } + [Fact] public void Startup_NodeOnlyReconnect_UsesNodeCredentialAndLegacyIdentityFallback() { @@ -214,14 +336,25 @@ public void Setup_IsHostedInTrayAndUsesSelfRestartAfterCompletion() var root = TestRepositoryPaths.GetRepositoryRoot(); var setupWindow = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "SetupWindow.xaml.cs")); - Assert.Contains("new SetupWindow()", source); + Assert.Contains("new SetupWindow(startAtGatewayInstalledMilestone: startAtGatewayInstalledMilestone)", source); Assert.Contains("setupWindow.SetupCompleted += OnSetupCompleted", source); Assert.Contains("ShowGatewayWizardAsync", source); - Assert.Contains("setupWindow.TryNavigateToWizard()", source); + Assert.Contains("EnsureSetupWindowAsync(startAtGatewayInstalledMilestone: true)", source); + Assert.Contains("startAtGatewayInstalledMilestone", setupWindow); + Assert.Contains("_persistStartupPreferenceOnComplete = false", setupWindow); + Assert.Contains("_showStartupPreferenceOnComplete = false", setupWindow); + Assert.Contains("CanNavigateToGatewayInstalledMilestone", setupWindow); + Assert.Contains("RootFrame.Content is not ProgressPage { IsPipelineRunning: true }", setupWindow); + Assert.Contains("TryNavigateToGatewayInstalledMilestone", setupWindow); + Assert.Contains("setupWindow.TryNavigateToGatewayInstalledMilestone()", source); + AssertInOrder( + setupWindow, + "SetupRunLock.TryAcquire", + "if (startAtGatewayInstalledMilestone)", + "NavigateToGatewayInstalledMilestone()"); Assert.Contains("CanNavigateToWizard", setupWindow); - // Direct onboarding must not hijack an already-open setup window: it - // only navigates a freshly created window so it cannot cancel an - // in-progress install running on ProgressPage. + // Direct onboarding may reuse an already-open idle setup window, but + // must not cancel an in-progress install running on ProgressPage. Assert.Contains("EnsureSetupWindowAsync", source); Assert.Contains("if (!createdNew)", source); Assert.Contains("RestartAfterSetupAsync", source); @@ -231,9 +364,103 @@ public void Setup_IsHostedInTrayAndUsesSelfRestartAfterCompletion() Assert.Contains("? \"openclaw://chat\" : null", source); Assert.Contains("WaitForRestartSourceIfRequested(Environment.GetCommandLineArgs())", source); AssertInOrder(source, "WaitForRestartSourceIfRequested(Environment.GetCommandLineArgs())", "_mutex = new Mutex"); + Assert.DoesNotContain("setupWindow.TryNavigateToWizard()", source); Assert.DoesNotContain("ResolveSetupEngineUiPath", source); Assert.DoesNotContain("OpenClaw.SetupEngine.UI.exe", source); Assert.DoesNotContain("Process.GetProcessesByName(\"OpenClaw.SetupEngine.UI\")", source); + Assert.False(File.Exists(Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Windows", "SetupWizardWindow.cs"))); + } + + [Fact] + public void GatewayInstalledMilestone_ShowsInlineStatusIfWizardCannotStart() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var xaml = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "ProgressPage.xaml")); + var code = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "ProgressPage.xaml.cs")); + var onBoard = ExtractMethod(code, "Onboard_Click"); + + Assert.Contains("x:Name=\"MilestoneStatusText\"", xaml); + Assert.Contains("SetupWindow.Active?.TryNavigateToWizard() == true", onBoard); + Assert.Contains("AutomationProperties.LiveSetting=\"Assertive\"", xaml); + Assert.Contains("MilestoneStatusText.Text", onBoard); + Assert.DoesNotContain("NavigateToWizard();", onBoard); + } + + [Fact] + public void SetupCompletion_PersistsStartupChoiceBeforeRestart() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "SetupWindow.xaml.cs")); + var method = ExtractMethod(source, "RequestSetupCompleted"); + + Assert.Contains("if (_persistStartupPreferenceOnComplete)", method); + Assert.Contains("_config.Settings.AutoStart = enableAutoStart", method); + Assert.Contains("TraySettingsConfig.UpdateAutoStartInSettingsFile", method); + AssertInOrder( + method, + "if (_persistStartupPreferenceOnComplete)", + "_config.Settings.AutoStart = enableAutoStart", + "TraySettingsConfig.UpdateAutoStartInSettingsFile", + "handler.Invoke"); + } + + [Fact] + public void CompletePage_UsesCompletionArgsForStartupPreference() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var setupWindow = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "SetupWindow.xaml.cs")); + var complete = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CompletePage.xaml.cs")); + var navigate = ExtractMethod(setupWindow, "NavigateToComplete"); + + Assert.Contains("DefaultAutoStart: true", navigate); + Assert.Contains("ShowStartupPreference: _showStartupPreferenceOnComplete", navigate); + Assert.Contains("StartupToggle.IsOn = args.DefaultAutoStart", complete); + Assert.Contains("StartupRow.Visibility = args.ShowStartupPreference ? Visibility.Visible : Visibility.Collapsed", complete); + Assert.Contains("StartupRow.Visibility == Visibility.Visible && StartupToggle.IsOn", complete); + Assert.DoesNotContain("StartupToggle.IsOn = true", complete); + } + + [Fact] + public void CapabilitiesPage_PersistsSelectedProfileIntoRuntimeNodeSettings() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); + var method = ExtractMethod(source, "WriteCapabilities"); + + Assert.Contains("config.Settings.ApplyCapabilities(caps)", method); + AssertInOrder( + method, + "prop?.SetValue(caps, toggle.IsOn)", + "config.Settings.ApplyCapabilities(caps)"); + Assert.Contains("_config.UsesBundledDefaultConfig", source); + Assert.Contains("!(_config?.UsesBundledDefaultConfig ?? false)", source); + } + + [Fact] + public void CapabilitiesPage_PermissionProbeFaultsShowInlineWarning() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "CapabilitiesPage.xaml.cs")); + var click = ExtractMethod(source, "PrimaryClickAsync"); + var build = ExtractMethod(source, "BuildPermissionRows"); + + Assert.Contains("!permissionsTask.IsCompletedSuccessfully", click); + Assert.Contains("catch (Exception ex)", build); + Assert.Contains("new InfoBar", build); + Assert.Contains("Couldn't read Windows permission status", build); + Assert.Contains("Review permissions later in Settings", build); + } + + [Fact] + public void WizardSecondaryButton_DoesNotSkipEntireWizardInErrorState() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "WizardPage.xaml.cs")); + var method = ExtractMethod(source, "SecondaryClickAsync"); + + Assert.DoesNotContain("_errorState", method); + Assert.DoesNotContain("SkipWizardAsync", method); + Assert.Contains("SendCurrentAnswerAsync(skip: true)", method); } [Fact] @@ -316,7 +543,7 @@ public void SetupUiImages_UseLibraryQualifiedAssetUris() .OrderBy(Path.GetFileName) .Select(File.ReadAllText)); - Assert.Contains("ms-appx:///OpenClaw.SetupEngine.UI/Assets/Setup/Lobster.png", xaml); + Assert.Contains("ms-appx:///OpenClaw.SetupEngine.UI/Assets/Setup/OpenClawMascot.png", xaml); Assert.DoesNotContain("ms-appx:///Assets/Setup/", xaml); } @@ -327,22 +554,38 @@ public void SetupWelcomePage_RunsExistingConfigDetectionOffUiThread() var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "WelcomePage.xaml.cs")); var method = ExtractMethod(source, "StartButtonClickAsync"); - Assert.Contains("StartButton.IsEnabled = false", method); + Assert.Contains("InstallButton.IsEnabled = false", method); + Assert.Contains("InstallTitle.Text = CheckingButtonText", method); Assert.Contains("CheckingButtonText", method); Assert.Contains("var setupWindow = SetupWindow.Active", method); Assert.Contains("await Task.Run(() => ExistingConfigDetector.Detect", method); Assert.Contains("setupWindow is null or { IsClosed: true } || xamlRoot is null", method); Assert.Contains("setupWindow is { IsClosed: false }", method); - Assert.Contains("StartButton.IsEnabled = true", method); + Assert.Contains("InstallTitle.Text = InstallButtonText", method); + Assert.Contains("InstallButton.IsEnabled = true", method); AssertInOrder( method, - "StartButton.IsEnabled = false", + "InstallButton.IsEnabled = false", "await Task.Run(() => ExistingConfigDetector.Detect", "setupWindow is null or { IsClosed: true } || xamlRoot is null", "dialog.ShowAsync()", "setupWindow.NavigateToCapabilities()"); } + [Fact] + public void WizardErrorState_UsesMoreOptionsAndPreservesTranscriptOnGatewayRestart() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var source = File.ReadAllText(Path.Combine(root, "src", "OpenClaw.SetupEngine.UI", "Pages", "WizardPage.xaml.cs")); + var showError = ExtractMethod(source, "ShowError"); + var restart = ExtractMethod(source, "RestartGatewayAsync"); + + Assert.Contains("SecondaryButton.Visibility = Visibility.Collapsed", showError); + Assert.Contains("ShowRecoveryActions()", showError); + Assert.DoesNotContain("SecondaryButton.Content = \"Skip wizard\"", showError); + Assert.Contains("StartWizardAsync(clearTranscript: false)", restart); + } + [Fact] public void TrayIcon_UpdateDelegatesToCoordinator() { diff --git a/tests/OpenClaw.Tray.Tests/ConnectionPagePlanApprovalBehaviorTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionPagePlanApprovalBehaviorTests.cs index dc22d7340..0154d1cdb 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionPagePlanApprovalBehaviorTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionPagePlanApprovalBehaviorTests.cs @@ -230,6 +230,46 @@ public void NodeError_RemainsErrorDespiteStalePendingReapproval() AssertTrustDoesNotOverride(plan, NodeCardState.OnNodeError); } + [Fact] + public void IntendedNodeIdle_ProjectsAsDegradedNodeError_NotHealthy() + { + var plan = Build( + new GatewayConnectionSnapshot + { + OverallState = OverallConnectionState.Degraded, + OperatorState = RoleConnectionState.Connected, + NodeConnectionIntended = true, + NodeState = RoleConnectionState.Idle + }, + localNode: null); + + Assert.Equal(ConnectionPageMode.Cockpit, plan.Mode); + Assert.Equal(ConnectionAccent.Caution, plan.StripAccent); + Assert.Equal("Connection degraded", plan.StripHeadline); + Assert.Contains("node has not connected", plan.StripSub, StringComparison.OrdinalIgnoreCase); + Assert.Equal(NodeCardState.OnNodeError, plan.NodeCard); + } + + [Fact] + public void MissingNodeCredential_ProjectsAsBlockedNode_NotHealthy() + { + var plan = Build( + new GatewayConnectionSnapshot + { + OverallState = OverallConnectionState.Degraded, + OperatorState = RoleConnectionState.Connected, + NodeConnectionIntended = true, + NodeState = RoleConnectionState.Error, + NodeError = "No node credential available. Re-pair this PC." + }, + localNode: null); + + Assert.Equal(ConnectionPageMode.Cockpit, plan.Mode); + Assert.Equal(ConnectionAccent.Caution, plan.StripAccent); + Assert.Equal(NodeCardState.OnNodeError, plan.NodeCard); + Assert.Equal("No node credential available. Re-pair this PC.", plan.NodeErrorDetail); + } + private ConnectionPagePlan Build( PairingApprovalKind pairingApprovalKind, GatewayNodeInfo? localNode, diff --git a/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs b/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs index 17ed08c2e..4a2b106c8 100644 --- a/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConnectionStatusPresenterTests.cs @@ -1,4 +1,5 @@ using OpenClaw.Connection; +using OpenClaw.Shared; using OpenClawTray.Services; using System.Xml.Linq; using Xunit; @@ -30,6 +31,84 @@ public void Pill_ReadyAndConnected_BothReadConnected() ConnectionStatusPresenter.Pill(OverallConnectionState.Ready)); } + [Theory] + [InlineData(OverallConnectionState.Connected, ConnectionStatus.Connected)] + [InlineData(OverallConnectionState.Ready, ConnectionStatus.Connected)] + [InlineData(OverallConnectionState.Connecting, ConnectionStatus.Connecting)] + [InlineData(OverallConnectionState.Degraded, ConnectionStatus.Connected)] + [InlineData(OverallConnectionState.PairingRequired, ConnectionStatus.Error)] + [InlineData(OverallConnectionState.Error, ConnectionStatus.Error)] + [InlineData(OverallConnectionState.Idle, ConnectionStatus.Disconnected)] + public void ToLegacyStatus_PreservesOperatorLiveCompatibility( + OverallConnectionState overall, + ConnectionStatus expected) + { + Assert.Equal(expected, ConnectionStatusPresenter.ToLegacyStatus(overall)); + } + + [Fact] + public void PlainText_UsesDistinctBlockedLabels() + { + Assert.Equal("Degraded", ConnectionStatusPresenter.PlainText(OverallConnectionState.Degraded, ConnectionStatus.Connected)); + Assert.Equal("Pairing required", ConnectionStatusPresenter.PlainText(OverallConnectionState.PairingRequired, ConnectionStatus.Connecting)); + } + + [Theory] + [InlineData(OverallConnectionState.Connected, true)] + [InlineData(OverallConnectionState.Ready, true)] + [InlineData(OverallConnectionState.Degraded, true)] + [InlineData(OverallConnectionState.PairingRequired, true)] + [InlineData(OverallConnectionState.Connecting, true)] + [InlineData(OverallConnectionState.Error, false)] + [InlineData(OverallConnectionState.Idle, false)] + public void IsLiveOrPending_IncludesBlockedLiveStates(OverallConnectionState overall, bool expected) + { + Assert.Equal(expected, ConnectionStatusPresenter.IsLiveOrPending(overall, ConnectionStatus.Disconnected)); + } + + [Fact] + public void ToLegacyStatus_SnapshotKeepsOperatorChannelLiveDuringNodeDegraded() + { + var snapshot = new GatewayConnectionSnapshot + { + OverallState = OverallConnectionState.Degraded, + OperatorState = RoleConnectionState.Connected, + NodeConnectionIntended = true, + NodeState = RoleConnectionState.Error + }; + + Assert.Equal(ConnectionStatus.Connected, ConnectionStatusPresenter.ToLegacyStatus(snapshot)); + Assert.True(ConnectionStatusPresenter.IsOperatorChannelLive(snapshot)); + } + + [Fact] + public void ToLegacyStatus_SnapshotKeepsOperatorChannelLiveDuringNodePairing() + { + var snapshot = new GatewayConnectionSnapshot + { + OverallState = OverallConnectionState.PairingRequired, + OperatorState = RoleConnectionState.Connected, + NodeConnectionIntended = true, + NodeState = RoleConnectionState.PairingRequired + }; + + Assert.Equal(ConnectionStatus.Connected, ConnectionStatusPresenter.ToLegacyStatus(snapshot)); + } + + [Fact] + public void ToLegacyStatus_SnapshotKeepsOperatorPairingBlocked() + { + var snapshot = new GatewayConnectionSnapshot + { + OverallState = OverallConnectionState.PairingRequired, + OperatorState = RoleConnectionState.PairingRequired, + NodeState = RoleConnectionState.Idle + }; + + Assert.Equal(ConnectionStatus.Error, ConnectionStatusPresenter.ToLegacyStatus(snapshot)); + Assert.False(ConnectionStatusPresenter.IsOperatorChannelLive(snapshot)); + } + [Fact] public void NodeRow_NodeModeDisabled_ReadsDisabled_EvenWhenTransportConnected() { diff --git a/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs b/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs index 1abe6014c..6de4ad5c8 100644 --- a/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs +++ b/tests/OpenClaw.Tray.Tests/NodeModeUiStateTests.cs @@ -162,6 +162,18 @@ public void PermissionsPage_McpToggleRefreshesNodeStatus() Assert.Contains("UpdateNodeStatus()", toggle); } + [Fact] + public void NodeService_ExposesMcpStartupFailures() + { + var service = ReadSource("src", "OpenClaw.Tray.WinUI", "Services", "NodeService.cs"); + + Assert.Contains("public string? McpStartupError", service); + Assert.Contains("public void SetMcpStartupError", service); + Assert.Contains("SetMcpStartupFailure(ex, \"capability registration\")", service); + Assert.Contains("return false;", ExtractMethodBody(service, "bool StartMcpServer")); + Assert.Contains("MCP server startup failed: listener did not start.", service); + } + [Fact] public void NewNodeStateStrings_ExistInEnUsResources() { diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 4bc8b4b3a..11e2caba0 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -74,6 +74,7 @@ + diff --git a/tests/OpenClaw.Tray.Tests/Services/McpRuntimeStatePolicyTests.cs b/tests/OpenClaw.Tray.Tests/Services/McpRuntimeStatePolicyTests.cs new file mode 100644 index 000000000..9c87ea97c --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/Services/McpRuntimeStatePolicyTests.cs @@ -0,0 +1,80 @@ +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests.Services; + +public sealed class McpRuntimeStatePolicyTests +{ + [Fact] + public void PlanStartupNotification_WhenDisabled_Dismisses() + { + var plan = McpRuntimeStatePolicy.PlanStartupNotification( + enableMcpServer: false, + isMcpRunning: false, + startupError: "port busy"); + + Assert.False(plan.ShouldShow); + Assert.True(plan.ShouldDismiss); + Assert.Null(plan.Message); + } + + [Fact] + public void PlanStartupNotification_WhenEnabledAndHealthy_Dismisses() + { + var plan = McpRuntimeStatePolicy.PlanStartupNotification( + enableMcpServer: true, + isMcpRunning: true, + startupError: null); + + Assert.False(plan.ShouldShow); + Assert.True(plan.ShouldDismiss); + } + + [Fact] + public void PlanStartupNotification_WhenEnabledWithStartupError_ShowsError() + { + var plan = McpRuntimeStatePolicy.PlanStartupNotification( + enableMcpServer: true, + isMcpRunning: false, + startupError: "Port 8765 is already in use."); + + Assert.True(plan.ShouldShow); + Assert.False(plan.ShouldDismiss); + Assert.Equal("Port 8765 is already in use.", plan.Message); + } + + [Fact] + public void GetSettingsSetError_WhenEnablingMcpFails_ReturnsStartupError() + { + var error = McpRuntimeStatePolicy.GetSettingsSetError( + nameof(SettingsManager.EnableMcpServer), + true, + isMcpRunning: false, + startupError: "Access denied."); + + Assert.Equal("Access denied.", error); + } + + [Fact] + public void GetSettingsSetError_WhenEnablingMcpDoesNotStart_ReturnsDefaultError() + { + var error = McpRuntimeStatePolicy.GetSettingsSetError( + nameof(SettingsManager.EnableMcpServer), + true, + isMcpRunning: false, + startupError: null); + + Assert.Equal(McpRuntimeStatePolicy.DefaultStartupError, error); + } + + [Fact] + public void GetSettingsSetError_WhenDisablingMcp_ReturnsNull() + { + var error = McpRuntimeStatePolicy.GetSettingsSetError( + nameof(SettingsManager.EnableMcpServer), + false, + isMcpRunning: false, + startupError: "old error"); + + Assert.Null(error); + } +} diff --git a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs index 55ff92744..6926c4a54 100644 --- a/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/Services/TrayDashboardSummaryBuilderTests.cs @@ -1,4 +1,5 @@ using OpenClaw.Shared; +using OpenClaw.Connection; using OpenClawTray.Services; using System; using Xunit; @@ -18,9 +19,14 @@ private static TrayMenuSnapshot Base( GatewayUsageInfo? usage = null, DateTime? lastUpdated = null, PairingListInfo? nodePairList = null, - DevicePairingListInfo? devicePairList = null) => new() + DevicePairingListInfo? devicePairList = null, + OverallConnectionState? overallState = null, + bool isMcpRunning = false, + string? mcpStartupError = null, + SettingsManager? settings = null) => new() { CurrentStatus = status, + OverallState = overallState, AuthFailureMessage = authFailure, GatewayUrl = gatewayUrl, GatewaySelf = null, @@ -36,10 +42,12 @@ private static TrayMenuSnapshot Base( Usage = usage, UsageStatus = null, UsageCost = null, - Settings = null, + Settings = settings, SetupMenuLabel = "Reconfigure...", ShowSetupMenuEntry = true, LastUpdated = lastUpdated, + IsMcpRunning = isMcpRunning, + McpStartupError = mcpStartupError, }; private static TrayDashboardSummary Build(TrayMenuSnapshot snapshot) => @@ -84,6 +92,136 @@ public void Error_IsCriticalSeverity() Assert.Equal("Connection error", summary.Headline); } + [Fact] + public void DegradedOverall_IsCautionAndNotConnected() + { + var summary = Build(Base( + ConnectionStatus.Error, + overallState: OverallConnectionState.Degraded)); + + Assert.Equal(TrayHealthSeverity.Caution, summary.Severity); + Assert.Equal("Connection degraded", summary.Headline); + } + + [Fact] + public void PairingRequiredOverall_IsDistinctFromConnecting() + { + var summary = Build(Base( + ConnectionStatus.Error, + overallState: OverallConnectionState.PairingRequired)); + + Assert.Equal(TrayHealthSeverity.Caution, summary.Severity); + Assert.Equal("Pairing required", summary.Headline); + } + + [Fact] + public void LocalMcpOnly_IsExplicitNotGatewayConnected() + { + var settingsDirectory = Path.Combine(Path.GetTempPath(), "openclaw-dashboard-mcp-" + Guid.NewGuid().ToString("N")); + try + { + var settings = new SettingsManager(settingsDirectory) + { + EnableMcpServer = true, + EnableNodeMode = false + }; + + var summary = Build(Base( + ConnectionStatus.Disconnected, + isMcpRunning: true, + settings: settings)); + + Assert.Equal(TrayHealthSeverity.Ok, summary.Severity); + Assert.Equal("Local MCP only", summary.Headline); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(settingsDirectory, recursive: true); } catch { } + } + } + + [Fact] + public void LocalMcpOnly_DoesNotMaskDegradedGatewayLifecycle() + { + var settingsDirectory = Path.Combine(Path.GetTempPath(), "openclaw-dashboard-mcp-" + Guid.NewGuid().ToString("N")); + try + { + var settings = new SettingsManager(settingsDirectory) + { + EnableMcpServer = true, + EnableNodeMode = false + }; + + var summary = Build(Base( + ConnectionStatus.Error, + overallState: OverallConnectionState.Degraded, + isMcpRunning: true, + settings: settings)); + + Assert.Equal(TrayHealthSeverity.Caution, summary.Severity); + Assert.Equal("Connection degraded", summary.Headline); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(settingsDirectory, recursive: true); } catch { } + } + } + + [Fact] + public void McpStartupError_OutranksDisconnected() + { + var settingsDirectory = Path.Combine(Path.GetTempPath(), "openclaw-dashboard-mcp-" + Guid.NewGuid().ToString("N")); + try + { + var settings = new SettingsManager(settingsDirectory) + { + EnableMcpServer = true + }; + + var summary = Build(Base( + ConnectionStatus.Disconnected, + mcpStartupError: "Port 8765 is already in use.", + settings: settings)); + + Assert.Equal(TrayHealthSeverity.Critical, summary.Severity); + Assert.Equal("Local MCP failed", summary.Headline); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(settingsDirectory, recursive: true); } catch { } + } + } + + [Fact] + public void StaleMcpStartupError_IsIgnoredWhenMcpDisabled() + { + var settingsDirectory = Path.Combine(Path.GetTempPath(), "openclaw-dashboard-mcp-" + Guid.NewGuid().ToString("N")); + try + { + var settings = new SettingsManager(settingsDirectory) + { + EnableMcpServer = false + }; + + var summary = Build(Base( + ConnectionStatus.Connected, + overallState: OverallConnectionState.Ready, + mcpStartupError: "stale failure", + settings: settings)); + + Assert.Equal(TrayHealthSeverity.Ok, summary.Severity); + Assert.Equal("Connected", summary.Headline); + } + finally + { + // slopwatch-ignore: SW003 Test cleanup or fixture teardown is best-effort and must not hide the test outcome. + try { Directory.Delete(settingsDirectory, recursive: true); } catch { } + } + } + [Fact] public void AuthFailure_OverridesConnectedWithCriticalSeverity() { diff --git a/tests/OpenClaw.Tray.Tests/Services/TrayTooltipBuilderTests.cs b/tests/OpenClaw.Tray.Tests/Services/TrayTooltipBuilderTests.cs index b97b063d7..897a29ab9 100644 --- a/tests/OpenClaw.Tray.Tests/Services/TrayTooltipBuilderTests.cs +++ b/tests/OpenClaw.Tray.Tests/Services/TrayTooltipBuilderTests.cs @@ -1,4 +1,5 @@ using OpenClaw.Shared; +using OpenClaw.Connection; using OpenClawTray.Helpers; using OpenClawTray.Services; using System; @@ -97,6 +98,107 @@ public void Build_StatusNotConnected_CountsOneWarning() Assert.Contains("Warnings 1", result); } + [Fact] + public void Build_DegradedOverall_DoesNotReadConnected() + { + var snapshot = BaseConnected() with + { + OverallState = OverallConnectionState.Degraded + }; + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("OpenClaw Tray - Degraded", result); + Assert.Contains("Warnings 1", result); + } + + [Fact] + public void Build_LocalMcpOnly_IsExplicit() + { + var settings = new SettingsManager(_tempDir) + { + EnableMcpServer = true, + EnableNodeMode = false + }; + var snapshot = new TrayStateSnapshot + { + Status = ConnectionStatus.Disconnected, + Settings = settings, + IsMcpRunning = true, + LastCheckTime = FixedTime + }; + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("OpenClaw Tray - Local MCP only", result); + Assert.Contains("Warnings 1", result); + } + + [Fact] + public void Build_LocalMcpOnly_DoesNotMaskDegradedGatewayLifecycle() + { + var settings = new SettingsManager(_tempDir) + { + EnableMcpServer = true, + EnableNodeMode = false + }; + var snapshot = new TrayStateSnapshot + { + Status = ConnectionStatus.Error, + OverallState = OverallConnectionState.Degraded, + Settings = settings, + IsMcpRunning = true, + LastCheckTime = FixedTime + }; + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("OpenClaw Tray - Degraded", result); + Assert.DoesNotContain("Local MCP only", result); + } + + [Fact] + public void Build_McpStartupError_IsExplicit() + { + var settings = new SettingsManager(_tempDir) + { + EnableMcpServer = true + }; + var snapshot = new TrayStateSnapshot + { + Status = ConnectionStatus.Disconnected, + Settings = settings, + McpStartupError = "Port 8765 is already in use.", + LastCheckTime = FixedTime + }; + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("OpenClaw Tray - Local MCP failed", result); + Assert.Contains("Warnings 2", result); + } + + [Fact] + public void Build_StaleMcpStartupError_IsIgnoredWhenMcpDisabled() + { + var settings = new SettingsManager(_tempDir) + { + EnableMcpServer = false + }; + var snapshot = BaseConnected(authFailure: null) with + { + OverallState = OverallConnectionState.Ready, + Settings = settings, + McpStartupError = "stale failure" + }; + + var result = new TrayTooltipBuilder(snapshot).Build(); + + Assert.Contains("OpenClaw Tray - Connected", result); + Assert.Contains("Warnings 1", result); + Assert.DoesNotContain("Local MCP failed", result); + } + [Fact] public void Build_AuthFailureMessage_CountsOneWarning() { diff --git a/tests/OpenClaw.Tray.UITests/TestApp.cs b/tests/OpenClaw.Tray.UITests/TestApp.cs index a07a31fdb..af3789967 100644 --- a/tests/OpenClaw.Tray.UITests/TestApp.cs +++ b/tests/OpenClaw.Tray.UITests/TestApp.cs @@ -21,10 +21,9 @@ namespace OpenClaw.Tray.UITests; internal sealed class TestApp : Application { /// - /// Merge XamlControlsResources + the production App.xaml's custom keys - /// (LobsterAccentBrush, AccentButtonStyle) so renderers that look them up - /// resolve a real value. Call this ON THE UI THREAD after Application.Current - /// is set. + /// Merge XamlControlsResources + production App.xaml's custom keys so + /// renderers that look them up resolve a real value. Call this ON THE UI + /// THREAD after Application.Current is set. /// public void MergeStandardResources() { @@ -39,9 +38,6 @@ public void MergeStandardResources() // going — the renderers degrade gracefully without theme styles. } - TryAddResource("LobsterAccentBrush", - ""); - TryAddResource("AccentButtonStyle", "