diff --git a/AGENTS.md b/AGENTS.md index d61f5d6b9..d5203fc62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ If a command fails: Notes: - If a build/test is blocked by an environmental lock (for example running executable locking output assemblies), stop/close the locking process and rerun. +- If validation is blocked by missing local Windows prerequisites, run `.\scripts\setup-dev.ps1` to install/verify developer and agent prerequisites, then rerun validation. Use `.\scripts\setup-dev.ps1 -CheckOnly` when you only need diagnostics. - **First-run gotcha**: `dotnet test --no-restore` silently no-ops in a fresh worktree where the test `bin/` doesn't exist yet (reports "Build succeeded in 0.5s" then exits 0 with no tests run). For first-run validation, either omit `--no-restore` OR run `dotnet build` on the test project first. Subsequent reruns honor `--no-restore` correctly. - In linked git worktrees, set `OPENCLAW_REPO_ROOT` to the worktree path before running tests that discover the repository root, for example: - `$env:OPENCLAW_REPO_ROOT='D:\github\openclaw-windows-node.'` @@ -75,6 +76,17 @@ Start with these docs before changing connection, pairing, node, MCP, or tray UX - `docs/WINDOWS_NODE_TESTING.md` - Windows node capabilities, manual smokes, and gateway-dependent behavior. - `docs/ONBOARDING_WIZARD.md` - first-run setup flow, setup-code/bootstrap pairing, and test isolation. +## Architecture Guardrails for Large Refactors + +`src\OpenClaw.Tray.WinUI\App.xaml.cs` and `src\OpenClaw.Tray.WinUI\Pages\ConnectionPage.xaml.cs` are active god-file reduction targets. When touching either file: + +- Prefer completing a real ownership transfer over moving code to partial classes. A new partial file is not progress unless it introduces a narrower owner, pure projection, policy, service, or tested seam. +- Keep `App` as the composition root. Shrink it by delegating cohesive behavior to focused services, but do not relocate startup ordering into another god object. +- Keep `ConnectionPage.xaml.cs` as the WinUI applicator until a pure row/plan/workflow seam exists. Do not move named-control setters into a presenter that just wraps the page. +- Add characterization tests before moving startup, credential, pairing, node/MCP, tray action, or direct-connect rollback behavior. Source-text contract tests are acceptable for WinUI-only seams, but prefer pure unit tests for policies and projections. +- Keep PRs small and reviewable: one seam per PR, with a clear invariant protected by tests. Stop and re-plan if a PR moves hundreds of lines without behavior coverage. +- In PR descriptions and handoffs, name the old owner, new owner, preserved invariant, and validation run so future agents do not reintroduce duplicate paths or grow new god objects. + Important current facts: - Gateway credentials are no longer stored in `SettingsData.Token` / `SettingsData.BootstrapToken`. `SettingsManager` may read legacy JSON fields only for one-time migration; new writes must go through `GatewayRegistry`. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8c8b85056..714b20ab8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -18,9 +18,13 @@ A comprehensive guide for building, running, and contributing to the OpenClaw Wi - **.NET 10 SDK** - [Download here](https://dotnet.microsoft.com/download) - **Windows 10/11** - WinUI 3 and Windows App SDK require Windows 10 version 1903 or later +- **Node.js LTS with npm** - Required by the WinUI build to restore JavaScript build assets +- **Windows 10 SDK** - Required for WinUI builds - **WebView2 Runtime** - Usually pre-installed on Windows 10+ ([Manual download](https://developer.microsoft.com/microsoft-edge/webview2/)) - **Visual Studio 2022** (optional) - For easier development and debugging with WinUI 3 designer support +Run `.\scripts\setup-dev.ps1` from the repository root to install or verify local prerequisites with winget. Agents can use `.\scripts\setup-dev.ps1 -RunValidation` to prepare the worktree and run the required closeout validation. + ### For Testing - **A running OpenClaw gateway instance** - The gateway provides the backend for chat, sessions, and notifications when validating gateway-mediated flows diff --git a/README.md b/README.md index a2d4870d7..1f8b66fe9 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,25 @@ Direct downloads from the latest OpenClaw release: ### Prerequisites - Windows 10 (20H2+) or Windows 11 - .NET 10.0 SDK - https://dotnet.microsoft.com/download/dotnet/10.0 +- Node.js LTS with npm (for WinUI build assets) - Windows 10 SDK (for WinUI build) - install via Visual Studio or standalone - WebView2 Runtime - pre-installed on modern Windows, or get from https://developer.microsoft.com/microsoft-edge/webview2 +### Developer / Agent Setup + +Use the setup script to install or verify local Windows build prerequisites: + +```powershell +# Install missing prerequisites with winget, trust the checkout, and verify setup +.\scripts\setup-dev.ps1 + +# Check only; do not install packages or change git safe.directory +.\scripts\setup-dev.ps1 -CheckOnly + +# Setup and run the required build/test validation +.\scripts\setup-dev.ps1 -RunValidation +``` + ### Build Use the build script to check prerequisites and build: diff --git a/build.ps1 b/build.ps1 index 55cf59c6e..77717b971 100644 --- a/build.ps1 +++ b/build.ps1 @@ -228,11 +228,23 @@ if (-not $nodeVersion) { # Check Windows SDK (for WinUI) $windowsSdkPath = "${env:ProgramFiles(x86)}\Windows Kits\10\Include" if (Test-Path $windowsSdkPath) { - $sdkVersions = Get-ChildItem $windowsSdkPath -Directory | Select-Object -ExpandProperty Name | Sort-Object -Descending - Write-Success "Windows SDK: $($sdkVersions[0])" + $sdkVersions = @( + Get-ChildItem $windowsSdkPath -Directory | + Where-Object { $_.Name -match "^\d+\.\d+\.\d+\.\d+$" } | + Sort-Object { [version]$_.Name } -Descending | + Select-Object -ExpandProperty Name + ) + + if ($sdkVersions.Count -gt 0) { + Write-Success "Windows SDK: $($sdkVersions[0])" + } else { + Write-Warning "Windows 10 SDK not found (needed for WinUI build)" + Write-Info "Install via Visual Studio Installer, standalone SDK, or: winget install --id Microsoft.WindowsSDK.10.0.26100 -e" + $issues += "Windows 10 SDK not detected" + } } else { Write-Warning "Windows 10 SDK not found (needed for WinUI build)" - Write-Info "Install via Visual Studio Installer or standalone SDK" + Write-Info "Install via Visual Studio Installer, standalone SDK, or: winget install --id Microsoft.WindowsSDK.10.0.26100 -e" $issues += "Windows 10 SDK not detected" } diff --git a/scripts/setup-dev.ps1 b/scripts/setup-dev.ps1 new file mode 100644 index 000000000..ab6dd45cf --- /dev/null +++ b/scripts/setup-dev.ps1 @@ -0,0 +1,274 @@ +<# +.SYNOPSIS + Prepares a Windows checkout for OpenClaw developer and agent work. + +.DESCRIPTION + Installs missing local prerequisites with winget, refreshes the current + process PATH, trusts the checkout for GitVersion, and runs the repository + prerequisite check. Use -CheckOnly to report what is missing without + installing anything. + +.PARAMETER CheckOnly + Check prerequisites without installing or changing git safe.directory. + +.PARAMETER RunValidation + After setup, run the full build plus the required shared and tray test + projects used by AGENTS.md closeout validation. + +.PARAMETER NoTrustRepository + Do not add this checkout to git safe.directory. + +.EXAMPLE + .\scripts\setup-dev.ps1 + .\scripts\setup-dev.ps1 -CheckOnly + .\scripts\setup-dev.ps1 -RunValidation +#> + +param( + [switch]$CheckOnly, + [switch]$RunValidation, + [switch]$NoTrustRepository +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +Set-Location $repoRoot + +function Write-Header($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan } +function Write-Success($text) { Write-Host "[OK] $text" -ForegroundColor Green } +function Write-WarningMessage($text) { Write-Host "[WARN] $text" -ForegroundColor Yellow } +function Write-ErrorMessage($text) { Write-Host "[ERROR] $text" -ForegroundColor Red } +function Write-Info($text) { Write-Host " $text" -ForegroundColor Gray } + +function Test-WindowsHost { + $isWindowsVariable = Get-Variable -Name IsWindows -ErrorAction SilentlyContinue + if ($isWindowsVariable) { + return [bool]$isWindowsVariable.Value + } + + return [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT +} + +function Update-ProcessPath { + $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $env:Path = @($machinePath, $userPath) -join ";" +} + +function Test-CommandAvailable($name) { + return $null -ne (Get-Command $name -ErrorAction SilentlyContinue) +} + +function Test-DotNet10Sdk { + if (-not (Test-CommandAvailable "dotnet")) { + return $false + } + + $sdks = & dotnet --list-sdks 2>$null + return $LASTEXITCODE -eq 0 -and ($sdks | Where-Object { $_ -match "^10\." }) +} + +function Test-NodeAndNpm { + return (Test-CommandAvailable "node") -and (Test-CommandAvailable "npm") +} + +function Get-WindowsSdkVersion { + $windowsSdkPath = "${env:ProgramFiles(x86)}\Windows Kits\10\Include" + if (-not (Test-Path $windowsSdkPath)) { + return $null + } + + $versions = @( + Get-ChildItem $windowsSdkPath -Directory | + Where-Object { $_.Name -match "^\d+\.\d+\.\d+\.\d+$" } | + Sort-Object { [version]$_.Name } -Descending | + Select-Object -ExpandProperty Name + ) + + if ($versions.Count -eq 0) { + return $null + } + + return $versions[0] +} + +function Get-WebView2RuntimeVersion { + $keys = @( + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}", + "HKCU:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" + ) + + foreach ($key in $keys) { + if (Test-Path $key) { + $version = (Get-ItemProperty $key -ErrorAction SilentlyContinue).pv + if ($version) { + return $version + } + } + } + + return $null +} + +function Install-WingetPackage($id, $displayName) { + if ($CheckOnly) { + Write-WarningMessage "$displayName is missing." + Write-Info "Install with: winget install --id $id -e" + return + } + + if (-not (Test-CommandAvailable "winget")) { + throw "winget is not available. Install App Installer from the Microsoft Store, then rerun this script." + } + + Write-Header "Installing $displayName" + $arguments = @( + "install", + "--id", $id, + "-e", + "--accept-source-agreements", + "--accept-package-agreements", + "--disable-interactivity" + ) + & winget @arguments + if ($LASTEXITCODE -ne 0) { + throw "winget failed to install $displayName ($id)." + } + + Update-ProcessPath +} + +function ConvertTo-GitSafeDirectoryPath($path) { + return ([System.IO.Path]::GetFullPath($path).TrimEnd("\") -replace "\\", "/") +} + +function Test-GitSafeDirectoryContains($path) { + if (-not (Test-CommandAvailable "git")) { + return $false + } + + $expected = (ConvertTo-GitSafeDirectoryPath $path).ToLowerInvariant() + $safeDirectories = & git config --global --get-all safe.directory 2>$null + if ($LASTEXITCODE -ne 0 -or -not $safeDirectories) { + return $false + } + + foreach ($safeDirectory in $safeDirectories) { + if ($safeDirectory -eq "*") { + return $true + } + + $normalized = ($safeDirectory.Trim().TrimEnd("\", "/") -replace "\\", "/").ToLowerInvariant() + if ($normalized -eq $expected) { + return $true + } + } + + return $false +} + +function Ensure-RepositoryTrust { + if ($NoTrustRepository -or $CheckOnly -or -not (Test-CommandAvailable "git")) { + return + } + + if (-not (Test-Path (Join-Path $repoRoot ".git"))) { + return + } + + if (Test-GitSafeDirectoryContains $repoRoot) { + Write-Success "Repository already trusted for GitVersion." + return + } + + $safeDirectory = ConvertTo-GitSafeDirectoryPath $repoRoot + Write-Info "Adding git safe.directory entry: $safeDirectory" + & git config --global --add safe.directory $safeDirectory + if ($LASTEXITCODE -ne 0) { + throw "Failed to add git safe.directory entry for $safeDirectory." + } + Write-Success "Repository trusted for GitVersion." +} + +function Require-Prerequisite($name, $isAvailable, $packageId) { + if ($isAvailable) { + Write-Success "$name detected." + return + } + + Install-WingetPackage $packageId $name +} + +if (-not (Test-WindowsHost)) { + throw "OpenClaw Windows development requires Windows." +} + +Write-Header "OpenClaw developer setup" +if ($CheckOnly) { + Write-Info "CheckOnly mode: no packages will be installed and git safe.directory will not be changed." +} + +Update-ProcessPath + +Require-Prerequisite "Git" (Test-CommandAvailable "git") "Git.Git" +Require-Prerequisite ".NET 10 SDK" (Test-DotNet10Sdk) "Microsoft.DotNet.SDK.10" +Require-Prerequisite "Node.js LTS with npm" (Test-NodeAndNpm) "OpenJS.NodeJS.LTS" +Require-Prerequisite "Windows SDK 10.0.26100" ([bool](Get-WindowsSdkVersion)) "Microsoft.WindowsSDK.10.0.26100" + +$webView2Version = Get-WebView2RuntimeVersion +if ($webView2Version) { + Write-Success "WebView2 Runtime detected ($webView2Version)." +} else { + Install-WingetPackage "Microsoft.EdgeWebView2Runtime" "WebView2 Runtime" +} + +Update-ProcessPath +Ensure-RepositoryTrust + +$missing = @() +if (-not (Test-CommandAvailable "git")) { $missing += "Git" } +if (-not (Test-DotNet10Sdk)) { $missing += ".NET 10 SDK" } +if (-not (Test-NodeAndNpm)) { $missing += "Node.js LTS with npm" } +if (-not (Get-WindowsSdkVersion)) { $missing += "Windows SDK 10.0.26100" } +if (-not (Get-WebView2RuntimeVersion)) { $missing += "WebView2 Runtime" } + +if ($missing.Count -gt 0) { + Write-ErrorMessage "Setup is incomplete:" + foreach ($item in $missing) { + Write-Info "- $item" + } + + if (-not $CheckOnly) { + Write-Info "If packages were just installed, open a new terminal and rerun .\scripts\setup-dev.ps1 -CheckOnly." + } + exit 1 +} + +Write-Header "Repository prerequisite check" +& "$repoRoot\build.ps1" -CheckOnly +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +if ($RunValidation) { + Write-Header "Required validation" + $env:OPENCLAW_REPO_ROOT = $repoRoot + + & "$repoRoot\build.ps1" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + dotnet build "$repoRoot\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + dotnet build "$repoRoot\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + dotnet test "$repoRoot\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj" --no-restore + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + dotnet test "$repoRoot\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj" --no-restore + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +Write-Success "OpenClaw developer setup is ready." diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index d623c5dda..4126d4def 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -1952,238 +1952,6 @@ private bool IsGatewayNodeEnabled() return _settings?.EnableNodeMode == true; } - /// - /// Ensures a WSL keepalive process is running for the local gateway distro - /// so the WSL2 VM stays up even after the tray exits. - /// Best-effort, fire-and-forget. - /// - private async Task TryEnsureLocalGatewayKeepAliveAsync() - { - try - { - if (_settings is null) return; - - var activeRecord = _gatewayRegistry?.GetActive(); - if (!WslKeepAlivePolicy.ShouldStart(activeRecord, _settings.GetEffectiveGatewayUrl())) - { - await StopStaleLocalGatewayKeepAliveAsync(); - return; - } - - var distroName = await ResolveLocalGatewayDistroNameAsync(activeRecord); - if (string.IsNullOrWhiteSpace(distroName)) return; - - // Verify distro exists before spawning keepalive - var runner = new WslExeCommandRunner(new AppLogger(), defaultTimeout: TimeSpan.FromSeconds(4)); - var distros = await runner.ListDistrosAsync(); - if (!distros.Any(d => string.Equals(d.Name, distroName, StringComparison.OrdinalIgnoreCase))) - { - Logger.Warn($"[WslKeepAlive] Distro '{distroName}' not found; skipping keepalive."); - return; - } - - // Spawn a detached wsl sleep process to keep the VM alive - var psi = new System.Diagnostics.ProcessStartInfo - { - FileName = ResolveWslExePath(), - UseShellExecute = false, - CreateNoWindow = true, - }; - psi.ArgumentList.Add("-d"); - psi.ArgumentList.Add(distroName); - psi.ArgumentList.Add("--"); - psi.ArgumentList.Add("sleep"); - psi.ArgumentList.Add("infinity"); - - var proc = System.Diagnostics.Process.Start(psi); - if (proc is not null) - { - Logger.Info($"[WslKeepAlive] Started keepalive for {distroName} (PID {proc.Id})."); - } - } - catch (Exception ex) - { - Logger.Warn($"[WslKeepAlive] Startup keepalive failed (non-fatal): {ex.Message}"); - } - } - - private async Task StopStaleLocalGatewayKeepAliveAsync() - { - try - { - var localDataDir = SetupExistingGatewayClassifier.ResolveLocalDataPath(); - var markerDir = Path.Combine(localDataDir, "wsl-keepalive"); - var markerDistroNames = ReadKeepAliveMarkerDistroNames(markerDir); - var setupStateDistroName = await ReadSetupStateDistroNameAsync(localDataDir); - var records = _gatewayRegistry?.GetAll() ?? []; - - foreach (var distroName in WslKeepAlivePolicy.FindStaleSetupManagedDistroNames( - records, - markerDistroNames, - setupStateDistroName)) - { - StopKeepAliveProcessesForDistro(distroName); - DeleteKeepAliveMarker(markerDir, distroName); - } - } - catch (Exception ex) - { - Logger.Warn($"[WslKeepAlive] Stale keepalive cleanup failed (non-fatal): {ex.Message}"); - } - } - - private static IReadOnlyList ReadKeepAliveMarkerDistroNames(string markerDir) - { - if (!Directory.Exists(markerDir)) - return []; - - var distroNames = new List(); - foreach (var markerPath in Directory.EnumerateFiles(markerDir, "*.json")) - { - if (WslKeepAlivePolicy.TryGetMarkerDistroName(File.ReadAllText(markerPath), out var distroName)) - distroNames.Add(distroName); - } - - return distroNames; - } - - private static async Task ReadSetupStateDistroNameAsync(string localDataDir) - { - var stateFile = Path.Combine(localDataDir, "setup-state.json"); - if (!File.Exists(stateFile)) - return null; - - var json = await File.ReadAllTextAsync(stateFile); - using var doc = System.Text.Json.JsonDocument.Parse(json); - return doc.RootElement.TryGetProperty("DistroName", out var distroElement) - ? distroElement.GetString() - : null; - } - - private static void StopKeepAliveProcessesForDistro(string distroName) - { - var procs = System.Diagnostics.Process.GetProcessesByName("wsl") - .Concat(System.Diagnostics.Process.GetProcessesByName("wsl.exe")); - - foreach (var proc in procs) - { - try - { - if (WslKeepAlivePolicy.IsKeepaliveCommandLine(GetProcessCommandLine(proc.Id), distroName)) - { - proc.Kill(entireProcessTree: true); - proc.WaitForExit(5000); - Logger.Info($"[WslKeepAlive] Stopped stale keepalive for {distroName} (PID {proc.Id})."); - } - } - catch (Exception ex) - { - // Process may have exited while being inspected — common race; log at Debug. - Logger.Debug($"[WslKeepAlive] Inspect/stop race for PID {proc.Id}: {ex.GetType().Name}: {ex.Message}"); - } - finally - { - proc.Dispose(); - } - } - } - - private static void DeleteKeepAliveMarker(string markerDir, string distroName) - { - if (!Directory.Exists(markerDir)) - return; - - foreach (var markerPath in Directory.EnumerateFiles(markerDir, "*.json")) - { - try - { - if (WslKeepAlivePolicy.TryGetMarkerDistroName(File.ReadAllText(markerPath), out var markerDistro) - && string.Equals(markerDistro, distroName, StringComparison.OrdinalIgnoreCase)) - { - File.Delete(markerPath); - Logger.Info($"[WslKeepAlive] Deleted stale keepalive marker for {distroName}."); - } - } - catch (Exception ex) - { - // Best-effort cleanup; stale/corrupt markers are not fatal. Log at Debug for diagnostics. - Logger.Debug($"[WslKeepAlive] Failed to process marker '{markerPath}': {ex.GetType().Name}: {ex.Message}"); - } - } - } - - private static string? GetProcessCommandLine(int pid) - { - try - { - var psi = new System.Diagnostics.ProcessStartInfo("powershell.exe", - $"-NoProfile -Command \"(Get-CimInstance Win32_Process -Filter 'ProcessId={pid}').CommandLine\"") - { - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true - }; - using var p = System.Diagnostics.Process.Start(psi); - if (p == null) return null; - var output = p.StandardOutput.ReadToEnd(); - p.WaitForExit(5000); - return output.Trim(); - } - catch (Exception ex) - { - Logger.Debug($"App: GetProcessCommandLine(pid={pid}) failed: {ex.GetType().Name}: {ex.Message}"); - return null; - } - } - - private static string ResolveWslExePath() - { - var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows); - if (string.IsNullOrWhiteSpace(windowsDir)) - windowsDir = Environment.GetEnvironmentVariable("SystemRoot") ?? @"C:\Windows"; - - return Path.Combine(windowsDir, "System32", "wsl.exe"); - } - - /// - /// Resolves the WSL distro name to keep alive. Prefers the value persisted by - /// onboarding in setup-state.json so the keepalive always targets the distro - /// the user actually installed. In DEBUG / test builds, an - /// OPENCLAW_WSL_DISTRO_NAME environment override is honored to match - /// Resolves the local gateway distro name by reading setup-state.json. - /// Falls back to "OpenClawGateway" if not found. - /// - private async Task ResolveLocalGatewayDistroNameAsync(GatewayRecord? activeRecord) - { - string? setupStateDistroName = null; - try - { - var stateFile = Path.Combine( - SetupExistingGatewayClassifier.ResolveLocalDataPath(), - "setup-state.json"); - - if (File.Exists(stateFile)) - { - var json = await File.ReadAllTextAsync(stateFile); - using var doc = System.Text.Json.JsonDocument.Parse(json); - if (doc.RootElement.TryGetProperty("DistroName", out var dn) && - dn.GetString() is { Length: > 0 } distroName) - { - setupStateDistroName = distroName; - } - } - } - catch (Exception ex) - { - Logger.Warn($"[WslKeepAlive] Failed to read setup-state.json: {ex.Message}"); - } - - return WslKeepAlivePolicy.ResolveDistroName( - activeRecord, - setupStateDistroName, - Environment.GetEnvironmentVariable("OPENCLAW_WSL_DISTRO_NAME")); - } - // The pre-unification ShouldInitializeNodeService(GatewayRecord, string) overload // and LocalNodeServiceOwnsIdentityFor have been removed: GatewayConnectionManager // is now the single owner of the WindowsNodeClient lifecycle for ALL gateways diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index c9b231a7a..a5ad0264b 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -40,6 +40,43 @@ public void Startup_Order_PreservesInitializationInvariants() "StartDeepLinkServer();"); } + [Fact] + public void Startup_WslKeepAlive_IsOwnedByDedicatedService() + { + var source = ReadAppSources(); + var startup = ExtractMethod(source, "OnLaunchedAsync"); + var service = ReadWslKeepAliveServiceSource(); + + Assert.Contains("new WslGatewayKeepAliveService(() => _settings, () => _gatewayRegistry)", startup); + Assert.Contains("Task.Run(wslKeepAlive.TryEnsureAsync)", startup); + + foreach (var duplicateMethod in new[] + { + "TryEnsureLocalGatewayKeepAliveAsync", + "StopStaleLocalGatewayKeepAliveAsync", + "ReadKeepAliveMarkerDistroNames", + "ReadSetupStateDistroNameAsync", + "StopKeepAliveProcessesForDistro", + "DeleteKeepAliveMarker", + "GetProcessCommandLine", + "ResolveWslExePath", + "ResolveLocalGatewayDistroNameAsync", + }) + { + Assert.DoesNotContain(duplicateMethod, source); + } + + Assert.Contains("public async Task TryEnsureAsync()", service); + Assert.Contains("StopStaleLocalGatewayKeepAliveAsync", service); + Assert.Contains("ReadKeepAliveMarkerDistroNames", service); + Assert.Contains("ReadSetupStateDistroNameAsync", service); + Assert.Contains("StopKeepAliveProcessesForDistro", service); + Assert.Contains("DeleteKeepAliveMarker", service); + Assert.Contains("GetProcessCommandLine", service); + Assert.Contains("ResolveWslExePath", service); + Assert.Contains("ResolveLocalGatewayDistroNameAsync", service); + } + [Fact] public void McpOnlyStartup_DoesNotRequireGatewayCredentials() { @@ -417,6 +454,13 @@ private static string ReadCoordinatorSource() root, "src", "OpenClaw.Tray.WinUI", "Services", "TrayIconCoordinator.cs")); } + private static string ReadWslKeepAliveServiceSource() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + return File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "Services", "WslGatewayKeepAliveService.cs")); + } + private static string ReadAppSources() { var root = TestRepositoryPaths.GetRepositoryRoot();