From 9ec8ee7160badbc3e9e04b560b7137ae6688d10d Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 18 May 2026 16:59:34 -0700 Subject: [PATCH 01/56] msix(t1): manifest hardening, StartupTask, CommandPalette publisher fix - Tray Package.appxmanifest: add uap5 namespace and windows.startupTask extension with TaskId=OpenClawCompanionStartup (default disabled, user opts in via Settings). - AutoStartManager: branch on PackageHelper.IsPackaged. Packaged path uses Windows.ApplicationModel.StartupTask.RequestEnableAsync/Disable so the user sees the proper one-time Windows consent dialog. Unpackaged path keeps the legacy HKCU\\...\\Run entry for dev/debug builds only. - CommandPalette Package.appxmanifest: drop the VS-template placeholders (CN=Microsoft Corporation, `A Lone Developer`, Windows.Universal device family), default-publish under the tray's publisher subject and at MaxVersionTested 26100.0. - CI: new `Patch CommandPalette MSIX manifest metadata` step that re-asserts the CommandPalette manifest is in lockstep with the tray (identity name, publisher, publisher display name, version) before the build/sign chain runs. - tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs: new assertion suite pinning the audited capability set, openclaw protocol, StartupTask TaskId, 4-part version, Publisher prefix, plus CommandPalette manifest sanity (no Microsoft placeholder, namespaced under tray, matching publisher, desktop-only). Validation: ./build.ps1 OK, Shared.Tests 1776 passed / 28 skipped, Tray.Tests 1100 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 22 ++ .../Package.appxmanifest | 19 +- src/OpenClaw.Tray.WinUI/Package.appxmanifest | 15 +- .../Services/AutoStartManager.cs | 76 +++++- .../MsixManifestAssertionTests.cs | 225 ++++++++++++++++++ 5 files changed, 344 insertions(+), 13 deletions(-) create mode 100644 tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 383387975..c8426b7e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -294,6 +294,28 @@ jobs: $xml.Save((Resolve-Path $manifest)) Write-Host "Patched MSIX manifest to identity $identityName, display name '$displayName', version $version" + - name: Patch CommandPalette MSIX manifest metadata + shell: pwsh + run: | + # Keep the CommandPalette extension package metadata in lockstep with the + # parent tray package so both sign under the same Trusted Signing cert + # subject and are discoverable as a coherent product. The repo template + # ships with placeholder Microsoft values that must NEVER ship. + $version = "${{ needs.test.outputs.majorMinorPatch }}.0" + $isAlpha = "${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, '-') }}" -eq "true" + $trayIdentity = if ($isAlpha) { "OpenClaw.Companion.Alpha" } else { "OpenClaw.Companion" } + $cmdpalIdentity = "$trayIdentity.CommandPalette" + $publisher = "CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US" + $publisherDisplay = "Scott Hanselman" + $manifest = "src/OpenClaw.CommandPalette/Package.appxmanifest" + [xml]$xml = Get-Content $manifest + $xml.Package.Identity.Name = $cmdpalIdentity + $xml.Package.Identity.Publisher = $publisher + $xml.Package.Identity.Version = $version + $xml.Package.Properties.PublisherDisplayName = $publisherDisplay + $xml.Save((Resolve-Path $manifest)) + Write-Host "Patched CommandPalette MSIX manifest to identity $cmdpalIdentity, publisher '$publisher', version $version" + - name: Build MSIX Package run: > msbuild src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj diff --git a/src/OpenClaw.CommandPalette/Package.appxmanifest b/src/OpenClaw.CommandPalette/Package.appxmanifest index 1035c5855..cc5f3d353 100644 --- a/src/OpenClaw.CommandPalette/Package.appxmanifest +++ b/src/OpenClaw.CommandPalette/Package.appxmanifest @@ -8,22 +8,25 @@ xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" IgnorableNamespaces="uap uap3 rescap"> + - - OpenClaw - A Lone Developer + OpenClaw Companion Command Palette + Scott Hanselman Assets\StoreLogo.png - - + + diff --git a/src/OpenClaw.Tray.WinUI/Package.appxmanifest b/src/OpenClaw.Tray.WinUI/Package.appxmanifest index cbab15c60..045e23ba5 100644 --- a/src/OpenClaw.Tray.WinUI/Package.appxmanifest +++ b/src/OpenClaw.Tray.WinUI/Package.appxmanifest @@ -3,8 +3,9 @@ + IgnorableNamespaces="uap uap5 rescap"> + + + diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs index dd5d36da1..32e9eb517 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs @@ -1,18 +1,54 @@ using Microsoft.Win32; +using OpenClawTray.Helpers; using System; +using System.Threading.Tasks; namespace OpenClawTray.Services; /// -/// Manages Windows auto-start registry entries. +/// Manages "Launch when Windows starts" for the tray. +/// +/// For MSIX-packaged installs (the shipping channel) the only correct API is +/// Windows.ApplicationModel.StartupTask. The corresponding +/// windows.startupTask extension is declared in Package.appxmanifest +/// with TaskId="OpenClawCompanionStartup" and Enabled="false"; the +/// user opts in via Settings, which surfaces the one-time Windows consent dialog +/// (and which the user can subsequently revoke via Task Manager → Startup). +/// +/// For unpackaged dev / debug builds we fall back to the legacy +/// HKCU\...\Run entry. The two paths are not interchangeable: an MSIX +/// install must NEVER write to HKCU\...\Run because (a) Windows ignores +/// it under MSIX governance and (b) the entry orphans when the package is +/// removed. /// public static class AutoStartManager { private const string RegistryKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; private const string AppName = "OpenClawTray"; + /// + /// StartupTask TaskId. Must match the TaskId attribute in + /// Package.appxmanifest under windows.startupTask. + /// + internal const string StartupTaskId = "OpenClawCompanionStartup"; + public static bool IsAutoStartEnabled() { + if (PackageHelper.IsPackaged) + { + try + { + var task = global::Windows.ApplicationModel.StartupTask.GetAsync(StartupTaskId).AsTask().GetAwaiter().GetResult(); + return task.State == global::Windows.ApplicationModel.StartupTaskState.Enabled + || task.State == global::Windows.ApplicationModel.StartupTaskState.EnabledByPolicy; + } + catch (Exception ex) + { + Logger.Warn($"StartupTask query failed (packaged): {ex.Message}"); + return false; + } + } + try { using var key = Registry.CurrentUser.OpenSubKey(RegistryKey, false); @@ -26,6 +62,12 @@ public static bool IsAutoStartEnabled() public static void SetAutoStart(bool enable) { + if (PackageHelper.IsPackaged) + { + _ = SetAutoStartPackagedAsync(enable); + return; + } + try { using var key = Registry.CurrentUser.CreateSubKey(RegistryKey, true); @@ -39,17 +81,43 @@ public static void SetAutoStart(bool enable) { var exePath = Environment.ProcessPath ?? System.Reflection.Assembly.GetExecutingAssembly().Location; key.SetValue(AppName, $"\"{exePath}\""); - Logger.Info("Auto-start enabled"); + Logger.Info("Auto-start enabled (unpackaged, HKCU\\...\\Run)"); } else { key.DeleteValue(AppName, false); - Logger.Info("Auto-start disabled"); + Logger.Info("Auto-start disabled (unpackaged, HKCU\\...\\Run)"); + } + } + catch (Exception ex) + { + Logger.Error($"Failed to set auto-start (unpackaged): {ex.Message}"); + } + } + + private static async Task SetAutoStartPackagedAsync(bool enable) + { + try + { + var task = await global::Windows.ApplicationModel.StartupTask.GetAsync(StartupTaskId); + if (enable) + { + // RequestEnableAsync surfaces the one-time consent prompt on first call + // and returns the resulting state. DisabledByUser / DisabledByPolicy mean + // the user revoked it via Task Manager and the toggle is essentially + // read-only until they re-enable it there. + var state = await task.RequestEnableAsync(); + Logger.Info($"StartupTask enable requested → state={state}"); + } + else + { + task.Disable(); + Logger.Info("StartupTask disabled"); } } catch (Exception ex) { - Logger.Error($"Failed to set auto-start: {ex.Message}"); + Logger.Error($"Failed to set auto-start (packaged): {ex.Message}"); } } } diff --git a/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs b/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs new file mode 100644 index 000000000..af02d938f --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs @@ -0,0 +1,225 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace OpenClaw.Tray.Tests; + +/// +/// Structural assertions on the MSIX Package.appxmanifest files for the +/// tray and the CommandPalette extension. These pin contracts that govern what +/// the signed MSIX is allowed to claim about itself: capabilities, identity, +/// publisher and startup-task TaskId. Manifest drift breaks signing (publisher +/// mismatch), breaks privacy expectations (extra capabilities silently slipping +/// in), or breaks the in-app StartupTask wiring (TaskId drift). +/// +/// CI patches Identity Name / Publisher / Version into both manifests before +/// build. The tests here cover the repo-source values plus the values CI is +/// expected to inject; we read the repo files directly because running tests +/// against the patched build output would require packaging tooling that the +/// unit-test target deliberately does not depend on. +/// +public sealed class MsixManifestAssertionTests +{ + private const string AppxFoundationNs = "http://schemas.microsoft.com/appx/manifest/foundation/windows10"; + private const string AppxUapNs = "http://schemas.microsoft.com/appx/manifest/uap/windows10"; + private const string AppxUap5Ns = "http://schemas.microsoft.com/appx/manifest/uap/windows10/5"; + private const string AppxUap3Ns = "http://schemas.microsoft.com/appx/manifest/uap/windows10/3"; + private const string AppxRescapNs = "http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"; + + private static string GetRepositoryRoot() + { + var envRepoRoot = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (!string.IsNullOrWhiteSpace(envRepoRoot) && Directory.Exists(envRepoRoot)) + return envRepoRoot; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if ((Directory.Exists(Path.Combine(directory.FullName, ".git")) || + File.Exists(Path.Combine(directory.FullName, ".git"))) && + File.Exists(Path.Combine(directory.FullName, "README.md"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new InvalidOperationException( + "Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path."); + } + + private static XDocument LoadManifest(params string[] relativePathSegments) + { + var path = Path.Combine(new[] { GetRepositoryRoot() }.Concat(relativePathSegments).ToArray()); + return XDocument.Load(path); + } + + // ---- Tray package ------------------------------------------------------ + + private static XDocument LoadTrayManifest() => + LoadManifest("src", "OpenClaw.Tray.WinUI", "Package.appxmanifest"); + + [Fact] + public void Tray_CapabilitySet_IsExactlyTheAuditedList() + { + // Privacy / security review pin: adding a capability silently bypasses the + // capability-audit review and may also block sideload trust if the user + // rejects the new prompt. If you need a new capability, update both this + // test AND docs/SETUP.md in the same change so the privacy story stays + // truthful. + var doc = LoadTrayManifest(); + var caps = doc.Descendants(XName.Get("Capabilities", AppxFoundationNs)).Single(); + + var capabilityNames = caps.Elements(XName.Get("Capability", AppxFoundationNs)) + .Select(e => (string?)e.Attribute("Name")) + .Where(n => n != null) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + var deviceCapabilityNames = caps.Elements(XName.Get("DeviceCapability", AppxFoundationNs)) + .Select(e => (string?)e.Attribute("Name")) + .Where(n => n != null) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + var rescapNames = caps.Elements(XName.Get("Capability", AppxRescapNs)) + .Select(e => (string?)e.Attribute("Name")) + .Where(n => n != null) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(new[] { "internetClient" }, capabilityNames); + Assert.Equal(new[] { "location", "microphone", "webcam" }, deviceCapabilityNames); + Assert.Equal(new[] { "runFullTrust" }, rescapNames); + } + + [Fact] + public void Tray_DeclaresOpenclawProtocolExtension() + { + var doc = LoadTrayManifest(); + var protocol = doc.Descendants(XName.Get("Protocol", AppxUapNs)).SingleOrDefault(); + Assert.NotNull(protocol); + Assert.Equal("openclaw", (string?)protocol!.Attribute("Name")); + } + + [Fact] + public void Tray_DeclaresStartupTaskExtensionMatchingAutoStartManager() + { + // The TaskId here MUST match AutoStartManager.StartupTaskId. If you rename + // either, rename both — Windows StartupTask lookup is case-sensitive and + // silently returns DisabledByPolicy on mismatch (no exception), which would + // make the Settings toggle appear stuck off. + var doc = LoadTrayManifest(); + var startupTask = doc.Descendants(XName.Get("StartupTask", AppxUap5Ns)).SingleOrDefault(); + Assert.NotNull(startupTask); + Assert.Equal("OpenClawCompanionStartup", (string?)startupTask!.Attribute("TaskId")); + Assert.Equal("false", (string?)startupTask.Attribute("Enabled")); + } + + [Fact] + public void Tray_TargetDeviceFamily_IsDesktopOnly_OnSupportedFloor() + { + var doc = LoadTrayManifest(); + var families = doc.Descendants(XName.Get("TargetDeviceFamily", AppxFoundationNs)) + .Select(e => ((string?)e.Attribute("Name"), (string?)e.Attribute("MinVersion"))) + .ToArray(); + + Assert.Single(families); + Assert.Equal(("Windows.Desktop", "10.0.19041.0"), families[0]); + } + + [Fact] + public void Tray_Identity_PublisherStartsWithExpectedSubject() + { + // Publisher in the manifest must match the Azure Trusted Signing cert + // subject EXACTLY at build time. CI does not patch Publisher (only Name / + // Version), so any drift here ships as the published value. + var doc = LoadTrayManifest(); + var identity = doc.Descendants(XName.Get("Identity", AppxFoundationNs)).Single(); + var publisher = (string?)identity.Attribute("Publisher"); + Assert.NotNull(publisher); + Assert.StartsWith("CN=Scott Hanselman", publisher!); + } + + [Fact] + public void Tray_Identity_VersionIsFourPart() + { + // MSIX requires X.Y.Z.0 (4-part). CI re-patches this from the tag during + // release, but the repo-source value must already be a valid 4-part so + // local Release builds and ad-hoc msbuild invocations don't fail. + var doc = LoadTrayManifest(); + var version = (string?)doc.Descendants(XName.Get("Identity", AppxFoundationNs)).Single().Attribute("Version"); + Assert.NotNull(version); + var parts = version!.Split('.'); + Assert.Equal(4, parts.Length); + Assert.All(parts, p => Assert.True(int.TryParse(p, out _), $"Version segment '{p}' is not an integer")); + Assert.Equal("0", parts[3]); + } + + // ---- CommandPalette package ------------------------------------------- + + private static XDocument LoadCmdPalManifest() => + LoadManifest("src", "OpenClaw.CommandPalette", "Package.appxmanifest"); + + [Fact] + public void CmdPal_Identity_DoesNotShipMicrosoftPlaceholder() + { + // The VS extension template ships with Publisher=CN=Microsoft Corporation + // and PublisherDisplayName="A Lone Developer". Both are recipes for an + // unsigned-publisher install warning on user machines. CI patches them at + // build time; the repo-source values must already be safe defaults. + var doc = LoadCmdPalManifest(); + var identity = doc.Descendants(XName.Get("Identity", AppxFoundationNs)).Single(); + var publisher = (string?)identity.Attribute("Publisher"); + Assert.NotNull(publisher); + Assert.DoesNotContain("Microsoft Corporation", publisher!); + + var publisherDisplay = (string?)doc.Descendants(XName.Get("PublisherDisplayName", AppxFoundationNs)).Single(); + Assert.NotEqual("A Lone Developer", publisherDisplay); + } + + [Fact] + public void CmdPal_Identity_NameIsNamespacedUnderTrayPackage() + { + // Both packages ship under the same publisher; namespacing the cmdpal + // identity under the tray identity keeps the two visibly related in + // Get-AppxPackage output and prevents accidental name collisions with + // unrelated extensions in the user's package store. + var doc = LoadCmdPalManifest(); + var identityName = (string?)doc.Descendants(XName.Get("Identity", AppxFoundationNs)).Single().Attribute("Name"); + Assert.NotNull(identityName); + Assert.StartsWith("OpenClaw.Companion", identityName!); + } + + [Fact] + public void CmdPal_Identity_PublisherMatchesTrayPublisher() + { + // The same Azure Trusted Signing cert signs both packages; the manifests + // must declare the same Publisher subject or signing fails with an opaque + // "publisher mismatch" error. + var tray = LoadTrayManifest(); + var cmdpal = LoadCmdPalManifest(); + var trayPublisher = (string?)tray.Descendants(XName.Get("Identity", AppxFoundationNs)).Single().Attribute("Publisher"); + var cmdpalPublisher = (string?)cmdpal.Descendants(XName.Get("Identity", AppxFoundationNs)).Single().Attribute("Publisher"); + Assert.Equal(trayPublisher, cmdpalPublisher); + } + + [Fact] + public void CmdPal_DeclaresCommandPaletteAppExtension() + { + var doc = LoadCmdPalManifest(); + var appExt = doc.Descendants(XName.Get("AppExtension", AppxUap3Ns)).SingleOrDefault(); + Assert.NotNull(appExt); + Assert.Equal("com.microsoft.commandpalette", (string?)appExt!.Attribute("Name")); + } + + [Fact] + public void CmdPal_TargetDeviceFamily_IsDesktopOnly() + { + // The repo template included Windows.Universal which is meaningless for a + // Win32 cmdpal extension and forces unnecessary universal-app validation + // during signing. + var doc = LoadCmdPalManifest(); + var families = doc.Descendants(XName.Get("TargetDeviceFamily", AppxFoundationNs)) + .Select(e => (string?)e.Attribute("Name")) + .ToArray(); + Assert.Equal(new[] { "Windows.Desktop" }, families); + } +} From e6fd8fdf85c9b67a0e217bc03cc86a50c36bcf35 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 18 May 2026 17:05:27 -0700 Subject: [PATCH 02/56] msix(t2): PermissionChecker uses AppCapability on packaged builds - PermissionChecker.CheckCameraAsync / CheckMicrophoneAsync / CheckLocation now branch on PackageHelper.IsPackaged. The packaged path goes through Windows.Security.Authorization.AppCapabilityAccess.AppCapability.Create(), which is the only API that reports the per-package consent state Windows surfaces under our package name in Settings > Privacy. The previous registry + DeviceAccessInformation path returned the wrong answer for MSIX users (it reads the global `Desktop apps` bucket, not our package-specific state). - SubscribeToAccessChanges likewise branches on PackageHelper. Packaged path subscribes AppCapability.AccessChanged for webcam, microphone, and location so the onboarding row strip live-updates when the user toggles consent in Settings > Privacy. Defense-in-depth: if any AppCapability.Create throws on an older Windows build, we unwind the partial subscription and hand back a no-op disposer (callers must not crash). - New MapAppCapabilityAccessStatus internal helper centralizes the AccessStatus -> PermissionStatus mapping with explicit arms for Allowed, UserPromptRequired, DeniedByUser, DeniedBySystem and a safe Unknown default. Unknown is deliberate so a future SDK enum value never silently bypasses capability consent. - tests/OpenClaw.Tray.Tests/PermissionCheckerPackagedMappingTests.cs: pins the packaged-branch contract via source-text assertions (the test target is net10.0 so cannot resolve WinRT types; we follow the InstallerIssAssertionTests precedent of reading the source and asserting structural invariants). Validation: ./build.ps1 OK, Shared.Tests 1776 passed / 28 skipped, Tray.Tests 1108 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Onboarding/Services/PermissionChecker.cs | 143 +++++++++++++++++- .../PermissionCheckerPackagedMappingTests.cs | 115 ++++++++++++++ 2 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 tests/OpenClaw.Tray.Tests/PermissionCheckerPackagedMappingTests.cs diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/Services/PermissionChecker.cs b/src/OpenClaw.Tray.WinUI/Onboarding/Services/PermissionChecker.cs index f7b3303a7..238f397f5 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/Services/PermissionChecker.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/Services/PermissionChecker.cs @@ -5,6 +5,7 @@ using Windows.Devices.Enumeration; using Windows.Foundation; using Windows.Graphics.Capture; +using Windows.Security.Authorization.AppCapabilityAccess; using Windows.UI.Notifications; using OpenClawTray.Helpers; @@ -13,7 +14,22 @@ namespace OpenClawTray.Onboarding.Services; /// /// Checks real Windows permission status for onboarding. /// Uses lightweight passive checks — never triggers OS consent dialogs. -/// Designed for unpackaged apps (WindowsPackageType=None). +/// +/// Branches on : +/// +/// Packaged (MSIX, the shipping channel): uses +/// Windows.Security.Authorization.AppCapabilityAccess.AppCapability for +/// webcam / microphone / location. This is the only API that reports the +/// per-package consent state surfaced in Settings → Privacy → <Capability> +/// under our package name (vs. the catch-all "Desktop apps" bucket). +/// Unpackaged (dev / debug builds): keeps the legacy +/// DeviceAccessInformation + registry probes which are what Windows +/// reports for arbitrary Win32 EXEs. +/// +/// Both branches are passive (no RequestAccessAsync) so they can run +/// during onboarding without interrupting the user. The actual capability +/// request happens later when the consuming service (e.g. CameraCaptureService) +/// first calls MediaCapture.InitializeAsync. /// public static class PermissionChecker { @@ -40,6 +56,22 @@ public record PermissionResult( string SettingsUri, string StatusLabel); + /// + /// Maps an (packaged-app capability state) + /// to our internal . Pulled out so unit tests can + /// pin the mapping without instantiating an (which + /// only resolves inside an MSIX-launched process). + /// + internal static (PermissionStatus Status, string LabelKey) MapAppCapabilityAccessStatus( + AppCapabilityAccessStatus status) => status switch + { + AppCapabilityAccessStatus.Allowed => (PermissionStatus.Granted, "Onboarding_Perm_Allowed"), + AppCapabilityAccessStatus.UserPromptRequired => (PermissionStatus.Unknown, "Onboarding_Perm_NotDetermined"), + AppCapabilityAccessStatus.DeniedByUser => (PermissionStatus.Denied, "Onboarding_Perm_DeniedUser"), + AppCapabilityAccessStatus.DeniedBySystem => (PermissionStatus.Denied, "Onboarding_Perm_DeniedSystem"), + _ => (PermissionStatus.Unknown, "Onboarding_Perm_NotDetermined") + }; + /// /// Checks all 5 permissions and returns current status for each. /// All checks are passive — no OS consent dialogs are triggered. @@ -63,6 +95,11 @@ public static async Task> CheckAllAsync() /// public static Action SubscribeToAccessChanges(Action onChanged) { + if (PackageHelper.IsPackaged) + { + return SubscribeToAccessChangesPackaged(onChanged); + } + var cameraAccess = DeviceAccessInformation.CreateFromDeviceClass(DeviceClass.VideoCapture); var micAccess = DeviceAccessInformation.CreateFromDeviceClass(DeviceClass.AudioCapture); @@ -79,6 +116,53 @@ public static Action SubscribeToAccessChanges(Action onChanged) }; } + private static Action SubscribeToAccessChangesPackaged(Action onChanged) + { + // AppCapability.AccessChanged fires whenever the user toggles the per-package + // consent in Settings → Privacy. We subscribe to webcam, microphone, and + // location together because the onboarding row strip surfaces all three. + AppCapability? webcam = null, microphone = null, location = null; + TypedEventHandler? handler = null; + try + { + webcam = AppCapability.Create("webcam"); + microphone = AppCapability.Create("microphone"); + location = AppCapability.Create("location"); + handler = (_, _) => onChanged(); + webcam.AccessChanged += handler; + microphone.AccessChanged += handler; + location.AccessChanged += handler; + } + catch (Exception) + { + // If any one of these failed, unwind any subscriptions we did make and + // hand back a no-op disposer; callers must not crash if the packaged + // capability surface is unavailable on an older OS build. + if (handler != null) + { + if (webcam != null) webcam.AccessChanged -= handler; + if (microphone != null) microphone.AccessChanged -= handler; + if (location != null) location.AccessChanged -= handler; + } + return () => { }; + } + + return () => + { + try + { + webcam.AccessChanged -= handler; + microphone.AccessChanged -= handler; + location.AccessChanged -= handler; + } + catch + { + // Best-effort unsubscription; nothing to do if the AppCapability + // objects have already been GC'd. + } + }; + } + private static PermissionResult CheckNotifications() { try @@ -146,6 +230,12 @@ private static async Task CheckCameraAsync() "ms-settings:privacy-webcam", LocalizationHelper.GetString("Onboarding_Perm_NoCameraDetected")); } + if (PackageHelper.IsPackaged) + { + return CheckAppCapability("webcam", LocalizationHelper.GetString("Onboarding_Perm_Camera"), "📷", + "ms-settings:privacy-webcam"); + } + var access = DeviceAccessInformation.CreateFromDeviceClass(DeviceClass.VideoCapture); var (status, label) = access.CurrentStatus switch { @@ -176,6 +266,12 @@ private static async Task CheckMicrophoneAsync() "ms-settings:privacy-microphone", LocalizationHelper.GetString("Onboarding_Perm_NoMicDetected")); } + if (PackageHelper.IsPackaged) + { + return CheckAppCapability("microphone", LocalizationHelper.GetString("Onboarding_Perm_Microphone"), "🎤", + "ms-settings:privacy-microphone"); + } + var access = DeviceAccessInformation.CreateFromDeviceClass(DeviceClass.AudioCapture); var (status, label) = access.CurrentStatus switch { @@ -213,11 +309,25 @@ private static PermissionResult CheckScreenCapture() } /// - /// Checks location permission passively via registry. - /// NEVER calls Geolocator.RequestAccessAsync() which triggers an OS consent dialog. + /// Checks location permission passively. + /// + /// Packaged: uses AppCapability.Create("location").CheckAccess() which + /// returns the per-package consent state — the same answer the user sees in + /// Settings → Privacy → Location under our package name. + /// + /// Unpackaged: reads HKLM\…\ConsentStore\location (system-wide kill + /// switch) and HKCU\…\ConsentStore\location (per-user). Both are + /// passive reads; we deliberately never call Geolocator.RequestAccessAsync() + /// here because that surfaces a consent dialog mid-onboarding. /// private static PermissionResult CheckLocation() { + if (PackageHelper.IsPackaged) + { + return CheckAppCapability("location", LocalizationHelper.GetString("Onboarding_Perm_Location"), "📍", + "ms-settings:privacy-location"); + } + try { // Check system-wide location service status @@ -258,4 +368,31 @@ private static PermissionResult CheckLocation() "ms-settings:privacy-location", LocalizationHelper.GetString("Onboarding_Perm_UnableToCheck")); } } + + /// + /// Packaged-app capability probe. Builds the + /// for a declared MSIX capability (webcam / microphone / location) using + /// . Falls back to Unknown if the API throws — + /// older Windows builds or non-declared capabilities both surface as a + /// throw and we don't want to crash onboarding for either. + /// + private static PermissionResult CheckAppCapability( + string capabilityName, + string displayName, + string icon, + string settingsUri) + { + try + { + var capability = AppCapability.Create(capabilityName); + var (status, labelKey) = MapAppCapabilityAccessStatus(capability.CheckAccess()); + return new PermissionResult(displayName, icon, status, settingsUri, + LocalizationHelper.GetString(labelKey)); + } + catch (Exception) + { + return new PermissionResult(displayName, icon, PermissionStatus.Unknown, settingsUri, + LocalizationHelper.GetString("Onboarding_Perm_UnableToCheck")); + } + } } diff --git a/tests/OpenClaw.Tray.Tests/PermissionCheckerPackagedMappingTests.cs b/tests/OpenClaw.Tray.Tests/PermissionCheckerPackagedMappingTests.cs new file mode 100644 index 000000000..d4a7cdf69 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/PermissionCheckerPackagedMappingTests.cs @@ -0,0 +1,115 @@ +using System; +using System.IO; + +namespace OpenClaw.Tray.Tests; + +/// +/// Pins the contract between WinRT's AppCapabilityAccessStatus (the +/// per-package consent state Windows surfaces for MSIX apps) and the onboarding +/// UI's PermissionChecker.PermissionStatus. +/// +/// We cannot instantiate a real AppCapability from this xUnit process +/// — the WinRT factory throws E_FAIL outside an MSIX-launched host — and the +/// tray.Tests target is net10.0 (not net10.0-windows), so we cannot even import +/// the WinRT enum. Following the precedent of , +/// we therefore pin the contract as source-text assertions on the production +/// switch arms. If you reorder or retitle these arms, also update this test — +/// silent drift here would silently bypass capability consent on MSIX users. +/// +public sealed class PermissionCheckerPackagedMappingTests +{ + private static string GetRepositoryRoot() + { + var envRepoRoot = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (!string.IsNullOrWhiteSpace(envRepoRoot) && Directory.Exists(envRepoRoot)) + return envRepoRoot; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if ((Directory.Exists(Path.Combine(directory.FullName, ".git")) || + File.Exists(Path.Combine(directory.FullName, ".git"))) && + File.Exists(Path.Combine(directory.FullName, "README.md"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new InvalidOperationException( + "Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path."); + } + + private static string LoadPermissionCheckerSource() => + File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Onboarding", "Services", "PermissionChecker.cs")); + + [Fact] + public void PackagedBranch_BranchesOnPackageHelper_ForCameraMicrophoneLocation() + { + // The three checks that have a packaged path MUST gate on PackageHelper.IsPackaged + // and dispatch to AppCapability.Create(""). Anything else means a + // packaged build is still reading the unpackaged registry / DeviceAccessInformation + // surface, which returns wrong answers on MSIX (and was the original bug we are + // closing here). + var src = LoadPermissionCheckerSource(); + + Assert.Contains("if (PackageHelper.IsPackaged)", src); + Assert.Contains("CheckAppCapability(\"webcam\"", src); + Assert.Contains("CheckAppCapability(\"microphone\"", src); + Assert.Contains("CheckAppCapability(\"location\"", src); + } + + [Fact] + public void PackagedBranch_AppCapabilityFactoryUsesExpectedCapabilityNames() + { + // AppCapability.Create takes the capability name as a lowercase string that + // must match the Name attribute declared in Package.appxmanifest. If either + // side drifts the OS returns DeniedBySystem permanently and the user sees + // a hopeless "denied by system" status with no recovery path. + var src = LoadPermissionCheckerSource(); + + Assert.Contains("AppCapability.Create(capabilityName)", src); + } + + [Theory] + [InlineData("AppCapabilityAccessStatus.Allowed", "PermissionStatus.Granted", "Onboarding_Perm_Allowed")] + [InlineData("AppCapabilityAccessStatus.UserPromptRequired", "PermissionStatus.Unknown", "Onboarding_Perm_NotDetermined")] + [InlineData("AppCapabilityAccessStatus.DeniedByUser", "PermissionStatus.Denied", "Onboarding_Perm_DeniedUser")] + [InlineData("AppCapabilityAccessStatus.DeniedBySystem", "PermissionStatus.Denied", "Onboarding_Perm_DeniedSystem")] + public void Mapping_HasSwitchArmForKnownStatus(string statusToken, string mappedStatusToken, string labelKey) + { + // Pin every documented mapping arm. A regression here means the packaged + // onboarding row would show a wrong status (or no status) for the + // corresponding consent state. + var src = LoadPermissionCheckerSource(); + + Assert.Contains(statusToken, src); + Assert.Contains(mappedStatusToken, src); + Assert.Contains(labelKey, src); + } + + [Fact] + public void Mapping_HasSafeUnknownDefault() + { + // Pin the default arm. Silent drift (e.g. mapping the default to Granted) + // would let a new SDK enum value bypass consent entirely. + var src = LoadPermissionCheckerSource(); + + Assert.Contains("_ => (PermissionStatus.Unknown, \"Onboarding_Perm_NotDetermined\")", src); + } + + [Fact] + public void SubscribeToAccessChanges_BranchesOnPackageHelper() + { + // The AccessChanged subscription has to use AppCapability.AccessChanged on + // packaged. The DeviceAccessInformation event only fires on Win32 EXEs. + var src = LoadPermissionCheckerSource(); + + Assert.Contains("SubscribeToAccessChangesPackaged(onChanged)", src); + Assert.Contains("AppCapability.Create(\"webcam\")", src); + Assert.Contains("AppCapability.Create(\"microphone\")", src); + Assert.Contains("AppCapability.Create(\"location\")", src); + Assert.Contains(".AccessChanged += handler", src); + } +} + From c6a3cd9d9644bd820ec7f1e3157d1fe8d13133d3 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 18 May 2026 17:07:35 -0700 Subject: [PATCH 03/56] msix(t5): document MSIX packaging strategy for OpenClaw.WinNode.Cli - docs/WINNODE_CLI_MSIX_PACKAGING.md: investigation result with the committed recommendation to package the worker-node CLI inside the tray MSIX as a second publishing a windows.appExecutionAlias (`openclaw-winnode.exe`). Documents why the current Environment.SpecialFolder.ApplicationData contract between tray and CLI only works by coincidence under MSIX, lays out the three options considered, includes the proposed Package.appxmanifest snippet, sketches the required CLI code changes, and pins acceptance criteria for the follow-up implementation PR. - docs/WINDOWS_NODE_ARCHITECTURE.md: link to the new design note so future contributors land on it from the Windows-platform umbrella doc. Doc-only change; build/tests unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/WINDOWS_NODE_ARCHITECTURE.md | 9 ++ docs/WINNODE_CLI_MSIX_PACKAGING.md | 162 +++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 docs/WINNODE_CLI_MSIX_PACKAGING.md diff --git a/docs/WINDOWS_NODE_ARCHITECTURE.md b/docs/WINDOWS_NODE_ARCHITECTURE.md index 095c4747e..f18f9dcc0 100644 --- a/docs/WINDOWS_NODE_ARCHITECTURE.md +++ b/docs/WINDOWS_NODE_ARCHITECTURE.md @@ -27,6 +27,15 @@ Related issues: #5 (Canvas Panel), #6 (Skills Settings UI), #7 (DEVELOPMENT.md), - [Technical Deep Dives](#technical-deep-dives) - [Contributing](#contributing) +## Related design notes + +- **[MSIX packaging of `OpenClaw.WinNode.Cli`](./WINNODE_CLI_MSIX_PACKAGING.md)** — the + worker-node CLI and the tray today share state via `%APPDATA%\OpenClawTray\...`, + which works under MSIX only by coincidence. The committed decision is to + package the CLI inside the tray MSIX as a second `` with a + `windows.appExecutionAlias` so the shared-state contract becomes enforceable + rather than implicit. + --- ## Current State diff --git a/docs/WINNODE_CLI_MSIX_PACKAGING.md b/docs/WINNODE_CLI_MSIX_PACKAGING.md new file mode 100644 index 000000000..a4e91e28f --- /dev/null +++ b/docs/WINNODE_CLI_MSIX_PACKAGING.md @@ -0,0 +1,162 @@ +# Node CLI packaging under MSIX + +> **Status:** Decision committed (recommended path: package the CLI inside the +> tray MSIX). Implementation is staged as a follow-up to PR #(this MSIX-E2E plan). + +## Why this matters + +`OpenClaw.WinNode.Cli` (the "worker node" CLI) and `OpenClaw.Tray.WinUI` (the +WinUI tray) share state via the file system. The contract today is implicit: + +| Artifact | Path | Writer | Reader | +|----------------------------------|-----------------------------------------------------|--------|--------| +| MCP bearer token | `%APPDATA%\OpenClawTray\mcp-token.txt` | Tray | CLI | +| Device identity (Ed25519 keypair)| `%APPDATA%\OpenClawTray\device-key-ed25519.json` | Tray | CLI | +| Exec-approval policy | `%LOCALAPPDATA%\OpenClawTray\exec-policy.json` | Tray | CLI | +| Operator pairing tokens | `%APPDATA%\OpenClawTray\settings.json` | Tray | CLI | + +Under MSIX with package identity this contract becomes load-bearing in a way +that is easy to break by accident: + +- `Environment.SpecialFolder.ApplicationData` returns the **user-profile** + `%APPDATA%` from both packaged and unpackaged processes, so today's CLI does + in fact see the same files the tray writes. This is the *only* reason the + contract works at all in MSIX mode. +- `ApplicationData.Current.LocalFolder` returns a **per-package** path + (`%LOCALAPPDATA%\Packages\\LocalState\`) that only packaged code can read. + Any future migration of the tray to that API would silently strand the CLI. +- MSIX file-system *redirection* (the legacy bridge that intercepts writes to + Program Files / HKLM / etc.) does **not** apply to `%APPDATA%`. So the shared + path keeps working — but only because nobody touches it. + +In short: the contract works today by coincidence. If anyone moves the tray to +`StorageFolder` APIs, the CLI stops finding the token without any compile-time +or runtime warning. We need to lock this down before we ship MSIX-only. + +## Options considered + +### Option A — Keep the CLI unpackaged, formalize the shared path + +- CLI ships as a stand-alone signed `.exe`, downloaded separately or bundled in + the same release ZIP alongside the MSIX. +- Both processes resolve their state directory via a new `OPENCLAW_SHARED_DIR` + environment variable, defaulting to `%APPDATA%\OpenClawTray`. The MSIX + install writes this variable as part of first-run setup. +- Pros: zero MSIX manifest change, no impact on packaging pipeline, CLI can be + invoked with absolute paths from anywhere. +- Cons: still two binaries to sign and distribute; users have to discover the + CLI separately; CLI cannot use any packaged-app API (notifications under our + package identity, AppCapability checks, etc.). +- Hazard: anyone who looks at `Environment.SpecialFolder.ApplicationData` in + the tray code and "tidies it up" to `ApplicationData.Current.LocalFolder` + breaks the CLI in a way that is invisible until a user hits it. The env-var + contract has to be documented in big letters and enforced by a test. + +### Option B — Package the CLI inside the same MSIX (recommended) + +- Tray `Package.appxmanifest` declares a **second** `` element for + the CLI, with a `windows.appExecutionAlias` extension publishing the alias + `openclaw-winnode.exe` on `PATH`. +- Both processes use `ApplicationData.Current.LocalFolder` to resolve shared + state; the package container guarantees they see identical paths. +- The CLI runs with package identity, which gives it: + - notifications under the tray's package name, + - `AppCapability.CheckAccess(...)` for capabilities declared in the same + manifest, + - first-class participation in the OS-level uninstall (its state inside the + package container goes away cleanly when the package is removed). +- Pros: one signed artifact; uniform identity story; impossible-to-drift shared + state contract; no orphaned files on uninstall for state inside the container. +- Cons: requires a second `` element + `appExecutionAlias` plumbing; + CLI cold-start path goes through the AppExecutionAlias resolver (≈30 ms one-time + overhead, irrelevant for our usage). + +### Option C — Drop the CLI entirely + +- Subsume all CLI functionality into a tray command palette and deep links. +- Pros: simplest manifest; simplest packaging; no shared-state contract at all. +- Cons: breaks every existing script / integration that shells out to the CLI; + removes the "agent on a server has no UI" use case; explicitly out of scope + for this plan. + +## Recommendation: Option B (packaged CLI with `appExecutionAlias`) + +Option B is the only choice that eliminates the "two processes happen to agree +on a path" hazard. The OS gives us a single uninstall path and a single signing +artifact, the CLI gets a real identity, and the contract between tray and CLI +becomes "we are the same package", which is enforceable rather than implicit. + +### Manifest snippet (proposed, not yet wired) + +```xml + + + + + + + + + + + + + + + + + + +``` + +Required namespace additions on ``: + +``` +xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3" +xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" +``` + +### Required CLI code changes (sketch, not yet committed) + +1. `OpenClaw.WinNode.Cli/Program.cs`: replace `Environment.SpecialFolder.ApplicationData` + resolution with a `PathResolver` that branches on `PackageHelper.IsPackaged` + (a copy of the tray's helper, or share via `OpenClaw.Shared`). Packaged → + `ApplicationData.Current.LocalFolder`. Unpackaged → today's path. +2. `OpenClaw.WinNode.Cli.csproj`: add `true` + so the CLI exe is properly bundled into the parent MSIX. +3. Tray `Package.appxmanifest`: insert the second `` from the + snippet above. +4. CI build job: add the CLI bin output to the AppPackages payload (today the + MSIX only includes the WinUI tray output). + +### Risks to track in the implementation PR + +- **Discovery**: the alias `openclaw-winnode.exe` only resolves once the user + has run the tray once (so the package registers). Anything that shells out + during install can't use it yet — use the full container path for installer + steps. +- **Console attach**: full-trust packaged consoles must `AllocConsole` / + `AttachConsole` to inherit the calling cmd's stdio; otherwise the CLI looks + like it does nothing when invoked from a terminal. The tray already has the + pattern in `App.xaml.cs`'s `RunCliUninstallAsync`; lift it into a helper. +- **AppContainer**: `runFullTrust` is still required for WSL / wsl.exe spawning + and for arbitrary file-system access. Do not remove it from the manifest. + +### Acceptance criteria for the follow-up implementation PR + +1. From a fresh PowerShell on a packaged install: `openclaw-winnode --version` + prints the same version the tray reports. +2. `Get-AppxPackage OpenClaw.Companion | Select Applications | fl *` shows both + applications. +3. CLI calls `--purge-wsl-orphans` and reports the same paths the tray would, + verified against a tray `--uninstall` golden output. +4. `Remove-AppxPackage` removes the CLI exe along with the tray (no orphan + `openclaw-winnode.exe` anywhere on disk). From f072d6fe7ae3ff8c429c4f8686bf32213089f8f9 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 18 May 2026 17:15:37 -0700 Subject: [PATCH 04/56] msix(t4): .appinstaller pipeline for non-Store auto-update - installer/openclaw-companion.appinstaller.template: AppInstaller XML template with placeholders for version, publisher, the two MSIX URIs, and the stable AppInstaller URL. UpdateSettings defaults: OnLaunch poll every 24h, ShowPrompt, non-blocking, ForceUpdateFromAnyVersion (rollback path), AutomaticBackgroundTask. - scripts/render-appinstaller.ps1: renders the template per tag with strict input validation (4-part version, absolute https URIs) and post-render XML parsing so a bad substitution surfaces at CI time, not deploy time. - .github/workflows/ci.yml: 'Render AppInstaller' step in the release job produces both a per-tag .appinstaller AND latest.appinstaller (stable filename for the gh-pages URL). Both are attached to the GitHub Release. - src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs: new internal service that wraps PackageManager.AddPackageByAppInstallerFileAsync for the in-app 'Check for updates' path. Returns a typed UpdateResult so the caller can surface UpdateQueued / NoUpdateAvailable / Failed / NotPackaged. - src/OpenClaw.Tray.WinUI/App.xaml.cs: branch CheckForUpdatesAsync and CheckForUpdatesUserInitiatedAsync on PackageHelper.IsPackaged. Packaged startup check no-ops (AppInstaller polls automatically); packaged manual check calls AppInstallerUpdateService directly. Unpackaged paths still go through Updatum until T3 deletes it. - docs/RELEASING.md: new 'Non-Store auto-update via .appinstaller' section documenting the four AppInstaller update triggers and operator caveats. - tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs: 9 new structural tests pinning template XML well-formedness, placeholder set, UpdateSettings values, MainBundle.Name matching production identity, and URL alignment between in-app service, CI, and docs. Validation: ./build.ps1 OK, Tray.Tests 1117 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 33 +++++ docs/RELEASING.md | 53 ++++++++ .../openclaw-companion.appinstaller.template | 52 ++++++++ scripts/render-appinstaller.ps1 | 123 +++++++++++++++++ src/OpenClaw.Tray.WinUI/App.xaml.cs | 66 +++++++++ .../Services/AppInstallerUpdateService.cs | 112 ++++++++++++++++ .../AppInstallerTemplateAssertionTests.cs | 125 ++++++++++++++++++ 7 files changed, 564 insertions(+) create mode 100644 installer/openclaw-companion.appinstaller.template create mode 100644 scripts/render-appinstaller.ps1 create mode 100644 src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs create mode 100644 tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8426b7e8..ae193d770 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -488,6 +488,37 @@ jobs: timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 + # Render the .appinstaller XML that drives non-Store auto-update for the MSIX. + # Windows AppInstaller polls the URL embedded in this file (see docs/RELEASING.md + # for the four update triggers). The rendered file is attached to the GitHub + # release alongside the .msix bundles AND must also be published at a stable + # URL so Windows AppInstaller has a non-tagged URL to poll over time. + - name: Render AppInstaller + if: steps.msix-x64.outcome == 'success' + shell: pwsh + run: | + $version = "${{ needs.test.outputs.majorMinorPatch }}.0" + $tag = "${{ github.ref_name }}" + $publisher = "CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US" + $base = "https://github.com/${{ github.repository }}/releases/download/$tag" + $x64Uri = "$base/OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix" + $arm64Uri = "$base/OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix" + # The stable channel URL is served by GitHub Pages from the gh-pages branch. + # Publishing TO gh-pages is intentionally separate (see follow-up issue); + # the URL still has to be embedded here so installs from this tag know where + # to poll for the next update. + $appInstallerUri = "https://openclaw.github.io/openclaw-windows-node/latest.appinstaller" + ./scripts/render-appinstaller.ps1 ` + -Version $version ` + -Publisher $publisher ` + -MsixX64Uri $x64Uri ` + -MsixArm64Uri $arm64Uri ` + -AppInstallerUri $appInstallerUri ` + -OutputPath "OpenClawCompanion-${{ needs.test.outputs.semVer }}.appinstaller" + # Also copy to a stable filename so the gh-pages publishing step (or a + # manual operator running the release) has a predictable filename. + Copy-Item "OpenClawCompanion-${{ needs.test.outputs.semVer }}.appinstaller" "latest.appinstaller" + # Create ZIP files for Updatum auto-update (needs "win-x64" in filename) - name: Create Release ZIPs run: | @@ -555,6 +586,8 @@ jobs: OpenClawTray-${{ needs.test.outputs.semVer }}-win-arm64.zip OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix + OpenClawCompanion-${{ needs.test.outputs.semVer }}.appinstaller + latest.appinstaller prerelease: ${{ contains(github.ref_name, '-') }} make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }} body: | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1934ecd22..280fdd491 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -50,6 +50,58 @@ After pushing a tag, confirm in GitHub Actions: - jobs complete successfully (build, build-msix, release) - release assets are attached to the tag release +## Non-Store auto-update via `.appinstaller` + +OpenClaw Companion ships outside the Microsoft Store but still wants +silent-ish updates. The supported pattern is a hosted `.appinstaller` XML file +that Windows AppInstaller polls; the CI release job renders one per tag from +`installer/openclaw-companion.appinstaller.template` via +`scripts/render-appinstaller.ps1` and attaches it to the GitHub release as +both `OpenClawCompanion-X.Y.Z.appinstaller` (per-tag) and `latest.appinstaller` +(stable filename for the published gh-pages URL). + +### Four ways an `.appinstaller` install gets to a new version + +When a user installs by clicking the `.appinstaller` (not the raw `.msix`), +Windows AppInstaller persists the source URL and the embedded +`` block. After that the following triggers can update the +install: + +1. **OnLaunch (passive)** — `HoursBetweenUpdateChecks="24" ShowPrompt="true" + UpdateBlocksActivation="false"`. Windows polls the URL no more than once per + 24 hours at app launch, prompts the user, and applies the update on the + *next* launch. This is the default path most users will see. +2. **OnLaunch (blocking)** — same poll, but with `UpdateBlocksActivation="true"` + the app waits while the update applies. We leave this OFF because it adds + user-visible cold-start latency. +3. **In-app, on demand** — the tray's "Check for updates" menu (when running + packaged) calls `PackageManager.AddPackageByAppInstallerFileAsync` against + `https://openclaw.github.io/openclaw-windows-node/latest.appinstaller`. This + bypasses the 24 h poll window and applies any newer published version + immediately (and restarts the app). +4. **Windows background scan** — Windows historically re-polls on user sign-in + and on Start-menu launches. This is best-effort and not contractually + guaranteed; never depend on it as the only update path for a particular + user cohort. + +### Important caveats for the release operator + +- The `Version` attribute in the rendered `.appinstaller` AND the `Version` + attribute inside `` AND the `` of the + attached MSIX must all match exactly. The CI rendering step asserts this; + if you hand-edit the rendered file before publishing, re-validate manually. +- The release notes "Quick Start" link should point at the **`.appinstaller`** + URL, not the raw `.msix`. A user who installs from a raw `.msix` does not + get the AppInstaller poll wired up and is stuck on that version until they + re-install via `.appinstaller`. +- The `latest.appinstaller` URL on GitHub Pages must keep pointing at the + currently shipping stable; pre-release alpha builds use their tag-specific + filename and never overwrite `latest.appinstaller`. +- Publishing `latest.appinstaller` to GitHub Pages is **a separate step** from + attaching it to the release. Until that's automated, the release operator + copies the file from the GitHub release into the `gh-pages` branch by hand + after the release artifacts are validated. + ## If you need to retag If a tag points to the wrong commit: @@ -61,3 +113,4 @@ git tag -a vX.Y.Z -m "Release vX.Y.Z" git push origin vX.Y.Z ``` + diff --git a/installer/openclaw-companion.appinstaller.template b/installer/openclaw-companion.appinstaller.template new file mode 100644 index 000000000..69b1ce70f --- /dev/null +++ b/installer/openclaw-companion.appinstaller.template @@ -0,0 +1,52 @@ + + + + + + + + + + true + + + diff --git a/scripts/render-appinstaller.ps1 b/scripts/render-appinstaller.ps1 new file mode 100644 index 000000000..87b680252 --- /dev/null +++ b/scripts/render-appinstaller.ps1 @@ -0,0 +1,123 @@ +<# +.SYNOPSIS + Renders installer/openclaw-companion.appinstaller.template into a release-ready + AppInstaller XML by substituting the {{TOKEN}} placeholders. + +.DESCRIPTION + Used by .github/workflows/ci.yml during the release job. Also runnable + locally to validate template renders before tagging a release. + + The rendered AppInstaller XML must validate against the AppInstaller schema + (https://schemas.microsoft.com/appx/appinstaller/2018). We assert via XML + load rather than schema validation because the schema isn't shipped with the + Windows SDK on most runners. + +.PARAMETER Version + 4-part version string (e.g. "0.5.3.0"). Must match the MSIX . + +.PARAMETER Publisher + Publisher subject from the MSIX manifest, with quoting preserved. Example: + "CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US" + +.PARAMETER MsixX64Uri + Absolute https:// URL of the x64 .msix release asset. + +.PARAMETER MsixArm64Uri + Absolute https:// URL of the arm64 .msix release asset. + +.PARAMETER AppInstallerUri + Absolute https:// URL of THIS rendered .appinstaller file on the stable + channel (e.g. https://openclaw.github.io/openclaw-windows-node/latest.appinstaller). + Embedded inside the AppInstaller so Windows AppInstaller knows where to poll. + +.PARAMETER OutputPath + Destination path for the rendered .appinstaller file. + +.EXAMPLE + ./scripts/render-appinstaller.ps1 ` + -Version 0.5.3.0 ` + -Publisher 'CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US' ` + -MsixX64Uri https://github.com/.../v0.5.3/OpenClawCompanion-0.5.3-win-x64.msix ` + -MsixArm64Uri https://github.com/.../v0.5.3/OpenClawCompanion-0.5.3-win-arm64.msix ` + -AppInstallerUri https://openclaw.github.io/openclaw-windows-node/latest.appinstaller ` + -OutputPath OpenClawCompanion-0.5.3.appinstaller +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $Version, + [Parameter(Mandatory)] [string] $Publisher, + [Parameter(Mandatory)] [string] $MsixX64Uri, + [Parameter(Mandatory)] [string] $MsixArm64Uri, + [Parameter(Mandatory)] [string] $AppInstallerUri, + [Parameter(Mandatory)] [string] $OutputPath +) + +$ErrorActionPreference = 'Stop' + +# Validate version is 4-part. AppInstaller silently accepts 1-3 part versions +# but Windows AppInstaller's update detector compares them as 4-part, so a +# 3-part value produces "no update available" forever. +$parts = $Version.Split('.') +if ($parts.Length -ne 4) { + throw "Version must be 4-part (X.Y.Z.W). Got: '$Version'" +} +foreach ($p in $parts) { + if (-not [int]::TryParse($p, [ref]([int]0))) { + throw "Version segment '$p' is not an integer." + } +} + +# Validate URIs are absolute https://. AppInstaller refuses to poll http:// in +# Windows 11 23H2+ (security policy) and a relative URL crashes the renderer. +foreach ($pair in @( + @{ Name = 'MsixX64Uri'; Value = $MsixX64Uri }, + @{ Name = 'MsixArm64Uri'; Value = $MsixArm64Uri }, + @{ Name = 'AppInstallerUri'; Value = $AppInstallerUri } + )) { + $u = $null + if (-not [Uri]::TryCreate($pair.Value, 'Absolute', [ref]$u) -or $u.Scheme -ne 'https') { + throw "$($pair.Name) must be an absolute https:// URL. Got: '$($pair.Value)'" + } +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$templatePath = Join-Path $repoRoot 'installer\openclaw-companion.appinstaller.template' +if (-not (Test-Path $templatePath)) { + throw "Template not found: $templatePath" +} + +$template = Get-Content $templatePath -Raw + +# Simple string substitution — inputs are not regex patterns and we don't want +# regex-metacharacter surprises from values like a publisher subject that +# contains literal commas/quotes or a URI with a percent-encoded character. +$rendered = $template +$rendered = $rendered.Replace('{{VERSION}}', $Version) +$rendered = $rendered.Replace('{{PUBLISHER}}', $Publisher) +$rendered = $rendered.Replace('{{MSIX_X64_URI}}', $MsixX64Uri) +$rendered = $rendered.Replace('{{MSIX_ARM64_URI}}', $MsixArm64Uri) +$rendered = $rendered.Replace('{{APPINSTALLER_URI}}', $AppInstallerUri) + +# Validate the rendered XML parses. A bad template / bad substitution surfaces +# here instead of at deploy time when Windows refuses to install. +[xml]$xml = $rendered +if ($xml.AppInstaller.Version -ne $Version) { + throw "Rendered XML has Version '$($xml.AppInstaller.Version)' but expected '$Version'. Substitution failure." +} +if ($xml.AppInstaller.MainBundle.Publisher -ne $Publisher) { + throw "Rendered XML has Publisher '$($xml.AppInstaller.MainBundle.Publisher)' but expected '$Publisher'." +} + +$outDir = Split-Path -Parent $OutputPath +if ($outDir -and -not (Test-Path $outDir)) { + New-Item -ItemType Directory -Force -Path $outDir | Out-Null +} +Set-Content -Path $OutputPath -Value $rendered -Encoding UTF8 + +Write-Host "Rendered AppInstaller: $OutputPath" +Write-Host " Version: $Version" +Write-Host " Publisher: $Publisher" +Write-Host " MSIX x64 URI: $MsixX64Uri" +Write-Host " MSIX ARM64 URI: $MsixArm64Uri" +Write-Host " AppInstaller URI: $AppInstallerUri" diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index d29d659a7..3478779ad 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3255,6 +3255,26 @@ private void OnSettingsHotkeyPressed(object? sender, EventArgs e) private async Task CheckForUpdatesAsync() { + // Packaged apps under MSIX don't need an in-app startup poll — Windows + // AppInstaller polls our hosted .appinstaller per the OnLaunch settings + // embedded in the AppInstaller XML and applies updates on the NEXT + // launch (see docs/RELEASING.md for the four update triggers). Calling + // Updatum here would silently double-publish a "vN+1 available" toast + // for the upgrade the OS already has staged. Return true so the caller + // launches the app normally. + if (OpenClawTray.Helpers.PackageHelper.IsPackaged) + { + Logger.Info("Skipping in-app update check (packaged build; AppInstaller polls OnLaunch)"); + _appState!.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Managed", + CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CheckedAt = DateTime.UtcNow, + Detail = "managed by Windows AppInstaller" + }; + return true; + } + try { #if DEBUG @@ -3352,6 +3372,52 @@ private async Task CheckForUpdatesAsync() private async Task CheckForUpdatesUserInitiatedAsync() { Logger.Info("Manual update check requested"); + + // Packaged: bypass the Updatum check/download/install dance and go + // directly to PackageManager.AddPackageByAppInstallerFileAsync. The + // AppInstaller URL is the single source of truth; if a newer version + // is published Windows will restart the app, otherwise we surface + // "already up to date" in the UI. + if (OpenClawTray.Helpers.PackageHelper.IsPackaged) + { + _appState!.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Checking", + CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CheckedAt = DateTime.UtcNow, + Detail = $"querying {AppInstallerUpdateService.LatestAppInstallerUri}" + }; + UpdateStatusDetailWindow(); + + var outcome = await AppInstallerUpdateService.TryApplyUpdateAsync(); + _appState!.UpdateInfo = outcome.Outcome switch + { + AppInstallerUpdateService.UpdateOutcome.UpdateQueued => new UpdateCommandCenterInfo + { + Status = "Updating", + CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CheckedAt = DateTime.UtcNow, + Detail = outcome.DetailMessage ?? "update queued; Windows will restart the app" + }, + AppInstallerUpdateService.UpdateOutcome.NoUpdateAvailable => new UpdateCommandCenterInfo + { + Status = "Current", + CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CheckedAt = DateTime.UtcNow, + Detail = outcome.DetailMessage ?? "no updates available" + }, + _ => new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CheckedAt = DateTime.UtcNow, + Detail = outcome.DetailMessage ?? "update failed" + } + }; + UpdateStatusDetailWindow(); + return; + } + var shouldContinue = await CheckForUpdatesAsync(); UpdateStatusDetailWindow(); if (!shouldContinue) diff --git a/src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs b/src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs new file mode 100644 index 000000000..dde67e0bd --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs @@ -0,0 +1,112 @@ +using System; +using System.Threading.Tasks; +using OpenClawTray.Helpers; + +namespace OpenClawTray.Services; + +/// +/// MSIX-only update path. When the tray is running as a packaged app the +/// canonical non-Store auto-update channel is an .appinstaller file +/// hosted at a stable URL (see installer/openclaw-companion.appinstaller.template +/// and docs/RELEASING.md). Windows AppInstaller polls that URL on +/// launch automatically via the OnLaunch settings embedded in the +/// AppInstaller XML. This service exposes the *manual* path the user takes +/// when they click "Check for updates" — it asks PackageManager to apply +/// whatever the AppInstaller URL currently advertises, which is the same +/// machinery the OnLaunch poll uses but bypasses the 24 h check window. +/// +/// This service is only invoked when +/// is true. The unpackaged dev / debug path is intentionally unchanged +/// (it short-circuits via the #if DEBUG in App.xaml.cs; once +/// Updatum and Inno are sunset in Track 3, the unpackaged update path +/// disappears entirely). +/// +internal static class AppInstallerUpdateService +{ + /// + /// Stable URL of the AppInstaller XML on GitHub Pages. The CI release job + /// (.github/workflows/ci.yml, step "Render AppInstaller") publishes both a + /// per-tag file and this stable latest.appinstaller alias; embedded + /// installs poll this URL according to the OnLaunch settings. + /// + public const string LatestAppInstallerUri = + "https://openclaw.github.io/openclaw-windows-node/latest.appinstaller"; + + /// + /// Reflects the outcome of so the caller + /// can surface a meaningful status to the user without coupling to WinRT. + /// + public enum UpdateOutcome + { + /// An upgrade was queued; the tray will be restarted by Windows. + UpdateQueued, + /// No newer version is currently published at the AppInstaller URL. + NoUpdateAvailable, + /// The call ran but Windows reported a non-fatal failure (e.g. network). + Failed, + /// Caller invoked the service from an unpackaged process (programming error). + NotPackaged + } + + public record UpdateResult(UpdateOutcome Outcome, string? DetailMessage); + + /// + /// Asks Windows to fetch and apply the MSIX advertised at the AppInstaller URL. + /// Returns when Windows has accepted (or rejected) the request; the actual + /// upgrade may continue asynchronously and finishes by restarting the app + /// (PackageManager invokes ForceApplicationShutdown so we don't fight + /// the OS for the package container). + /// + public static async Task TryApplyUpdateAsync(string? appInstallerUri = null) + { + if (!PackageHelper.IsPackaged) + { + return new UpdateResult(UpdateOutcome.NotPackaged, + "AppInstallerUpdateService called from an unpackaged process. " + + "The unpackaged update path uses Updatum (see App.xaml.cs); " + + "branch on PackageHelper.IsPackaged before invoking this service."); + } + + var uri = new Uri(appInstallerUri ?? LatestAppInstallerUri, UriKind.Absolute); + + try + { + // Late-bind PackageManager so the file compiles on unpackaged test + // builds that don't actually link against Windows.Management.Deployment. + // Same global:: prefix dance as AutoStartManager — `Windows` resolves + // to OpenClawTray.Windows here otherwise. + var manager = new global::Windows.Management.Deployment.PackageManager(); + var deploymentOperation = manager.AddPackageByAppInstallerFileAsync( + uri, + global::Windows.Management.Deployment.AddPackageByAppInstallerOptions.ForceTargetAppShutdown, + manager.GetDefaultPackageVolume()); + + var result = await deploymentOperation.AsTask(); + + if (result.IsRegistered) + { + // Successfully registered the new version. Windows will restart + // the app per the ForceTargetAppShutdown flag. + return new UpdateResult(UpdateOutcome.UpdateQueued, + "Update applied; Windows will restart the app."); + } + + // ExtendedErrorCode is the canonical "why didn't it install" surface. + // 0x80073D02 (E_PACKAGES_IN_USE) is the typical "no update available" + // shape from AppInstaller; treat unknown failures as Failed not + // NoUpdateAvailable so the UI never silently lies about being up to date. + var hr = (uint)result.ExtendedErrorCode.HResult; + return hr switch + { + 0x80073D02 => new UpdateResult(UpdateOutcome.NoUpdateAvailable, + "Already on the latest version published at the AppInstaller URL."), + _ => new UpdateResult(UpdateOutcome.Failed, + $"PackageManager reported HRESULT 0x{hr:X8}: {result.ErrorText}") + }; + } + catch (Exception ex) + { + return new UpdateResult(UpdateOutcome.Failed, ex.Message); + } + } +} diff --git a/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs b/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs new file mode 100644 index 000000000..6106ad74a --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs @@ -0,0 +1,125 @@ +using System; +using System.IO; +using System.Xml.Linq; + +namespace OpenClaw.Tray.Tests; + +/// +/// Structural assertions on the AppInstaller template + the in-app update +/// service contract. The template is rendered by CI and parsed by Windows +/// AppInstaller; even a single attribute typo silently breaks auto-update +/// for every user who installed via that link, with no in-app surface to +/// notice. The tests here pin: +/// +/// 1. The template is well-formed XML against the AppInstaller schema URI. +/// 2. The five placeholder tokens are present (so the CI render script's +/// substitution table is exhaustive). +/// 3. The UpdateSettings block has the documented OnLaunch / Force / +/// AutomaticBackgroundTask values (silent regression here would change +/// user-visible behavior). +/// 4. The in-app service points at the same hosted URL the release pipeline +/// publishes (drift here would split-brain installs that polled the +/// "stable" URL against installs that polled the "in-app" URL). +/// +public sealed class AppInstallerTemplateAssertionTests +{ + private static string GetRepositoryRoot() + { + var envRepoRoot = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (!string.IsNullOrWhiteSpace(envRepoRoot) && Directory.Exists(envRepoRoot)) + return envRepoRoot; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if ((Directory.Exists(Path.Combine(directory.FullName, ".git")) || + File.Exists(Path.Combine(directory.FullName, ".git"))) && + File.Exists(Path.Combine(directory.FullName, "README.md"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new InvalidOperationException( + "Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path."); + } + + private static string LoadTemplate() => + File.ReadAllText(Path.Combine( + GetRepositoryRoot(), "installer", "openclaw-companion.appinstaller.template")); + + [Fact] + public void Template_IsWellFormedXml() + { + // Parse with placeholders intact — XML parsing tolerates {{TOKEN}} as + // attribute *values* because they're just strings. If we added attribute + // names with placeholders we'd have to render before parsing. + var doc = XDocument.Parse(LoadTemplate()); + Assert.Equal("AppInstaller", doc.Root!.Name.LocalName); + Assert.Equal("http://schemas.microsoft.com/appx/appinstaller/2018", + doc.Root.Name.NamespaceName); + } + + [Theory] + [InlineData("{{VERSION}}")] + [InlineData("{{PUBLISHER}}")] + [InlineData("{{MSIX_X64_URI}}")] + [InlineData("{{MSIX_ARM64_URI}}")] + [InlineData("{{APPINSTALLER_URI}}")] + public void Template_DeclaresExpectedPlaceholder(string token) + { + // scripts/render-appinstaller.ps1 substitutes exactly these five tokens. + // If you add a new placeholder here, also add a -replace in the script + // AND a CI step parameter. If you remove one, the renderer silently + // ships the literal {{TOKEN}} string to AppInstaller which fails to parse. + Assert.Contains(token, LoadTemplate()); + } + + [Fact] + public void Template_HasDocumentedUpdateSettings() + { + var doc = XDocument.Parse(LoadTemplate()); + XNamespace ns = "http://schemas.microsoft.com/appx/appinstaller/2018"; + + var onLaunch = doc.Descendants(ns + "OnLaunch").SingleOrDefault(); + Assert.NotNull(onLaunch); + Assert.Equal("24", (string?)onLaunch!.Attribute("HoursBetweenUpdateChecks")); + Assert.Equal("true", (string?)onLaunch.Attribute("ShowPrompt")); + Assert.Equal("false", (string?)onLaunch.Attribute("UpdateBlocksActivation")); + + Assert.Contains(doc.Descendants(ns + "ForceUpdateFromAnyVersion"), + e => e.Value == "true"); + Assert.Contains(doc.Descendants(ns + "AutomaticBackgroundTask"), _ => true); + } + + [Fact] + public void Template_MainBundleNameMatchesProductionPackageIdentity() + { + // The MainBundle Name must equal Package.appxmanifest Identity Name + // (after CI patches it to OpenClaw.Companion). Drift here = Windows + // refuses to apply the update because it sees a different package. + var doc = XDocument.Parse(LoadTemplate()); + XNamespace ns = "http://schemas.microsoft.com/appx/appinstaller/2018"; + var mainBundle = doc.Descendants(ns + "MainBundle").Single(); + Assert.Equal("OpenClaw.Companion", (string?)mainBundle.Attribute("Name")); + } + + [Fact] + public void InAppService_PointsAtSameStableUrlAsReleaseChannel() + { + // The CI release job copies the rendered file to "latest.appinstaller" + // and the README documents the gh-pages URL. The in-app + // AppInstallerUpdateService MUST poll that same URL; otherwise the + // in-app "Check for updates" button and the OS background poll see + // different versions. + var service = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Services", "AppInstallerUpdateService.cs")); + + Assert.Contains("https://openclaw.github.io/openclaw-windows-node/latest.appinstaller", service); + + // And the same URL must appear in the CI workflow (the render step) so + // the rendered file points at the same stable URL it is hosted at. + var ci = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "ci.yml")); + Assert.Contains("https://openclaw.github.io/openclaw-windows-node/latest.appinstaller", ci); + } +} From 4a119f1dde2b04890f16f87fffb2b727d4e50316 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 18 May 2026 17:26:31 -0700 Subject: [PATCH 05/56] msix(t3): sunset Inno + Updatum, add --purge-wsl-orphans recovery CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We're MSIX-only now. This commit retires every code path that existed only for the Inno .exe distribution + the Updatum auto-update channel, and lands the documented recovery path for the case where a user removed the MSIX without first running the in-app Reset & remove (PR #310). Deleted (Inno + portable ZIP + Updatum sunset): - installer.iss - scripts/Uninstall-LocalGateway.ps1 - src/OpenClaw.Tray.WinUI/Dialogs/DownloadProgressDialog.cs - src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs - tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs - tests/PackagingTests/Test-InnoUninstallOrdering.ps1 - docs/uninstall-portable.md - Updatum PackageReference from src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj - using Updatum + AppUpdater static + DownloadAndInstallUpdateAsync from App.xaml.cs CI (.github/workflows/ci.yml): - Dropped 'Install Inno Setup', 'Build x64 Installer', 'Build arm64 Installer', 'Sign Installer', 'Create Release ZIPs' steps from the release job. - Release-notes 'Quick Start' now points users at latest.appinstaller, not the raw .msix (raw .msix installs don't wire up auto-update). - Release attached files reduced to the 4 MSIX-only assets: latest.appinstaller, OpenClawCompanion-.appinstaller, the two .msix. Added (recovery CLI): - src/OpenClaw.WinNode.Cli/OrphanPurger.cs: detects orphan WSL distros (openclaw-* prefix), orphan %APPDATA%/%LOCALAPPDATA% folders, legacy openclaw:// URI scheme registration, legacy HKCU Run autostart key. Dry-run by default; --confirm-destructive applies. --json-output for machine consumption. Exit codes 0 (clean / removed) / 1 (dirty dry-run) / 2 (some removals failed). - OpenClaw.WinNode.Cli --purge-wsl-orphans dispatches to OrphanPurger BEFORE the --command required-flag check. - docs/uninstall-msix.md: replaces the manual recovery PowerShell with the CLI flag (one command instead of five) and includes the equivalent PowerShell fallback for the case where the CLI itself was removed. Doc cleanup: - docs/SETUP.md: install instructions rewritten around the .appinstaller URL. - DEVELOPMENT.md: Release Process section rewritten for the MSIX-only pipeline. - scripts/validate-msix-storage-paths.ps1: recovery guidance updated to point at the new --purge-wsl-orphans CLI. - src/OpenClaw.Tray.WinUI/App.xaml.cs: mutex-name comment no longer references the deleted installer.iss AppMutex contract. Note: SettingsManager.SkippedUpdateTag is left in place — the field is harmless and removing it would force a settings.json migration. It will be naturally retired when SettingsData gets its next breaking change. Validation: ./build.ps1 OK, Shared.Tests 1776 passed / 28 skipped, Tray.Tests 1115 passed (-2 from deleted InstallerIssAssertionTests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 85 +-- DEVELOPMENT.md | 18 +- docs/SETUP.md | 29 +- docs/uninstall-msix.md | 49 +- docs/uninstall-portable.md | 71 -- installer.iss | 90 --- scripts/Uninstall-LocalGateway.ps1 | 79 --- scripts/validate-msix-storage-paths.ps1 | 8 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 142 +--- .../Dialogs/DownloadProgressDialog.cs | 43 -- .../Dialogs/UpdateDialog.cs | 121 ---- .../OpenClaw.Tray.WinUI.csproj | 1 - src/OpenClaw.WinNode.Cli/OrphanPurger.cs | 329 +++++++++ src/OpenClaw.WinNode.Cli/Program.cs | 22 + .../InstallerIssAssertionTests.cs | 65 -- .../PermissionCheckerPackagedMappingTests.cs | 7 +- .../Test-InnoUninstallOrdering.ps1 | 637 ------------------ 17 files changed, 445 insertions(+), 1351 deletions(-) delete mode 100644 docs/uninstall-portable.md delete mode 100644 installer.iss delete mode 100644 scripts/Uninstall-LocalGateway.ps1 delete mode 100644 src/OpenClaw.Tray.WinUI/Dialogs/DownloadProgressDialog.cs delete mode 100644 src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs create mode 100644 src/OpenClaw.WinNode.Cli/OrphanPurger.cs delete mode 100644 tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs delete mode 100644 tests/PackagingTests/Test-InnoUninstallOrdering.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae193d770..e6b88fe14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -519,71 +519,11 @@ jobs: # manual operator running the release) has a predictable filename. Copy-Item "OpenClawCompanion-${{ needs.test.outputs.semVer }}.appinstaller" "latest.appinstaller" - # Create ZIP files for Updatum auto-update (needs "win-x64" in filename) - - name: Create Release ZIPs - run: | - Compress-Archive -Path artifacts/tray-win-x64/* -DestinationPath OpenClawTray-${{ needs.test.outputs.semVer }}-win-x64.zip - Compress-Archive -Path artifacts/tray-win-arm64/* -DestinationPath OpenClawTray-${{ needs.test.outputs.semVer }}-win-arm64.zip - - # Inno Setup installer for x64 - - name: Install Inno Setup - run: choco install innosetup -y - - - name: Build x64 Installer - run: | - # Prepare x64 files - mkdir publish-x64 - copy artifacts/tray-win-x64/* publish-x64/ -Recurse - mkdir publish-x64\cmdpal - $manifestFolder = Get-ChildItem -Path "artifacts/cmdpal-x64" -Recurse -Filter "AppxManifest.xml" | Select-Object -First 1 - if ($manifestFolder) { - Copy-Item "$($manifestFolder.DirectoryName)\*" -Destination publish-x64\cmdpal -Recurse - } - # Build installer - & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DMyAppVersion=${{ needs.test.outputs.majorMinorPatch }} /DMyAppArch=x64 /Dpublish=publish-x64 installer.iss - - - name: Build arm64 Installer - run: | - # Prepare arm64 files - mkdir publish-arm64 - copy artifacts/tray-win-arm64/* publish-arm64/ -Recurse - mkdir publish-arm64\cmdpal - $manifestFolder = Get-ChildItem -Path "artifacts/cmdpal-arm64" -Recurse -Filter "AppxManifest.xml" | Select-Object -First 1 - if ($manifestFolder) { - Copy-Item "$($manifestFolder.DirectoryName)\*" -Destination publish-arm64\cmdpal -Recurse - } - # Build installer - & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DMyAppVersion=${{ needs.test.outputs.majorMinorPatch }} /DMyAppArch=arm64 /Dpublish=publish-arm64 installer.iss - - - name: Azure Login for Signing - uses: azure/login@v3 - with: - creds: '{"clientId":"${{ secrets.AZURE_CLIENT_ID }}","clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ secrets.AZURE_SUBSCRIPTION_ID }}","tenantId":"${{ secrets.AZURE_TENANT_ID }}"}' - - - name: Sign Installer - uses: azure/trusted-signing-action@v2 - with: - azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} - azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} - azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} - endpoint: https://wus2.codesigning.azure.net/ - signing-account-name: hanselman - certificate-profile-name: WindowsEdgeLight - files-folder: Output - files-folder-filter: exe - file-digest: SHA256 - timestamp-rfc3161: http://timestamp.acs.microsoft.com - timestamp-digest: SHA256 - - name: Create Release uses: softprops/action-gh-release@v3 with: generate_release_notes: true files: | - Output/OpenClawTray-Setup-x64.exe - Output/OpenClawTray-Setup-arm64.exe - OpenClawTray-${{ needs.test.outputs.semVer }}-win-x64.zip - OpenClawTray-${{ needs.test.outputs.semVer }}-win-arm64.zip OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix OpenClawCompanion-${{ needs.test.outputs.semVer }}.appinstaller @@ -591,22 +531,20 @@ jobs: prerelease: ${{ contains(github.ref_name, '-') }} make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }} body: | - ## OpenClaw Windows Hub ${{ github.ref_name }} + ## OpenClaw Companion ${{ github.ref_name }} ### Downloads - - **Installer (x64)**: `OpenClawTray-Setup-x64.exe` - Intel/AMD 64-bit - - **Installer (ARM64)**: `OpenClawTray-Setup-arm64.exe` - Windows on ARM (Surface, etc.) - - **Portable x64**: `OpenClawTray-${{ needs.test.outputs.semVer }}-win-x64.zip` - - **Portable ARM64**: `OpenClawTray-${{ needs.test.outputs.semVer }}-win-arm64.zip` - - **MSIX x64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix` - Packaged (camera/mic consent) - - **MSIX ARM64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix` - Packaged (camera/mic consent) + - **AppInstaller (recommended)**: `latest.appinstaller` — install once and Windows auto-updates from then on (see docs/RELEASING.md for the 4 update triggers) + - **Tag-pinned AppInstaller**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}.appinstaller` + - **MSIX x64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix` — offline / power-user install (no auto-update) + - **MSIX ARM64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix` — Windows on ARM (Surface, Snapdragon) ### Features - 🦞 System tray integration with gateway status - - 🎯 PowerToys Command Palette extension (optional) - - 🔄 Auto-updates from GitHub Releases + - 🎯 PowerToys Command Palette extension + - 🔄 Auto-updates via Windows AppInstaller (non-Store channel) - ✅ Code-signed with Azure Trusted Signing - - 📦 MSIX package available for native camera/microphone consent prompts + - 📦 MSIX-only distribution: native camera/microphone/location consent prompts under our package name ### Requirements - Windows 10 version 1903 or later @@ -615,7 +553,6 @@ jobs: - PowerToys (for Command Palette extension) ### Quick Start - 1. Run the installer for your architecture (or sideload the MSIX for camera consent) - 2. Optionally enable Command Palette extension during install - 3. Launch from Start Menu or system tray - 4. Right-click tray icon → Settings to configure + 1. Click the `latest.appinstaller` link above — Windows AppInstaller will install the signed MSIX and wire up auto-update. + 2. Launch from Start Menu or system tray. + 3. Right-click tray icon → Settings to configure. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e70126015..418927a24 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -611,19 +611,19 @@ On every build, the following artifacts are uploaded: When a tag is pushed (e.g., `git tag v1.2.3 && git push origin v1.2.3`): 1. **Build & Sign:** - - All artifacts built for x64 and ARM64 - - Executables signed with Azure Trusted Signing certificate + - MSIX packages built for x64 and ARM64 + - Both `.msix` files and the rendered `.appinstaller` are signed with the Azure Trusted Signing certificate -2. **Create Installers:** - - Inno Setup creates Windows installers - - Includes both Tray app and Command Palette extension - - Separate installers for x64 and ARM64 +2. **Render AppInstaller:** + - `scripts/render-appinstaller.ps1` produces both `OpenClawCompanion-X.Y.Z.appinstaller` (per-tag) and `latest.appinstaller` (stable filename for the gh-pages URL) + - See [`docs/RELEASING.md`](./docs/RELEASING.md) for the four AppInstaller update triggers 3. **GitHub Release:** - Automatic release created with tag name - - Includes: - - Installers: `OpenClawTray-Setup-x64.exe`, `OpenClawTray-Setup-arm64.exe` - - Portable ZIPs: `OpenClawTray-{version}-win-x64.zip`, `OpenClawTray-{version}-win-arm64.zip` + - Attached assets: + - `latest.appinstaller` (recommended user download) + - `OpenClawCompanion-X.Y.Z.appinstaller` (tag-pinned) + - `OpenClawCompanion-X.Y.Z-win-x64.msix` and `-win-arm64.msix` (offline / power-user) - Release notes auto-generated from commits ### Monitoring CI diff --git a/docs/SETUP.md b/docs/SETUP.md index 2ae877136..ba89246a0 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -14,35 +14,36 @@ Before installing, make sure you have: ### 1. Download the Installer -Go to the [Releases page](https://github.com/openclaw/openclaw-windows-node/releases) and download the latest installer for your architecture: +Go to the [Releases page](https://github.com/openclaw/openclaw-windows-node/releases) and download the **AppInstaller** file: -| File | Architecture | +| File | Description | |------|-------------| -| `OpenClawTray-Setup-x64.exe` | Intel / AMD (most PCs) | -| `OpenClawTray-Setup-arm64.exe` | ARM64 (Surface Pro X, Snapdragon laptops) | +| `latest.appinstaller` | Recommended — install once, Windows auto-updates from then on | +| `OpenClawCompanion-X.Y.Z-win-x64.msix` | Offline / power-user install for Intel / AMD (no auto-update) | +| `OpenClawCompanion-X.Y.Z-win-arm64.msix` | Offline / power-user install for ARM64 (Surface Pro X, Snapdragon laptops; no auto-update) | -If you're unsure, use the **x64** installer. +If you're unsure, click **`latest.appinstaller`** — Windows AppInstaller will pick the right architecture for your machine and wire up automatic updates. -### 2. Run the Installer +### 2. Run the AppInstaller -Double-click the downloaded `.exe`. Windows may show a SmartScreen prompt — click **More info → Run anyway** (this is normal for code-signed apps that haven't yet accumulated reputation). +Click `latest.appinstaller`. Windows AppInstaller opens, shows the publisher (Scott Hanselman, code-signed via Azure Trusted Signing), and offers to install. -The installer runs without requiring administrator privileges. +The install runs without requiring administrator privileges. -### 3. Choose Optional Components +### 3. Optional integrations -The installer offers two optional components: +The MSIX installs the tray app. Optional integrations are configured from inside the app: -- **Create Desktop Icon** — adds a shortcut to your desktop. -- **Start OpenClaw Tray when Windows starts** — launches Molty automatically at login (recommended). -- **Install PowerToys Command Palette extension** — enables OpenClaw commands in PowerToys Command Palette (requires [PowerToys](https://github.com/microsoft/PowerToys) to be installed). See [POWERTOYS.md](./POWERTOYS.md) for details. +- **PowerToys Command Palette extension** — install separately if you use PowerToys; see [POWERTOYS.md](./POWERTOYS.md). +- **Launch at Windows sign-in** — toggle in **Settings → Auto-start** (uses the Windows StartupTask API; the user can revoke it from Task Manager → Startup). ### 4. First Launch -After the installer finishes, OpenClaw Tray starts automatically. Look for the 🦞 lobster icon in the system tray (bottom-right corner of the taskbar, near the clock). +After install, OpenClaw Tray starts automatically. Look for the 🦞 lobster 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). + ### 5. Onboarding Wizard On first launch, Molty opens a **6-screen onboarding wizard** that walks you through setup: diff --git a/docs/uninstall-msix.md b/docs/uninstall-msix.md index 5a50ce336..5e62d8229 100644 --- a/docs/uninstall-msix.md +++ b/docs/uninstall-msix.md @@ -45,31 +45,46 @@ Therefore, removing the MSIX package via **Settings → Apps → OpenClaw Tray ## Manual Recovery (After MSIX Removed Without In-Tray Cleanup) -If the MSIX was already removed and the WSL distro / app data remains: +If the MSIX was already removed and the WSL distro / app data remains, the +**supported recovery path** is the dedicated CLI flag: ```powershell -# 1. Unregister the distro (removes .vhdx from wsl's internal store) -wsl --unregister OpenClawGateway +# Detect orphans (dry-run; exits 1 if any found) +openclaw-winnode --purge-wsl-orphans --json-output -# 2. Remove VHD parent directory (wsl --unregister may leave the folder) -Remove-Item "$env:LOCALAPPDATA\OpenClawTray\wsl\OpenClawGateway" ` - -Recurse -Force -ErrorAction SilentlyContinue +# Apply the deletions +openclaw-winnode --purge-wsl-orphans --confirm-destructive --json-output +``` + +The CLI detects and removes: + +| Kind | Where | +|-----------------------|--------------------------------------------------------------------------------------| +| `wsl-distro` | Any WSL distro whose name starts with `openclaw-` | +| `appdata-folder` | `%APPDATA%\OpenClawTray\` | +| `localappdata-folder` | `%LOCALAPPDATA%\OpenClawTray\` | +| `registry-uri-scheme` | `HKCU\Software\Classes\openclaw` (legacy unpackaged URI scheme) | +| `registry-run-key` | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\OpenClawTray` (legacy autostart) | + +If the CLI is not available (e.g., the package was uninstalled before this +fallback was published), the equivalent PowerShell one-liners are: + +```powershell +# 1. Unregister the WSL distro(s) +wsl --list --quiet | + Where-Object { $_ -match '^openclaw-' } | + ForEach-Object { wsl --unregister $_ } -# 3. Remove autostart registry entry +# 2. Remove autostart registry entry (legacy) Remove-ItemProperty ` -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" ` -Name "OpenClawTray" -ErrorAction SilentlyContinue -# 4. Remove local app data (setup state, logs) -Remove-Item "$env:LOCALAPPDATA\OpenClawTray" -Recurse -Force -ErrorAction SilentlyContinue +# 3. Remove openclaw:// URI scheme registration (legacy) +Remove-Item "HKCU:\SOFTWARE\Classes\openclaw" -Recurse -Force -ErrorAction SilentlyContinue -# 5. Remove roaming app data (settings, device key — only if you want full purge) -# NOTE: mcp-token.txt is intentionally preserved here; delete manually if needed. -Remove-Item "$env:APPDATA\OpenClawTray\setup-state.json" -Force -ErrorAction SilentlyContinue +# 4. Remove app data +Remove-Item "$env:LOCALAPPDATA\OpenClawTray" -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item "$env:APPDATA\OpenClawTray" -Recurse -Force -ErrorAction SilentlyContinue ``` -Or use the validation script if it is available separately: - -```powershell -.\validate-wsl-gateway-uninstall.ps1 -Mode Full -ConfirmDestructive -``` diff --git a/docs/uninstall-portable.md b/docs/uninstall-portable.md deleted file mode 100644 index 027b7e164..000000000 --- a/docs/uninstall-portable.md +++ /dev/null @@ -1,71 +0,0 @@ -# Uninstalling OpenClaw Tray — Portable ZIP - -> **Date:** 2026-05-07 -> **Branch:** feat/wsl-gateway-uninstall - -Portable (ZIP) installations have **no automatic uninstall hook**. -Simply deleting the folder leaves the WSL distro, app data, and autostart -entry behind. Follow one of the two paths below for a clean removal. - ---- - -## Recommended: In-Tray Removal (Requires the Tray Running) - -1. Open the tray icon. -2. Navigate to **Settings → Local Gateway**. -3. Click **"Remove Local Gateway"**. -4. The engine stops keepalive processes, unregisters the WSL distro, nulls - the device token, removes autostart, and cleans up app data. -5. After the operation completes, delete the portable folder. - ---- - -## CLI: Headless Removal (No Tray UI Required) - -Run from the portable folder: - -```powershell -# Destructive — removes the local WSL gateway cleanly, then print result to stdout -.\OpenClaw.Tray.WinUI.exe --uninstall --confirm-destructive - -# With JSON output for programmatic consumption (tokens redacted in output): -.\OpenClaw.Tray.WinUI.exe --uninstall --confirm-destructive --json-output .\uninstall-result.json - -# Dry-run — records what would happen without any destruction: -.\OpenClaw.Tray.WinUI.exe --uninstall --dry-run -``` - -**Exit codes:** - -| Code | Meaning | -|------|---------| -| 0 | Success — all steps completed, postconditions satisfied | -| 1 | Partial failure — one or more steps failed (see JSON output or stderr) | -| 2 | Bad arguments — `--confirm-destructive` or `--dry-run` missing | - -After the CLI command exits 0, delete the portable folder. - ---- - -## WARNING: Deleting the Folder Without Running Uninstall - -Deleting the portable folder **without** running the uninstall first leaves: - -- **WSL distro orphaned** — `OpenClawGateway` remains in `wsl --list`. - Manual cleanup: `wsl --unregister OpenClawGateway` - -- **App data** remains under: - - `%APPDATA%\OpenClawTray\` — device key, settings, mcp-token - - `%LOCALAPPDATA%\OpenClawTray\` — setup state, logs, exec policy, VHD parent dir - -- **Autostart entry** may remain in - `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run\OpenClawTray` - -Manual WSL + registry cleanup: - -```powershell -wsl --unregister OpenClawGateway -Remove-ItemProperty -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" ` - -Name "OpenClawTray" -ErrorAction SilentlyContinue -Remove-Item "$env:LOCALAPPDATA\OpenClawTray\wsl\OpenClawGateway" -Recurse -Force -ErrorAction SilentlyContinue -``` diff --git a/installer.iss b/installer.iss deleted file mode 100644 index 661cd691f..000000000 --- a/installer.iss +++ /dev/null @@ -1,90 +0,0 @@ -; OpenClaw Tray Inno Setup Script (WinUI version) -#define MyAppName "OpenClaw Tray" -#define MyAppPublisher "Scott Hanselman" -#define MyAppURL "https://github.com/openclaw/openclaw-windows-node" -#define MyAppExeName "OpenClaw.Tray.WinUI.exe" - -; MyAppArch should be passed via /DMyAppArch=x64 or /DMyAppArch=arm64 -#ifndef MyAppArch - #define MyAppArch "x64" -#endif - -[Setup] -AppId={{M0LTB0T-TRAY-4PP1-D3N7}} -AppName={#MyAppName} -AppVersion={#MyAppVersion} -AppPublisher={#MyAppPublisher} -AppPublisherURL={#MyAppURL} -AppSupportURL=https://github.com/openclaw/openclaw-windows-node/issues -AppUpdatesURL=https://github.com/openclaw/openclaw-windows-node/releases -DefaultDirName={localappdata}\OpenClawTray -DefaultGroupName={#MyAppName} -DisableProgramGroupPage=yes -OutputBaseFilename=OpenClawTray-Setup-{#MyAppArch} -Compression=lzma -SolidCompression=yes -WizardStyle=modern -PrivilegesRequired=lowest -SetupIconFile=src\OpenClaw.Tray.WinUI\Assets\openclaw.ico -UninstallDisplayIcon={app}\{#MyAppExeName} -; Round 2 (Scott #5): block install/uninstall while the tray is running. -; Mutex name matches App.xaml.cs (`new Mutex(true, "OpenClawTray", …)`). -; Tray and Inno run in the same user session, so the bare name resolves -; against Local\OpenClawTray — no Global\ prefix needed. -AppMutex=OpenClawTray -#if MyAppArch == "arm64" -ArchitecturesInstallIn64BitMode=arm64 -ArchitecturesAllowed=arm64 -#else -ArchitecturesInstallIn64BitMode=x64 -ArchitecturesAllowed=x64 -#endif - -[Languages] -Name: "english"; MessagesFile: "compiler:Default.isl" - -; publish folder should be passed via /Dpublish=publish-x64 or /Dpublish=publish-arm64 -#ifndef publish - #define publish "publish" -#endif - -[Tasks] -Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked -Name: "startupicon"; Description: "Start OpenClaw Tray when Windows starts"; GroupDescription: "Startup:"; Flags: unchecked -Name: "cmdpalette"; Description: "Install PowerToys Command Palette extension"; GroupDescription: "Integrations:"; Flags: unchecked - -[Files] -; WinUI Tray app - include all files (WinUI needs DLLs, not single-file) -Source: "{#publish}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs -; Command Palette extension (all files from build output). -; skipifsourcedoesntexist: prevents ISCC compile error when the cmdpal publish -; dir is absent (e.g. developer builds that skip the cmdpalette task). -Source: "{#publish}\cmdpal\*"; DestDir: "{app}\CommandPalette"; Flags: ignoreversion recursesubdirs skipifsourcedoesntexist; Tasks: cmdpalette -; WSL gateway uninstall helper — invoked by [UninstallRun] to drive clean removal -Source: "scripts\Uninstall-LocalGateway.ps1"; DestDir: "{app}"; Flags: ignoreversion - -[Icons] -Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" -Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" -Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon -Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: startupicon - -[Run] -Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent -; Register Command Palette extension (silently, only if task selected) -Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -Command ""Add-AppxPackage -Register '{app}\CommandPalette\AppxManifest.xml' -ForceApplicationShutdown"""; Flags: runhidden; Tasks: cmdpalette - -[UninstallRun] -; ORDERING NOTE: Inno Setup runs [UninstallRun] entries BEFORE deleting {app} -; directory contents. This guarantees OpenClawTray.exe is still present when -; the script executes. See Inno docs: "[UninstallRun] section". -; Fallback: if OpenClawTray.exe is missing for any reason, Uninstall-LocalGateway.ps1 -; logs the error to {app}\uninstall-gateway-error.log and exits 0 so Inno continues. -; *** DO NOT COMMENT OUT OR REMOVE THE Flags LINE BELOW *** -; waituntilterminated is non-negotiable: without it Inno races ahead and deletes -; {app} while the PowerShell hook (and the CLI engine it invokes) is still running, -; leaving 279+ application files behind after unins000.exe reports exit 0. -; runhidden suppresses the console window that would otherwise flash briefly. -Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\Uninstall-LocalGateway.ps1"""; Flags: shellexec waituntilterminated runhidden; StatusMsg: "Removing local WSL gateway..." -; Unregister Command Palette extension on uninstall -Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -Command ""Get-AppxPackage -Name '*OpenClaw*' | Remove-AppxPackage"""; Flags: runhidden diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 deleted file mode 100644 index cde043c95..000000000 --- a/scripts/Uninstall-LocalGateway.ps1 +++ /dev/null @@ -1,79 +0,0 @@ -<# -.SYNOPSIS - Inno Setup [UninstallRun] helper — removes the local WSL gateway via the - OpenClaw tray CLI flag. - -.DESCRIPTION - INNO ORDERING CONTRACT - ---------------------- - Per Inno Setup documentation, [UninstallRun] entries execute BEFORE the - {app} directory is deleted. OpenClawTray.exe is therefore guaranteed to - be present when this script runs. - - WHAT THIS SCRIPT DOES - --------------------- - 1. Locates OpenClawTray.exe in the same directory as this script ({app}). - 2. Invokes: OpenClawTray.exe --uninstall --confirm-destructive --json-output - 3. Logs success or failure to {app}\uninstall-gateway-result.json. - 4. If the EXE is missing (e.g., partial install), logs the error and exits 0 - so the Inno uninstaller continues. The user may need to clean up manually - (see docs\uninstall-portable.md for manual steps). - - FALLBACK - -------- - Exit 0 in all error cases so Inno does not abort the uninstall if gateway - cleanup fails. The result JSON captures the failure for post-mortem. - -.NOTES - Date: 2026-05-07 - Author: Aaron (Backend / Infrastructure Engineer) - Branch: feat/wsl-gateway-uninstall - Commit: 5 of 7 - - Token / key material is NEVER written to the result log; the engine - and CLI layer both redact sensitive fields before serializing. -#> - -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' - -$scriptDir = $PSScriptRoot -$exePath = Join-Path $scriptDir 'OpenClaw.Tray.WinUI.exe' -$resultPath = Join-Path $scriptDir 'uninstall-gateway-result.json' -$errorPath = Join-Path $scriptDir 'uninstall-gateway-error.log' - -# --------------------------------------------------------------------------- -# EXE presence check — fallback if somehow missing -# --------------------------------------------------------------------------- -if (-not (Test-Path -LiteralPath $exePath)) { - $msg = "[$(Get-Date -Format 'o')] Uninstall-LocalGateway.ps1: " + - "OpenClawTray.exe not found at '$exePath'. " + - "WSL gateway cleanup skipped. Manual cleanup may be required." - try { $msg | Out-File -LiteralPath $errorPath -Encoding UTF8 -Force } catch {} - Write-Warning $msg - exit 0 -} - -# --------------------------------------------------------------------------- -# Invoke CLI uninstall -# --------------------------------------------------------------------------- -$exitCode = 0 -try { - & $exePath --uninstall --confirm-destructive --json-output $resultPath - $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } - - if ($exitCode -eq 0) { - Write-Host "OpenClaw local WSL gateway removed successfully." -ForegroundColor Green - } else { - Write-Warning "OpenClaw gateway uninstall exited $exitCode; see '$resultPath' for details." - } -} catch { - $msg = "[$(Get-Date -Format 'o')] Uninstall-LocalGateway.ps1 error: $($_.Exception.Message)" - try { $msg | Out-File -LiteralPath $errorPath -Encoding UTF8 -Force } catch {} - Write-Warning $msg -} - -# Always exit 0 so Inno does not abort the broader uninstall. -exit 0 diff --git a/scripts/validate-msix-storage-paths.ps1 b/scripts/validate-msix-storage-paths.ps1 index 4a2d62924..ac6cfcdc3 100644 --- a/scripts/validate-msix-storage-paths.ps1 +++ b/scripts/validate-msix-storage-paths.ps1 @@ -38,10 +38,10 @@ - MUST add in-app pre-uninstall warning banner gated on: PackageHelper.IsPackaged() && File.Exists(setupStatePath) so users are warned before removing the MSIX package. - - The Inno uninstaller script (Uninstall-LocalGateway.ps1) targets real paths - unconditionally — no change needed there. - - Recovery: scripts/validate-wsl-gateway-uninstall.ps1 -Scenario Full - -ConfirmDestructiveClean is still relevant for orphaned state. + - Recovery (after MSIX was removed without using the in-tray button): + openclaw-winnode --purge-wsl-orphans --confirm-destructive --json-output + (see docs/uninstall-msix.md for the equivalent PowerShell one-liners if the + CLI is not available.) If "PathB-CleanRemove": - Remove-AppxPackage handles file-based artifact cleanup automatically. diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 3478779ad..dc080fabe 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -26,19 +26,12 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Updatum; using WinUIEx; namespace OpenClawTray; public partial class App : Application, OpenClawTray.Services.IAppCommands { - internal static readonly UpdatumManager AppUpdater = new("shanselman", "openclaw-windows-hub") - { - FetchOnlyLatestRelease = true, - InstallUpdateSingleFileExecutableName = "OpenClaw.Tray.WinUI", - }; - private TrayIcon? _trayIcon; private GatewayConnectionManager? _connectionManager; private GatewayRegistry? _gatewayRegistry; @@ -593,11 +586,13 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) // two test runs against the same data dir would otherwise pick different // mutex names — and `Math.Abs(int.MinValue)` overflows. Use a stable // SHA-256 prefix instead. - // NOTE: The bare "OpenClawTray" mutex name is also referenced by - // installer.iss `AppMutex=` for install/uninstall race coordination - // (round 2, Scott #5). The suffixed test-isolation variant is - // intentionally not covered by AppMutex — production installs only - // ever use the unsuffixed name. + // NOTE: Historically the bare "OpenClawTray" mutex name was also referenced + // by installer.iss `AppMutex=` for install/uninstall race coordination. + // installer.iss has been removed (MSIX-only distribution); the mutex name + // is retained because (a) it's load-bearing for the running-tray + // single-instance check, and (b) the merged LocalGatewayUninstall flow + // (PR #310) and any future Reset & remove path still need a stable name + // to detect a running tray. var mutexName = "OpenClawTray"; if (DataDirOverride is not null) { @@ -3277,83 +3272,19 @@ private async Task CheckForUpdatesAsync() try { -#if DEBUG - Logger.Info("Skipping update check in debug build"); + // Unpackaged builds (dev / debug / CI hosts) have no shipping update + // channel — there's no MSIX to apply and Updatum has been removed. + // Just stamp the UpdateInfo so the diagnostics panel reflects the + // current state and let the app launch. + Logger.Info("Skipping update check (unpackaged build; no update channel available)"); _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Skipped", CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", CheckedAt = DateTime.UtcNow, - Detail = "debug build" + Detail = "unpackaged build; install the MSIX for auto-update" }; return true; -#else - Logger.Info("Checking for updates..."); - _appState!.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Checking", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", - CheckedAt = DateTime.UtcNow - }; - var updateFound = await AppUpdater.CheckForUpdatesAsync(); - - if (!updateFound) - { - Logger.Info("No updates available"); - _appState!.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Current", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", - CheckedAt = DateTime.UtcNow, - Detail = "no updates available" - }; - return true; - } - - var release = AppUpdater.LatestRelease!; - var changelog = AppUpdater.GetChangelog(true) ?? "No release notes available."; - Logger.Info($"Update available: {release.TagName}"); - _appState!.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Available", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", - LatestVersion = release.TagName, - CheckedAt = DateTime.UtcNow, - Detail = "prompted" - }; - - if (!string.IsNullOrWhiteSpace(_settings?.SkippedUpdateTag) && - string.Equals(_settings.SkippedUpdateTag, release.TagName, StringComparison.OrdinalIgnoreCase)) - { - Logger.Info($"Skipping update prompt for remembered version {release.TagName}"); - _appState!.UpdateInfo.Detail = "skipped by user"; - return true; - } - - var dialog = new UpdateDialog(release.TagName, changelog); - var result = await dialog.ShowAsync(); - - if (result == UpdateDialogResult.Download) - { - _appState!.UpdateInfo.Detail = "download requested"; - if (_settings != null) - { - _settings.SkippedUpdateTag = string.Empty; - _settings.Save(); - } - var installed = await DownloadAndInstallUpdateAsync(); - return !installed; // Don't launch if update succeeded - } - - if (result == UpdateDialogResult.Skip && _settings != null) - { - _settings.SkippedUpdateTag = release.TagName ?? string.Empty; - _settings.Save(); - _appState!.UpdateInfo.Detail = "skipped by user"; - } - - return true; // RemindLater or Skip - continue -#endif } catch (Exception ex) { @@ -3373,11 +3304,10 @@ private async Task CheckForUpdatesUserInitiatedAsync() { Logger.Info("Manual update check requested"); - // Packaged: bypass the Updatum check/download/install dance and go - // directly to PackageManager.AddPackageByAppInstallerFileAsync. The - // AppInstaller URL is the single source of truth; if a newer version - // is published Windows will restart the app, otherwise we surface - // "already up to date" in the UI. + // Packaged: bypass any in-app dance and go directly to + // PackageManager.AddPackageByAppInstallerFileAsync. The AppInstaller URL + // is the single source of truth; if a newer version is published Windows + // will restart the app, otherwise we surface "already up to date". if (OpenClawTray.Helpers.PackageHelper.IsPackaged) { _appState!.UpdateInfo = new UpdateCommandCenterInfo @@ -3418,42 +3348,10 @@ private async Task CheckForUpdatesUserInitiatedAsync() return; } - var shouldContinue = await CheckForUpdatesAsync(); + // Unpackaged: no update channel. Show the user the same "skipped" status + // the startup check would set. + await CheckForUpdatesAsync(); UpdateStatusDetailWindow(); - if (!shouldContinue) - { - Exit(); - } - } - - private async Task DownloadAndInstallUpdateAsync() - { - DownloadProgressDialog? progressDialog = null; - try - { - progressDialog = new DownloadProgressDialog(AppUpdater); - progressDialog.ShowAsync(); // Fire and forget - - var downloadedAsset = await AppUpdater.DownloadUpdateAsync(); - - progressDialog?.Close(); - - if (downloadedAsset == null || !System.IO.File.Exists(downloadedAsset.FilePath)) - { - Logger.Error("Update download failed or file missing"); - return false; - } - - Logger.Info("Installing update and restarting..."); - await AppUpdater.InstallUpdateAsync(downloadedAsset); - return true; - } - catch (Exception ex) - { - Logger.Error($"Update failed: {ex.Message}"); - progressDialog?.Close(); - return false; - } } #endregion diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/DownloadProgressDialog.cs b/src/OpenClaw.Tray.WinUI/Dialogs/DownloadProgressDialog.cs deleted file mode 100644 index c66b2e466..000000000 --- a/src/OpenClaw.Tray.WinUI/Dialogs/DownloadProgressDialog.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media; -using OpenClawTray.Helpers; -using Updatum; - -namespace OpenClawTray.Dialogs; - -public sealed class DownloadProgressDialog -{ - private Window? _window; - private readonly UpdatumManager? _updater; - - public DownloadProgressDialog(UpdatumManager updater) - { - _updater = updater; - } - - public void ShowAsync() - { - _window = new Window { Title = LocalizationHelper.GetString("WindowTitle_Downloading") }; - _window.SystemBackdrop = new MicaBackdrop(); - - var panel = new StackPanel { Padding = new Thickness(20) }; - var progressText = new TextBlock { Text = LocalizationHelper.GetString("Download_ProgressText"), Margin = new Thickness(0, 0, 0, 10) }; - var progressBar = new ProgressBar { IsIndeterminate = true }; - - panel.Children.Add(progressText); - panel.Children.Add(progressBar); - _window.Content = panel; - - // Size and center the window - _window.AppWindow.Resize(new global::Windows.Graphics.SizeInt32(400, 200)); - var displayArea = Microsoft.UI.Windowing.DisplayArea.Primary; - var centerX = (displayArea.WorkArea.Width - 400) / 2; - var centerY = (displayArea.WorkArea.Height - 200) / 2; - _window.AppWindow.Move(new global::Windows.Graphics.PointInt32(centerX, centerY)); - - _window.Activate(); - } - - public void Close() => _window?.Close(); -} diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs b/src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs deleted file mode 100644 index 5767a2d97..000000000 --- a/src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs +++ /dev/null @@ -1,121 +0,0 @@ -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media; -using OpenClawTray.Helpers; -using OpenClawTray.Services; -using System; -using System.Threading.Tasks; -using WinUIEx; - -namespace OpenClawTray.Dialogs; - -public enum UpdateDialogResult -{ - Download, - Skip, - RemindLater -} - -/// -/// Dialog showing available update with release notes. -/// Built directly in a WindowEx (no ContentDialog/XamlRoot issues). -/// -public sealed class UpdateDialog : WindowEx -{ - private readonly TaskCompletionSource _tcs = new(); - private UpdateDialogResult _result = UpdateDialogResult.RemindLater; - - public UpdateDialog(string version, string changelog) - { - Title = LocalizationHelper.GetString("WindowTitle_Update"); - this.SetWindowSize(560, 420); - this.CenterOnScreen(); - this.SetIcon("Assets\\openclaw.ico"); - SystemBackdrop = new MicaBackdrop(); - - var root = new Grid - { - Padding = new Thickness(32), - RowSpacing = 16 - }; - root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); - root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - - // Header - var header = new TextBlock - { - Text = string.Format(LocalizationHelper.GetString("Update_VersionAvailable"), version), - Style = (Style)Application.Current.Resources["SubtitleTextBlockStyle"] - }; - Grid.SetRow(header, 0); - root.Children.Add(header); - - // Content - var content = new StackPanel { Spacing = 12 }; - - var currentVersion = typeof(UpdateDialog).Assembly.GetName().Version?.ToString() ?? "Unknown"; - content.Children.Add(new TextBlock - { - Text = string.Format(LocalizationHelper.GetString("Update_CurrentVersion"), currentVersion), - Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"] - }); - - content.Children.Add(new TextBlock - { - Text = LocalizationHelper.GetString("Update_WhatsNew"), - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold - }); - - content.Children.Add(new ScrollViewer - { - MaxHeight = 200, - Content = new TextBlock - { - Text = changelog, - TextWrapping = TextWrapping.Wrap - } - }); - - Grid.SetRow(content, 1); - root.Children.Add(content); - - // Buttons - var buttonPanel = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - Spacing = 8 - }; - - var skipButton = new Button { Content = LocalizationHelper.GetString("Update_SkipButton") }; - skipButton.Click += (s, e) => { Logger.Info("[Update] User clicked 'Skip'"); _result = UpdateDialogResult.Skip; Close(); }; - buttonPanel.Children.Add(skipButton); - - var laterButton = new Button { Content = LocalizationHelper.GetString("Update_RemindLaterButton") }; - laterButton.Click += (s, e) => { Logger.Info("[Update] User clicked 'Remind Later'"); _result = UpdateDialogResult.RemindLater; Close(); }; - buttonPanel.Children.Add(laterButton); - - var downloadButton = new Button - { - Content = LocalizationHelper.GetString("Update_DownloadButton"), - Style = (Style)Application.Current.Resources["AccentButtonStyle"] - }; - downloadButton.Click += (s, e) => { Logger.Info("[Update] User clicked 'Download'"); _result = UpdateDialogResult.Download; Close(); }; - buttonPanel.Children.Add(downloadButton); - - Grid.SetRow(buttonPanel, 2); - root.Children.Add(buttonPanel); - - Content = root; - Closed += (s, e) => _tcs.TrySetResult(_result); - - Logger.Info($"[Update] Update dialog shown for version {version}"); - } - - public new Task ShowAsync() - { - Activate(); - return _tcs.Task; - } -} diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index cb77f8daa..a8bd1300a 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -64,7 +64,6 @@ - diff --git a/src/OpenClaw.WinNode.Cli/OrphanPurger.cs b/src/OpenClaw.WinNode.Cli/OrphanPurger.cs new file mode 100644 index 000000000..a57997003 --- /dev/null +++ b/src/OpenClaw.WinNode.Cli/OrphanPurger.cs @@ -0,0 +1,329 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Win32; + +namespace OpenClaw.WinNode.Cli; + +/// +/// Recovery path for the case where the user uninstalled the OpenClaw Companion +/// MSIX *without* first running the in-app "Reset & remove" flow. MSIX has no +/// supported CustomUninstall hook for non-Store packages, so anything we wrote +/// outside the package container (WSL distros installed by the local-gateway +/// flow, files under %APPDATA%\OpenClawTray\, the openclaw:// URI +/// registration, the auto-start Run key) becomes orphaned after Remove-AppxPackage. +/// +/// This class detects those orphans and (with --confirm-destructive) +/// removes them. Without that flag it dry-runs and emits JSON describing what +/// it would delete. Surfaced via OpenClaw.WinNode.Cli --purge-wsl-orphans. +/// +/// Exit codes: +/// +/// 0 — no orphans found, or all detected orphans were removed. +/// 1 — orphans were found but not removed (dry-run mode, missing +/// --confirm-destructive). The JSON report enumerates them. +/// 2 — orphan removal was attempted and at least one item failed. +/// +/// +internal static class OrphanPurger +{ + /// Names of WSL distros we know we install via the local-gateway flow. + /// + /// The local-gateway installer uses openclaw-local as the canonical + /// distro name. We pattern-match the prefix instead of an exact name so a + /// future openclaw-staging / openclaw-arm64 variant is also + /// caught. + /// + internal const string OrphanWslDistroPrefix = "openclaw-"; + + public record OrphanItem(string Kind, string Name, string Detail); + public record PurgeReport( + IReadOnlyList Orphans, + IReadOnlyList Removed, + IReadOnlyList Failed, + bool ConfirmDestructive); + + public static async Task RunAsync( + bool confirmDestructive, + bool jsonOutput, + TextWriter stdout, + TextWriter stderr, + Func? envLookup = null) + { + envLookup ??= Environment.GetEnvironmentVariable; + + var orphans = new List(); + orphans.AddRange(DetectWslDistros(stderr)); + orphans.AddRange(DetectFileOrphans(envLookup)); + orphans.AddRange(DetectRegistryOrphans()); + + var removed = new List(); + var failed = new List(); + + if (confirmDestructive) + { + foreach (var orphan in orphans) + { + try + { + await RemoveAsync(orphan, stderr); + removed.Add(orphan); + } + catch (Exception ex) + { + failed.Add(orphan with { Detail = $"{orphan.Detail} (remove failed: {ex.Message})" }); + } + } + } + + var report = new PurgeReport(orphans, removed, failed, confirmDestructive); + if (jsonOutput) + { + stdout.WriteLine(JsonSerializer.Serialize(report, + new JsonSerializerOptions { WriteIndented = true })); + } + else + { + WriteHumanReport(report, stdout); + } + + if (failed.Count > 0) return 2; + if (!confirmDestructive && orphans.Count > 0) return 1; + return 0; + } + + private static IEnumerable DetectWslDistros(TextWriter stderr) + { + // wsl.exe --list --quiet writes one distro name per line, encoded as + // UTF-16LE without BOM (a known wsl.exe quirk). We force the codepage + // via cmd /U-equivalent and read raw bytes, then strip the BOM-less + // UTF-16. + ProcessStartInfo psi; + try + { + psi = new ProcessStartInfo("wsl.exe", "--list --quiet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = System.Text.Encoding.Unicode + }; + } + catch (Exception ex) + { + stderr.WriteLine($"[purge] WSL not available ({ex.Message}); skipping distro detection."); + yield break; + } + + Process? proc = null; + try + { + proc = Process.Start(psi); + } + catch (Exception ex) + { + stderr.WriteLine($"[purge] wsl.exe failed to launch ({ex.Message}); skipping distro detection."); + yield break; + } + if (proc is null) yield break; + + var output = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(); + if (proc.ExitCode != 0) + { + // Most likely "WSL has no distributions installed" — exit code 1 + // with empty output. Nothing to do. + yield break; + } + + foreach (var rawLine in output.Split('\n')) + { + var line = rawLine.Trim().Trim('\u0000'); + if (line.Length == 0) continue; + if (!line.StartsWith(OrphanWslDistroPrefix, StringComparison.OrdinalIgnoreCase)) continue; + yield return new OrphanItem( + Kind: "wsl-distro", + Name: line, + Detail: $"WSL distribution installed by the OpenClaw local-gateway flow"); + } + } + + private static IEnumerable DetectFileOrphans(Func envLookup) + { + foreach (var candidate in new[] + { + (Env: "APPDATA", Sub: "OpenClawTray", Kind: "appdata-folder"), + (Env: "LOCALAPPDATA", Sub: "OpenClawTray", Kind: "localappdata-folder"), + }) + { + var root = envLookup(candidate.Env); + if (string.IsNullOrEmpty(root)) continue; + var path = Path.Combine(root, candidate.Sub); + if (!Directory.Exists(path)) continue; + + long byteCount = 0; + int fileCount = 0; + try + { + foreach (var f in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + fileCount++; + byteCount += new FileInfo(f).Length; + } + } + catch { /* best-effort size; reporting still useful */ } + + yield return new OrphanItem( + Kind: candidate.Kind, + Name: path, + Detail: $"{fileCount} file(s), {byteCount} byte(s)"); + } + } + + private static IEnumerable DetectRegistryOrphans() + { + // openclaw:// URI scheme (unpackaged-only; PR #310 path). Packaged + // installs use the windows.protocol manifest extension and there is + // nothing in HKCU\Software\Classes for them. + if (TryRegistryKeyExists(Registry.CurrentUser, @"Software\Classes\openclaw", out var detail)) + { + yield return new OrphanItem("registry-uri-scheme", + @"HKCU\Software\Classes\openclaw", + detail); + } + + // HKCU\...\Run entry for the legacy auto-start path (now superseded by + // the MSIX StartupTask extension; an orphan here would silently re-launch + // the no-longer-installed exe at sign-in). + if (TryRegistryValueExists(Registry.CurrentUser, + @"Software\Microsoft\Windows\CurrentVersion\Run", "OpenClawTray", out var runDetail)) + { + yield return new OrphanItem("registry-run-key", + @"HKCU\Software\Microsoft\Windows\CurrentVersion\Run\OpenClawTray", + runDetail); + } + } + + private static bool TryRegistryKeyExists(RegistryKey root, string path, out string detail) + { + detail = "registry key present"; + try + { + using var key = root.OpenSubKey(path, writable: false); + return key != null; + } + catch + { + return false; + } + } + + private static bool TryRegistryValueExists(RegistryKey root, string path, string valueName, out string detail) + { + detail = $"value '{valueName}' present"; + try + { + using var key = root.OpenSubKey(path, writable: false); + if (key == null) return false; + return key.GetValue(valueName) != null; + } + catch + { + return false; + } + } + + private static async Task RemoveAsync(OrphanItem orphan, TextWriter stderr) + { + switch (orphan.Kind) + { + case "wsl-distro": + await RunWslUnregister(orphan.Name, stderr); + break; + case "appdata-folder": + case "localappdata-folder": + Directory.Delete(orphan.Name, recursive: true); + break; + case "registry-uri-scheme": + Registry.CurrentUser.DeleteSubKeyTree(@"Software\Classes\openclaw", throwOnMissingSubKey: false); + break; + case "registry-run-key": + using (var key = Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\CurrentVersion\Run", writable: true)) + { + key?.DeleteValue("OpenClawTray", throwOnMissingValue: false); + } + break; + default: + throw new InvalidOperationException($"Unknown orphan kind: {orphan.Kind}"); + } + } + + private static async Task RunWslUnregister(string distroName, TextWriter stderr) + { + // We deliberately do NOT shell out via cmd /c — wsl.exe arguments don't + // need quoting in this case and going through cmd lets a maliciously + // named distro inject extra commands. + var psi = new ProcessStartInfo("wsl.exe", $"--unregister {distroName}") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + var proc = Process.Start(psi) + ?? throw new InvalidOperationException("wsl.exe failed to launch"); + await proc.WaitForExitAsync(); + if (proc.ExitCode != 0) + { + var err = await proc.StandardError.ReadToEndAsync(); + throw new InvalidOperationException( + $"wsl --unregister {distroName} exited {proc.ExitCode}: {err.Trim()}"); + } + } + + private static void WriteHumanReport(PurgeReport report, TextWriter stdout) + { + if (report.Orphans.Count == 0) + { + stdout.WriteLine("No OpenClaw orphans detected."); + return; + } + + stdout.WriteLine($"Detected {report.Orphans.Count} orphan(s):"); + foreach (var o in report.Orphans) + { + stdout.WriteLine($" [{o.Kind}] {o.Name} — {o.Detail}"); + } + + if (!report.ConfirmDestructive) + { + stdout.WriteLine(); + stdout.WriteLine("Dry-run; pass --confirm-destructive to actually remove them."); + return; + } + + if (report.Removed.Count > 0) + { + stdout.WriteLine(); + stdout.WriteLine($"Removed {report.Removed.Count}:"); + foreach (var o in report.Removed) + { + stdout.WriteLine($" [{o.Kind}] {o.Name}"); + } + } + if (report.Failed.Count > 0) + { + stdout.WriteLine(); + stdout.WriteLine($"Failed to remove {report.Failed.Count}:"); + foreach (var o in report.Failed) + { + stdout.WriteLine($" [{o.Kind}] {o.Name} — {o.Detail}"); + } + } + } +} diff --git a/src/OpenClaw.WinNode.Cli/Program.cs b/src/OpenClaw.WinNode.Cli/Program.cs index b5b34b710..fd088a8e8 100644 --- a/src/OpenClaw.WinNode.Cli/Program.cs +++ b/src/OpenClaw.WinNode.Cli/Program.cs @@ -56,6 +56,18 @@ public static async Task RunAsync( return args.Length == 0 ? 2 : 0; } + // Standalone subcommands intercepted BEFORE argument parsing because + // they don't need a --command and bypass the MCP transport entirely. + // Currently: --purge-wsl-orphans (recovery path for users who removed + // the MSIX without running the in-app "Reset & remove" first; see + // docs/uninstall-msix.md). + if (args.Contains("--purge-wsl-orphans")) + { + var confirm = args.Contains("--confirm-destructive"); + var json = args.Contains("--json-output"); + return await OrphanPurger.RunAsync(confirm, json, stdout, stderr, envLookup); + } + WinNodeOptions options; try { @@ -800,10 +812,20 @@ internal static void PrintUsage(TextWriter stdout) stdout.WriteLine(" --verbose Print endpoint + ignored flags to stderr"); stdout.WriteLine(" --help, -h Show this help"); stdout.WriteLine(); + stdout.WriteLine("Recovery subcommands (do not require --command):"); + stdout.WriteLine(" --purge-wsl-orphans Detect WSL distros / %APPDATA% files / openclaw://"); + stdout.WriteLine(" registry keys left behind by a Remove-AppxPackage that"); + stdout.WriteLine(" skipped the in-app Reset & remove. Dry-run by default;"); + stdout.WriteLine(" pass --confirm-destructive to actually delete."); + stdout.WriteLine(" --confirm-destructive Apply the deletions (otherwise dry-run; exit 1 if dirty)"); + stdout.WriteLine(" --json-output Emit the orphan/removed/failed report as JSON"); + stdout.WriteLine(); stdout.WriteLine("Examples:"); stdout.WriteLine(" winnode --command system.which --params '{\"bins\":[\"git\",\"node\"]}'"); stdout.WriteLine(" winnode --command screen.snapshot"); stdout.WriteLine(" winnode --command canvas.present --params '{\"url\":\"https://example.com\"}'"); + stdout.WriteLine(" winnode --purge-wsl-orphans --json-output # dry-run"); + stdout.WriteLine(" winnode --purge-wsl-orphans --confirm-destructive --json-output"); stdout.WriteLine(); stdout.WriteLine("See skill.md (next to this exe) for the full agent reference: every supported"); stdout.WriteLine("command, its argument schema, and the A2UI v0.8 JSONL grammar."); diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs deleted file mode 100644 index 2e0698de2..000000000 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -namespace OpenClaw.Tray.Tests; - -/// -/// Structural assertions on installer.iss. These pin contracts that cannot -/// be exercised by an in-process unit test because they require ISCC + the -/// resulting unins000.exe to verify end-to-end. -/// -/// Round 2 (Scott #5) — AppMutex coordination prevents the Inno uninstaller -/// from racing the running tray on shared state (settings.json, -/// gateways.json, device-key-ed25519.json, Logs/). The mutex name must -/// match App.xaml.cs's single-instance mutex. -/// -public sealed class InstallerIssAssertionTests -{ - private static string GetRepositoryRoot() - { - var envRepoRoot = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); - if (!string.IsNullOrWhiteSpace(envRepoRoot) && Directory.Exists(envRepoRoot)) - return envRepoRoot; - - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory != null) - { - if ((Directory.Exists(Path.Combine(directory.FullName, ".git")) || - File.Exists(Path.Combine(directory.FullName, ".git"))) && - File.Exists(Path.Combine(directory.FullName, "README.md"))) - return directory.FullName; - directory = directory.Parent; - } - - throw new InvalidOperationException( - "Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path."); - } - - [Fact] - public void Installer_HasAppMutexMatchingTraySingleInstance() - { - var iss = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "installer.iss")); - Assert.Contains("AppMutex=OpenClawTray", iss); - - // The matching tray-side mutex name must be present in App.xaml.cs. - var appXamlCs = File.ReadAllText(Path.Combine( - GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); - Assert.Contains("var mutexName = \"OpenClawTray\";", appXamlCs); - } - - /// - /// Round 2 (Bot B2) — the keepalive process killer must use - /// entireProcessTree:true. wsl.exe spawns wslhost.exe and in-distro - /// processes; killing only the parent leaves children holding distro - /// state and blocks wsl --unregister. Spawning a real WSL process tree - /// in a unit test is brittle, so we pin the source contract instead. - /// - [Fact] - public void StopKeepalive_KillsEntireProcessTree_SourceAssertion() - { - var src = File.ReadAllText(Path.Combine( - GetRepositoryRoot(), - "src", "OpenClaw.Tray.WinUI", "Services", "LocalGatewaySetup", - "LocalGatewayUninstall.cs")); - - Assert.Contains("proc.Kill(entireProcessTree: true)", src); - Assert.DoesNotContain("proc.Kill(entireProcessTree: false)", src); - } -} diff --git a/tests/OpenClaw.Tray.Tests/PermissionCheckerPackagedMappingTests.cs b/tests/OpenClaw.Tray.Tests/PermissionCheckerPackagedMappingTests.cs index d4a7cdf69..49c81dbb4 100644 --- a/tests/OpenClaw.Tray.Tests/PermissionCheckerPackagedMappingTests.cs +++ b/tests/OpenClaw.Tray.Tests/PermissionCheckerPackagedMappingTests.cs @@ -11,10 +11,9 @@ namespace OpenClaw.Tray.Tests; /// We cannot instantiate a real AppCapability from this xUnit process /// — the WinRT factory throws E_FAIL outside an MSIX-launched host — and the /// tray.Tests target is net10.0 (not net10.0-windows), so we cannot even import -/// the WinRT enum. Following the precedent of , -/// we therefore pin the contract as source-text assertions on the production -/// switch arms. If you reorder or retitle these arms, also update this test — -/// silent drift here would silently bypass capability consent on MSIX users. +/// the WinRT enum. We pin the contract as source-text assertions on the +/// production switch arms (see also the manifest assertions in +/// MsixManifestAssertionTests). /// public sealed class PermissionCheckerPackagedMappingTests { diff --git a/tests/PackagingTests/Test-InnoUninstallOrdering.ps1 b/tests/PackagingTests/Test-InnoUninstallOrdering.ps1 deleted file mode 100644 index f1385d585..000000000 --- a/tests/PackagingTests/Test-InnoUninstallOrdering.ps1 +++ /dev/null @@ -1,637 +0,0 @@ -<# -.SYNOPSIS - Packaging test — verifies that Inno Setup's [UninstallRun] entry for - Uninstall-LocalGateway.ps1 runs BEFORE {app} directory deletion. - -.DESCRIPTION - WHAT THIS TEST VERIFIES - ----------------------- - RubberDucky finding 8 requires a packaging test that proves the script at - {app}\Uninstall-LocalGateway.ps1 can run (and does run) BEFORE Inno Setup - deletes the {app}\ directory during a silent uninstall. - - HOW IT WORKS - ------------ - 1. [BUILD] Require a pre-built Inno installer (.exe) via -InstallerPath, or - attempt to locate one in the expected build output path. - 2. [INSTALL] Run the installer silently to a temp prefix directory. - 3. [VERIFY] Assert that {app}\OpenClaw.Tray.WinUI.exe and - {app}\Uninstall-LocalGateway.ps1 both exist post-install. - 4. [UNINSTALL] Run unins000.exe /VERYSILENT /LOG=. - 5. [PARSE LOG] Grep the Inno uninstall log for: - a) Evidence that Uninstall-LocalGateway.ps1 was invoked (or - that the [UninstallRun] powershell entry ran). - b) Evidence that {app}\ directory was deleted. - c) Ordering: (a) appears BEFORE (b) in the log (by line number). - 6. [CLEANUP] Remove temp install directory and any WSL residual state created - by the test. (A fresh Inno install with no real gateway means - no WSL distro is ever registered, so WSL cleanup is a no-op.) - 7. [VERDICT] - PASS = files existed post-install AND hook line found before dir- - deletion line in the uninstall log. - FAIL = any of: files missing post-install, hook did not run, - hook ran AFTER directory deletion, or uninstall crashed. - SKIP = no Inno installer available at the expected/given path. - - NOTES ON INNO LOG FORMAT - ------------------------ - When Inno runs with /LOG= it writes a plain-text log with entries like: - Log opened. (YYYY-MM-DD) - ... - -- Run entry #0: Filename: powershell.exe ...Uninstall-LocalGateway.ps1... - ... - Dir: C:\...\OpenClawTray (directory): deleted. - Line ordering is the authoritative source of truth for the ordering check. - -.PARAMETER InstallerPath - Absolute path to the Inno-produced installer EXE. If omitted the test - searches standard build-output locations (publish-x64\installer\, - Output\OpenClawTray-Setup-x64.exe). If still not found the test exits - with SKIP. - -.PARAMETER TempInstallDir - Base directory under which a unique per-run subdirectory is created for - the test installation. Defaults to $env:TEMP\InnoOrderingTest. - The test cleans up this directory after completion (pass or fail). - -.PARAMETER KeepTempDir - When set, do NOT remove the temp install directory after the test. - Use for post-mortem investigation of a FAIL result. - -.PARAMETER OutputDir - Directory to write test artifacts (log, verdict.json, summary.md). - Defaults to .\packaging-test-output\\. - -.EXAMPLE - # Typical run (will locate installer automatically): - .\Test-InnoUninstallOrdering.ps1 - -.EXAMPLE - # Explicit installer path: - .\Test-InnoUninstallOrdering.ps1 -InstallerPath C:\build\OpenClawTray-Setup-x64.exe - -.EXAMPLE - # Keep temp dir for debugging: - .\Test-InnoUninstallOrdering.ps1 -InstallerPath C:\build\OpenClawTray-Setup-x64.exe -KeepTempDir - -.NOTES - Date: 2026-05-07 - Author: Bostick (Tester / Quality / Validation) - Branch: feat/wsl-gateway-uninstall - - Style mirrors validate-wsl-gateway-uninstall.ps1: - - Set-StrictMode -Version Latest - - $ErrorActionPreference = 'Stop' - - Structured step logging - - Stops processes by PID only - - No \\wsl$ or \\wsl.localhost paths - - EXIT CODES - ---------- - 0 PASS Ordering confirmed: hook ran, app dir deleted after. - 1 FAIL Ordering wrong, files missing, hook didn't run, or uninstall crashed. - 2 SKIP No installer available; test cannot run on this machine. - 3 ERROR Unexpected script error. -#> - -[CmdletBinding()] -param( - [string]$InstallerPath = "", - [string]$TempInstallDir = "", - [switch]$KeepTempDir, - [string]$OutputDir = "" -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -# --------------------------------------------------------------------------- -# Exit-code sentinels -# --------------------------------------------------------------------------- -$EXIT_PASS = 0 -$EXIT_FAIL = 1 -$EXIT_SKIP = 2 -$EXIT_ERROR = 3 - -# --------------------------------------------------------------------------- -# Script-level state -# --------------------------------------------------------------------------- -$script:steps = [System.Collections.Generic.List[object]]::new() -$script:verdict = 'UNKNOWN' -$utcStamp = (Get-Date).ToUniversalTime().ToString("yyyyMMdd-HHmmssZ") - -if ([string]::IsNullOrEmpty($OutputDir)) { - $OutputDir = Join-Path (Get-Location) "packaging-test-output\$utcStamp" -} -New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null - -# --------------------------------------------------------------------------- -# Logging helpers (mirror validate-wsl-gateway patterns) -# --------------------------------------------------------------------------- -function Add-Step { - param( - [string]$Name, - [string]$Status, # Passed | Failed | Skipped | Warning | Info - [string]$Message, - [hashtable]$Data = @{} - ) - $entry = [ordered]@{ - name = $Name - status = $Status - message = $Message - data = $Data - timestamp = (Get-Date).ToString("o") - } - $script:steps.Add($entry) - - $ts = (Get-Date).ToString("HH:mm:ss") - $color = switch ($Status) { - "Passed" { "Green" } - "Failed" { "Red" } - "Skipped" { "DarkGray" } - "Warning" { "Yellow" } - "Info" { "Cyan" } - default { "White" } - } - Write-Host "[$ts] [$Status] $Name — $Message" -ForegroundColor $color -} - -function Write-Info { - param([string]$Message) - $ts = (Get-Date).ToString("HH:mm:ss") - Write-Host "[$ts] $Message" -ForegroundColor DarkCyan -} - -# --------------------------------------------------------------------------- -# Write verdict JSON + summary MD -# --------------------------------------------------------------------------- -function Write-Results { - param( - [string]$Verdict, - [string]$Notes = "", - [int]$ExitCode = $EXIT_FAIL - ) - - $verdictData = [ordered]@{ - verdict = $Verdict - exit_code = $ExitCode - notes = $Notes - started_at = $script:startedAt - finished_at = (Get-Date).ToString("o") - installer = $script:installerPath - temp_dir = $script:tempInstallPath - output_dir = $OutputDir - steps = @($script:steps) - } - - $verdictPath = Join-Path $OutputDir "verdict.json" - $verdictData | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $verdictPath -Encoding UTF8 - - $summaryPath = Join-Path $OutputDir "summary.md" - $lines = @( - "# Inno Uninstall Ordering Test", - "", - "| Field | Value |", - "|-----------|-------|", - "| Verdict | $Verdict |", - "| ExitCode | $ExitCode |", - "| Installer | $($script:installerPath) |", - "| OutputDir | $OutputDir |", - "| Date | 2026-05-07 |", - "", - "## Notes", "", - $Notes, "", - "## Steps", "" - ) - foreach ($s in $script:steps) { - $lines += "- [$($s.status)] $($s.name): $($s.message)" - } - $lines | Set-Content -LiteralPath $summaryPath -Encoding UTF8 - - $verdictColor = switch ($Verdict) { - "PASS" { "Green" } - "SKIP" { "Cyan" } - "FAIL" { "Red" } - "ERROR" { "Red" } - default { "Yellow" } - } - Write-Host "" - Write-Host "════════════════════════════════════════" -ForegroundColor $verdictColor - Write-Host " VERDICT : $Verdict" -ForegroundColor $verdictColor - Write-Host " ExitCode : $ExitCode" -ForegroundColor $verdictColor - Write-Host " Output : $OutputDir" -ForegroundColor $verdictColor - Write-Host "════════════════════════════════════════" -ForegroundColor $verdictColor - Write-Host "" -} - -# --------------------------------------------------------------------------- -# Installer locator -# --------------------------------------------------------------------------- -function Find-Installer { - # Caller-supplied hint - if (-not [string]::IsNullOrEmpty($InstallerPath) -and (Test-Path -LiteralPath $InstallerPath)) { - return $InstallerPath - } - - # Script is in tests\PackagingTests\ → repo root is 3 levels up - $repoRoot = Split-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -Parent - $candidates = @( - (Join-Path $repoRoot "Output\OpenClawTray-Setup-x64.exe"), - (Join-Path $repoRoot "installer-output\OpenClawTray-Setup-x64.exe"), - (Join-Path $repoRoot "publish-x64\installer\OpenClawTray-Setup-x64.exe") - ) - foreach ($c in $candidates) { - if (Test-Path -LiteralPath $c) { return $c } - } - - # Search Output/ recursively for any matching file - foreach ($searchRoot in @((Join-Path $repoRoot "Output"), (Join-Path $repoRoot "installer-output"))) { - if (Test-Path -LiteralPath $searchRoot) { - $found = Get-ChildItem -LiteralPath $searchRoot -Recurse -Filter "OpenClawTray-Setup*.exe" -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 - if ($found) { return $found.FullName } - } - } - - return $null -} - -# --------------------------------------------------------------------------- -# Parse Inno uninstall log for ordering evidence -# --------------------------------------------------------------------------- -function Test-UninstallLogOrdering { - param([string]$LogPath) - - if (-not (Test-Path -LiteralPath $LogPath)) { - return [ordered]@{ - log_found = $false - hook_line_index = -1 - dir_delete_line_index = -1 - hook_ran = $false - dir_deleted = $false - ordering_correct = $false - notes = "Log file not found: $LogPath" - } - } - - $lines = Get-Content -LiteralPath $LogPath -Encoding UTF8 -ErrorAction SilentlyContinue - - $hookIdx = -1 - $dirDelIdx = -1 - - for ($i = 0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - - # Hook evidence: Inno logs [UninstallRun] execution in multiple ways: - # "-- Run entry #0:" (start of run-entry block) - # "Executing: powershell.exe" ... "Uninstall-LocalGateway" (run detail) - # "Process exit code: 0" (completion) - # We key on the first mention of the script name in the run section. - if ($hookIdx -eq -1) { - if ($line -match 'Uninstall-LocalGateway' -or - $line -match 'UninstallLocalGateway' -or - ($line -match 'Run entry' -and $line -match 'powershell') -or - ($line -match 'Exec:.*powershell.*Uninstall') -or - ($line -match 'StatusMsg.*Removing local WSL gateway')) { - $hookIdx = $i - } - } - - # Directory deletion evidence: - # "Dir: C:\...\OpenClawTray (directory): deleted." - # "Deleting directory: C:\..." - if ($dirDelIdx -eq -1) { - if (($line -match '(?i)deleting directory') -or - ($line -match '(?i)Dir:.*directory.*delet')) { - # Ensure it's the {app} directory, not a subdirectory cleanup - if ($line -match 'OpenClawTray' -or $line -match [regex]::Escape($script:appDirPattern)) { - $dirDelIdx = $i - } - } - } - } - - $hookRan = ($hookIdx -ge 0) - $dirDeleted = ($dirDelIdx -ge 0) - $orderingOk = $hookRan -and $dirDeleted -and ($hookIdx -lt $dirDelIdx) - - return [ordered]@{ - log_found = $true - log_line_count = $lines.Count - hook_line_index = $hookIdx - hook_line_text = if ($hookIdx -ge 0) { $lines[$hookIdx] } else { "" } - dir_delete_line_index = $dirDelIdx - dir_delete_line_text = if ($dirDelIdx -ge 0) { $lines[$dirDelIdx] } else { "" } - hook_ran = $hookRan - dir_deleted = $dirDeleted - ordering_correct = $orderingOk - notes = if ($orderingOk) { - "hook at line $hookIdx < dir-delete at line $dirDelIdx — ordering CORRECT" - } elseif (-not $hookRan) { - "hook entry not found in log — [UninstallRun] may not have run" - } elseif (-not $dirDeleted) { - "dir-delete entry not found in log — check Inno verbosity" - } else { - "ORDERING WRONG: hook at line $hookIdx >= dir-delete at line $dirDelIdx" - } - } -} - -# --------------------------------------------------------------------------- -# WSL residual cleanup (no-op for a clean install with no gateway) -# --------------------------------------------------------------------------- -function Invoke-WslCleanupCheck { - $wslLines = @() - try { - $raw = & wsl --list --quiet 2>&1 - $wslLines = ($raw | Out-String) -split "`r?`n" | - ForEach-Object { ($_ -replace '\x00', '').Trim() } | - Where-Object { $_ } - } - catch { } - - $openClawDistros = @($wslLines | Where-Object { $_ -like '*OpenClawGateway*' }) - if ($openClawDistros.Count -gt 0) { - Add-Step "wsl-cleanup-check" "Warning" "$($openClawDistros.Count) OpenClawGateway distro(s) still registered after uninstall. Unexpected for a fresh-install test." @{ - distros = $openClawDistros - } - } - else { - Add-Step "wsl-cleanup-check" "Passed" "No OpenClawGateway WSL distros registered (expected for a fresh-install test)." - } -} - -# --------------------------------------------------------------------------- -# MAIN -# --------------------------------------------------------------------------- -$script:startedAt = (Get-Date).ToString("o") -$script:installerPath = "" -$script:tempInstallPath = "" -$script:appDirPattern = "" - -Write-Host "" -Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan -Write-Host "║ Test-InnoUninstallOrdering.ps1 (2026-05-07) ║" -ForegroundColor Cyan -Write-Host "║ Verifies [UninstallRun] hook runs BEFORE {app} deletion ║" -ForegroundColor Cyan -Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan -Write-Host " OutputDir : $OutputDir" -Write-Host "" - -$exitCode = $EXIT_ERROR - -try { - - # ===================================================================== - # STEP 1 — Locate installer - # ===================================================================== - $foundInstaller = Find-Installer - if ([string]::IsNullOrEmpty($foundInstaller)) { - Add-Step "locate-installer" "Skipped" "No Inno installer found. Pass -InstallerPath to specify one explicitly." @{ - searchedPaths = @("Output\OpenClawTray-Setup-x64.exe", - "installer-output\OpenClawTray-Setup-x64.exe", - "publish-x64\installer\OpenClawTray-Setup-x64.exe") - } - Write-Info "SKIP: No installer available on this machine. Build the installer first or use -InstallerPath." - Write-Results -Verdict "SKIP" -ExitCode $EXIT_SKIP ` - -Notes "Installer not found. Build with 'iscc installer.iss' or pass -InstallerPath." - exit $EXIT_SKIP - } - - $script:installerPath = $foundInstaller - Add-Step "locate-installer" "Passed" "Installer found: $foundInstaller" - Write-Info "Installer: $foundInstaller" - - # ===================================================================== - # STEP 2 — Create a temp install prefix - # ===================================================================== - if ([string]::IsNullOrEmpty($TempInstallDir)) { - $TempInstallDir = Join-Path $env:TEMP "InnoOrderingTest" - } - $runId = [System.Guid]::NewGuid().ToString("N").Substring(0, 8) - $tempInstallPath = Join-Path $TempInstallDir "run-$runId" - New-Item -ItemType Directory -Force -Path $tempInstallPath | Out-Null - $script:tempInstallPath = $tempInstallPath - $script:appDirPattern = $tempInstallPath # Inno will install to this dir - - Add-Step "create-temp-dir" "Passed" "Temp install prefix: $tempInstallPath" - Write-Info "Temp install dir: $tempInstallPath" - - # ===================================================================== - # STEP 3 — Silent install - # ===================================================================== - Write-Info "Running silent install..." - $installLog = Join-Path $OutputDir "install.log" - $installArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', - "/DIR=$tempInstallPath", "/LOG=$installLog") - - try { - $proc = Start-Process -FilePath $foundInstaller -ArgumentList $installArgs ` - -Wait -PassThru -WindowStyle Hidden - $installExitCode = $proc.ExitCode - Add-Step "silent-install" "Passed" "Installer exited $installExitCode." @{ - installerPath = $foundInstaller - installDir = $tempInstallPath - logPath = $installLog - exitCode = $installExitCode - } - Write-Info "Install exit code: $installExitCode" - } - catch { - Add-Step "silent-install" "Failed" "Installer threw: $($_.Exception.Message)" - Write-Results -Verdict "FAIL" -ExitCode $EXIT_FAIL ` - -Notes "Silent install threw an exception: $($_.Exception.Message)" - exit $EXIT_FAIL - } - - # ===================================================================== - # STEP 4 — Verify post-install file presence - # ===================================================================== - $exePath = Join-Path $tempInstallPath "OpenClaw.Tray.WinUI.exe" - $hookScriptPath = Join-Path $tempInstallPath "Uninstall-LocalGateway.ps1" - - $exeExists = Test-Path -LiteralPath $exePath - $hookScriptExists = Test-Path -LiteralPath $hookScriptPath - - if (-not $exeExists -or -not $hookScriptExists) { - Add-Step "verify-post-install-files" "Failed" "Expected files missing post-install." @{ - "OpenClaw.Tray.WinUI.exe exists" = $exeExists - "Uninstall-LocalGateway.ps1 exists" = $hookScriptExists - } - Write-Results -Verdict "FAIL" -ExitCode $EXIT_FAIL ` - -Notes "Post-install file check failed. EXE=$exeExists Hook=$hookScriptExists" - exit $EXIT_FAIL - } - - Add-Step "verify-post-install-files" "Passed" "Both required files exist post-install." @{ - exePath = $exePath - hookScriptPath = $hookScriptPath - } - - # ===================================================================== - # STEP 5 — Locate unins000.exe - # ===================================================================== - $uninsExe = Join-Path $tempInstallPath "unins000.exe" - if (-not (Test-Path -LiteralPath $uninsExe)) { - # Inno may produce unins001.exe etc. if a previous install left a unins000. - $uninsExe = Get-ChildItem -LiteralPath $tempInstallPath -Filter "unins*.exe" ` - -ErrorAction SilentlyContinue | Sort-Object Name | Select-Object -First 1 | - ForEach-Object { $_.FullName } - if ([string]::IsNullOrEmpty($uninsExe)) { - Add-Step "locate-uninstaller" "Failed" "unins000.exe not found in $tempInstallPath" - Write-Results -Verdict "FAIL" -ExitCode $EXIT_FAIL -Notes "Inno uninstaller not found." - exit $EXIT_FAIL - } - } - Add-Step "locate-uninstaller" "Passed" "Uninstaller: $uninsExe" - - # ===================================================================== - # STEP 6 — Silent uninstall with log capture - # ===================================================================== - $uninstallLog = Join-Path $OutputDir "uninstall.log" - $uninstallArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', - "/LOG=$uninstallLog") - - Write-Info "Running silent uninstall..." - try { - $proc2 = Start-Process -FilePath $uninsExe -ArgumentList $uninstallArgs ` - -Wait -PassThru -WindowStyle Hidden - $uninstallExitCode = $proc2.ExitCode - Add-Step "silent-uninstall" "Passed" "Uninstaller exited $uninstallExitCode." @{ - uninsExe = $uninsExe - logPath = $uninstallLog - exitCode = $uninstallExitCode - } - Write-Info "Uninstall exit code: $uninstallExitCode" - } - catch { - Add-Step "silent-uninstall" "Failed" "Uninstaller threw: $($_.Exception.Message)" - Write-Results -Verdict "FAIL" -ExitCode $EXIT_FAIL ` - -Notes "Silent uninstall threw: $($_.Exception.Message)" - exit $EXIT_FAIL - } - - # ===================================================================== - # STEP 7 — Parse log: verify hook ran AND ordering is correct - # ===================================================================== - Write-Info "Parsing uninstall log for ordering evidence..." - $ordering = Test-UninstallLogOrdering -LogPath $uninstallLog - - # Save ordering analysis - $orderingPath = Join-Path $OutputDir "ordering-analysis.json" - $ordering | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $orderingPath -Encoding UTF8 - - $orderingStatus = if ($ordering.ordering_correct) { "Passed" } ` - elseif (-not $ordering.log_found) { "Warning" } ` - else { "Failed" } - - Add-Step "log-ordering-check" $orderingStatus $ordering.notes @{ - log_path = $uninstallLog - hook_line = $ordering.hook_line_index - hook_line_text = $ordering.hook_line_text - dir_delete_line = $ordering.dir_delete_line_index - dir_delete_line_text = $ordering.dir_delete_line_text - ordering_correct = $ordering.ordering_correct - analysis_file = $orderingPath - } - - # ===================================================================== - # STEP 7b — Supplemental: verify {app} dir was deleted after uninstall - # ===================================================================== - $appDirGone = -not (Test-Path -LiteralPath $tempInstallPath) - if ($appDirGone) { - Add-Step "verify-app-dir-deleted" "Passed" "{app} directory removed by uninstaller (expected)." - } - else { - Add-Step "verify-app-dir-deleted" "Warning" "{app} directory still exists after uninstall: $tempInstallPath" - } - - # ===================================================================== - # STEP 8 — WSL cleanup check - # ===================================================================== - Invoke-WslCleanupCheck - - # ===================================================================== - # STEP 9 — Final verdict - # ===================================================================== - - # Determine if the log ordering check was conclusive. - # If the log wasn't found or the hook was not in it, try a weaker check: - # look for evidence in the uninstall log that Uninstall-LocalGateway.ps1 was - # at least attempted (it may exit 0 quietly without verbose log entries). - $hookConfirmed = $ordering.hook_ran - - if (-not $hookConfirmed -and $ordering.log_found) { - # Secondary check: scan raw log for the script name anywhere - $rawLog = Get-Content -LiteralPath $uninstallLog -Raw -Encoding UTF8 -ErrorAction SilentlyContinue - if ($rawLog -match 'Uninstall-LocalGateway') { - Add-Step "secondary-hook-check" "Passed" "Secondary scan found 'Uninstall-LocalGateway' in uninstall log." - $hookConfirmed = $true - } - else { - Add-Step "secondary-hook-check" "Warning" "'Uninstall-LocalGateway' not found anywhere in uninstall log. The [UninstallRun] entry may not have been executed." - } - } - elseif (-not $ordering.log_found) { - Add-Step "secondary-hook-check" "Warning" "Cannot perform secondary check: uninstall log not found." - } - - # Determine pass/fail/skip - if (-not $ordering.log_found) { - # No log = can't confirm ordering; FAIL with guidance - $finalVerdict = "FAIL" - $notes = "Uninstall log not produced. Ensure Inno's /LOG= switch works for this installer version." - $exitCode = $EXIT_FAIL - } - elseif ($ordering.ordering_correct) { - $finalVerdict = "PASS" - $notes = $ordering.notes - $exitCode = $EXIT_PASS - } - elseif (-not $hookConfirmed) { - $finalVerdict = "FAIL" - $notes = "Hook not confirmed in log. [UninstallRun] entry may be missing or not triggered." - $exitCode = $EXIT_FAIL - } - else { - $finalVerdict = "FAIL" - $notes = $ordering.notes - $exitCode = $EXIT_FAIL - } - - $script:verdict = $finalVerdict - Write-Results -Verdict $finalVerdict -ExitCode $exitCode -Notes $notes - -} -catch { - $errMsg = $_.Exception.Message - Add-Step "unhandled-error" "Failed" $errMsg - Write-Host "ERROR: $errMsg" -ForegroundColor Red - Write-Results -Verdict "ERROR" -ExitCode $EXIT_ERROR -Notes $errMsg - $exitCode = $EXIT_ERROR -} -finally { - # Cleanup temp install directory unless -KeepTempDir or it was already removed by uninstall - if (-not $KeepTempDir -and -not [string]::IsNullOrEmpty($script:tempInstallPath)) { - if (Test-Path -LiteralPath $script:tempInstallPath) { - try { - Remove-Item -LiteralPath $script:tempInstallPath -Recurse -Force -ErrorAction SilentlyContinue - Write-Info "Temp install dir removed: $($script:tempInstallPath)" - } - catch { - Write-Info "Warning: could not remove temp dir: $($script:tempInstallPath) — $($_.Exception.Message)" - } - } - } - - # Also remove the parent temp base dir if it was auto-created and is now empty - if (-not $KeepTempDir -and -not [string]::IsNullOrEmpty($TempInstallDir)) { - if (Test-Path -LiteralPath $TempInstallDir) { - $remaining = @(Get-ChildItem -LiteralPath $TempInstallDir -ErrorAction SilentlyContinue) - if ($remaining.Count -eq 0) { - Remove-Item -LiteralPath $TempInstallDir -Force -ErrorAction SilentlyContinue - } - } - } -} - -exit $exitCode From c1e79f4ffa42a24f70855b20fc5f72abb32c2c46 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 18 May 2026 17:32:50 -0700 Subject: [PATCH 06/56] msix(t6): test automation + manual E2E runbook Automation (T6a): - scripts/test-msix-install.ps1: local-runnable smoke test that walks Add-AppxPackage -> assert package presence/publisher/version -> Start-Process activation -> wait for OpenClawTray-DeepLink named pipe -> send openclaw://health -> Remove-AppxPackage -> orphan check. Used as the automated counterpart to runbook scenarios 1 and 6. - scripts/test-appinstaller-update.ps1: spins up a local HttpListener that serves vN.msix, vN+1.msix and the rendered .appinstaller; walks Add-AppxPackage -AppInstallerFile -> re-render to vN+1 -> PackageManager.AddPackageByAppInstallerFileAsync -> assert Get-AppxPackage reports vN+1. Catches .appinstaller XML / template regressions before they reach a real GitHub release. - tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs: 9 source-text assertions (same precedent as the historical InstallerIssAssertionTests) pinning OrphanWslDistroPrefix, the five orphan-kind names, the exit-code policy (0/1/2), and the dry-run-is-default invariant. The recovery CLI in OpenClaw.WinNode.Cli is internal so we cannot link the assembly into the net10.0 tray-tests target; the source-text approach keeps the contract pinned without forcing the CLI to expose internals. Manual runbook (T6b): - docs/MSIX_E2E_TEST_RUNBOOK.md: 10-scenario release runbook covering clean install, packaged permission consent prompts (with the "package name appears in Settings > Privacy" assertion that catches an accidental fallback to the unpackaged DeviceAccessInformation surface), permission revocation while running (proves AppCapability.AccessChanged is wired), StartupTask (proves we did NOT regress to HKCU\\...\\Run), local-gateway install + clean uninstall, dirty-uninstall + recovery via --purge-wsl-orphans (proves Q1 mitigation works), .appinstaller auto-update across all four trigger paths, sideload trust on a no-dev-mode box, and an ARM64 cross-check. Note: the existing build-msix CI job already runs on every PR (gated only on the test job, not on tags), so PR-time MSIX build coverage is already present in CI; only the *signing* step is tag-gated, which is correct. Validation: ./build.ps1 OK, Shared.Tests 1776 passed / 28 skipped, Tray.Tests 1123 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/MSIX_E2E_TEST_RUNBOOK.md | 177 ++++++++++++++++++ scripts/test-appinstaller-update.ps1 | 158 ++++++++++++++++ scripts/test-msix-install.ps1 | 166 ++++++++++++++++ .../OrphanPurgerContractTests.cs | 92 +++++++++ 4 files changed, 593 insertions(+) create mode 100644 docs/MSIX_E2E_TEST_RUNBOOK.md create mode 100644 scripts/test-appinstaller-update.ps1 create mode 100644 scripts/test-msix-install.ps1 create mode 100644 tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs diff --git a/docs/MSIX_E2E_TEST_RUNBOOK.md b/docs/MSIX_E2E_TEST_RUNBOOK.md new file mode 100644 index 000000000..e326e03b9 --- /dev/null +++ b/docs/MSIX_E2E_TEST_RUNBOOK.md @@ -0,0 +1,177 @@ +# MSIX End-to-End Test Runbook + +Manual test matrix for an OpenClaw Companion MSIX release. Run on a fresh +Windows 11 24H2 VM and on a Windows-on-ARM device before promoting a tag to +`make_latest=true`. + +The automated counterpart lives in: + +- `scripts/test-msix-install.ps1` — install/launch/uninstall smoke test. +- `scripts/test-appinstaller-update.ps1` — `.appinstaller` upgrade simulation. +- `tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs` — manifest contract. +- `tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs` — template contract. + +The runbook below covers the things automation cannot — OS consent dialogs, +multi-launch behaviour, real WSL distros, and the dirty-uninstall recovery. + +## Pre-flight + +- [ ] Clean Windows 11 24H2 VM. No prior `OpenClaw*` package, no `openclaw-*` + WSL distros, no `%APPDATA%\OpenClawTray\` or `%LOCALAPPDATA%\OpenClawTray\`. +- [ ] `wsl --list` reports either no distros or only unrelated distros. +- [ ] Dev Mode **off** in Windows Settings (so sideload trust is exercised + end-to-end through Trusted Signing, not bypassed). + +## Scenarios + +### 1. Clean install via `.appinstaller` + +1. Open the GitHub release page in Edge. +2. Click `latest.appinstaller`. +3. **Assert** Windows AppInstaller opens with: + - Publisher: `CN=Scott Hanselman, O=Scott Hanselman, …` (no "untrusted") + - DisplayName: `OpenClaw Companion` + - Version: the tag version +4. Click **Install**. +5. **Assert** the tray icon appears in the notification area within 5 s. +6. **Assert** `Get-AppxPackage OpenClaw.Companion*` returns one row with the + expected `Publisher` and a 4-part `Version`. +7. **Assert** Settings → Apps shows "OpenClaw Companion" with the AppInstaller + source URL visible under "Installed from". + +### 2. First-run permission consent (packaged path) + +1. On first launch, open Settings → Onboarding → Permissions. +2. **Assert** each row reports a status pulled from the per-package consent + API (the unpackaged code path would have said "denied" or "unknown" here + for camera/mic/location). +3. Trigger an action that uses each capability and **assert** the OS + consent prompt appears once, with **"OpenClaw Companion"** as the app + name (not "Desktop apps", which would mean we accidentally fell back to + the unpackaged DeviceAccessInformation surface). + - Camera: click "Take photo" in the onboarding camera widget. + - Microphone: click "Test microphone". + - Location: click "Use location" in the onboarding wizard. +4. **Assert** Settings → Privacy → Camera (and Microphone, Location) lists + "OpenClaw Companion" with a per-app toggle. + +### 3. Permission revocation while running + +1. With the tray running, open Settings → Privacy → Camera. +2. Turn the **OpenClaw Companion** toggle OFF. +3. **Assert** the tray's Permissions page (Settings → Permissions or the + onboarding row strip) updates within ~1 s without restart — this proves + the `AppCapability.AccessChanged` subscription wired up by + `PermissionChecker.SubscribeToAccessChangesPackaged` is firing. +4. Toggle it back ON and **assert** the row returns to "Granted". + +### 4. StartupTask (replaces the legacy HKCU\\…\\Run autostart) + +1. Open Settings → Auto-start. Toggle **Launch when Windows starts** ON. +2. **Assert** Windows shows the one-time consent dialog for the + `OpenClawCompanionStartup` task. +3. Sign out and back in (or reboot). +4. **Assert** the tray appears in the notification area shortly after sign-in. +5. **Assert** Task Manager → Startup apps lists "OpenClaw Companion" with + status "Enabled". +6. **Assert** `Get-ItemProperty 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name OpenClawTray -EA SilentlyContinue` + is empty — i.e. we did NOT also write the legacy Run key. + +### 5. Local-gateway install + in-app uninstall + +1. Open Onboarding → Local gateway → "Install WSL gateway". Wait for the + distro to register and the tray status to flip to "Connected". +2. **Assert** `wsl --list --quiet` shows `openclaw-local` (or the variant + the install chose). +3. Open Settings → Local Gateway → **Remove Local Gateway**. +4. **Assert** the in-app status reports success; `wsl --list --quiet` no + longer shows the distro; `%LOCALAPPDATA%\OpenClawTray\wsl-keepalive\` + markers are gone. + +### 6. Clean app uninstall + +1. Run the in-app **Settings → "Reset & remove"** (when implemented per + the Track 3 follow-up). Until that lands, run scenario 5 first, then: +2. Settings → Apps → OpenClaw Companion → Uninstall. +3. Reboot. +4. **Assert** `Get-AppxPackage OpenClaw.Companion*` returns nothing. +5. **Assert** the following are absent: `%APPDATA%\OpenClawTray\`, + `%LOCALAPPDATA%\OpenClawTray\`, `HKCU:\Software\Classes\openclaw`, + `HKCU:\Software\Microsoft\Windows\CurrentVersion\Run\OpenClawTray`, + `openclaw-*` WSL distros. + +### 7. Dirty uninstall + recovery (proves `--purge-wsl-orphans`) + +This scenario deliberately skips the in-app cleanup so we can verify the +support recipe works. + +1. Re-install per scenario 1 and re-do scenario 5 up to and including a + working WSL gateway. +2. Without using Settings → Local Gateway, go straight to Settings → Apps + and Uninstall. +3. **Assert** the WSL distro is still present (`wsl --list --quiet` shows + `openclaw-local`) — this is the failure mode we need to recover from. +4. Run the published one-liner from `docs/uninstall-msix.md`: + ```powershell + openclaw-winnode --purge-wsl-orphans --json-output + ``` +5. **Assert** exit code 1 and the JSON report enumerates the orphan distro + and any leftover folders. +6. Run with `--confirm-destructive`: + ```powershell + openclaw-winnode --purge-wsl-orphans --confirm-destructive --json-output + ``` +7. **Assert** exit code 0 and the `Removed` list contains everything from + the earlier `Orphans` list. `wsl --list --quiet` no longer shows the + distro; `%APPDATA%\OpenClawTray\` and `%LOCALAPPDATA%\OpenClawTray\` are + gone. + +### 8. `.appinstaller` auto-update (vN → vN+1) + +1. Install vN via `latest.appinstaller`. +2. Publish vN+1 by tagging `vX.Y.Z+1` and re-uploading the rendered + `latest.appinstaller` to GitHub Pages (the release pipeline produces the + file; the gh-pages publish is currently manual — see RELEASING.md). +3. **Trigger 1 (OnLaunch, passive):** Launch the tray. Wait up to 24 h + (or temporarily set `HoursBetweenUpdateChecks="0"` in a test render to + force the check on this launch). **Assert** the tray exits and relaunches + on the next start at vN+1. +4. **Trigger 2 (in-app, on demand):** From a fresh vN install, click + tray menu → "Check for updates". **Assert** the tray exits within ~5 s + and Windows restarts it at vN+1. **Assert** the in-app status surfaces + `UpdateQueued` (or `Current` if vN+1 wasn't published yet). +5. **Trigger 3 (Windows background scan):** Reinstall vN, sign out, sign + back in, give Windows 1–2 minutes. **Assert** the tray eventually + updates to vN+1 without any user interaction. Note: this trigger is + best-effort per Microsoft docs; do not fail the release if it doesn't + fire — just record the observation. + +### 9. Sideload trust on a stock no-dev-mode box + +1. Fresh Win11 VM, Dev Mode OFF, no developer keys imported. +2. Double-click the `.msix` directly (not the `.appinstaller`) downloaded + from the release. +3. **Assert** the install succeeds with no "untrusted publisher" warning + — the Azure Trusted Signing cert chain is what's being validated here. + +### 10. ARM64 + +1. On a Windows-on-ARM device (Surface Pro X, Snapdragon X laptop), repeat + scenarios 1, 2, 5, 7, 8 against the `-win-arm64.msix`. +2. **Assert** every consent dialog still shows the package name "OpenClaw + Companion" (no name mangling on ARM64 manifests). +3. **Assert** scenario 8 step 3 also works — `.appinstaller` is + architecture-aware and Windows picks the ARM64 MSIX from the same URL. + +## Recording results + +Record outcomes per scenario in the release tracking issue with: + +- Build tag tested +- OS build (winver) +- Architecture (x64 / arm64) +- Pass / Fail / Skip +- Notes for any partial passes or unexpected dialogs + +Promote `latest.appinstaller` to GitHub Pages only after scenarios 1, 2, 5, +6, 7, 8 (triggers 1 and 2), 9, and 10 all pass on at least one VM. diff --git a/scripts/test-appinstaller-update.ps1 b/scripts/test-appinstaller-update.ps1 new file mode 100644 index 000000000..83cc9927b --- /dev/null +++ b/scripts/test-appinstaller-update.ps1 @@ -0,0 +1,158 @@ +<# +.SYNOPSIS + Simulates a non-Store .appinstaller upgrade by hosting two MSIX versions + on a local HTTP server and walking the install vN -> publish vN+1 -> + trigger upgrade flow end-to-end. + +.DESCRIPTION + The point of this script is to catch regressions in the .appinstaller + XML and the PackageManager.AddPackageByAppInstallerFileAsync wiring + *without* needing a real GitHub release / GitHub Pages cycle. Run this + before a release tag goes out; if it fails, the same failure will happen + to every user that installs from latest.appinstaller. + + Steps: + 1. Launch a tiny HTTP server (HttpListener) on localhost:8765 that serves + the two MSIX files + a rendered .appinstaller pointing at vN+1. + 2. Render an "old" .appinstaller pointing at vN, install it (this records + the source URL with Windows AppInstaller). + 3. Re-render the .appinstaller in place pointing at vN+1. + 4. Invoke PackageManager.AddPackageByAppInstallerFileAsync against the + local URL — this is the same call the in-app "Check for updates" + button makes. + 5. Assert Get-AppxPackage reports the new Version. + 6. Tear down. + +.PARAMETER MsixVnPath + Path to the "older" .msix (used as the seed install). + +.PARAMETER MsixVn1Path + Path to the "newer" .msix (used as the upgrade target). + +.PARAMETER VnVersion + 4-part version of the older .msix (e.g. 0.5.3.0). + +.PARAMETER Vn1Version + 4-part version of the newer .msix (e.g. 0.5.4.0). + +.PARAMETER Publisher + Publisher subject that must match BOTH MSIX manifests. + +.EXAMPLE + ./scripts/test-appinstaller-update.ps1 ` + -MsixVnPath .\OpenClawCompanion-0.5.3-win-x64.msix -VnVersion 0.5.3.0 ` + -MsixVn1Path .\OpenClawCompanion-0.5.4-win-x64.msix -Vn1Version 0.5.4.0 +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $MsixVnPath, + [Parameter(Mandatory)] [string] $VnVersion, + [Parameter(Mandatory)] [string] $MsixVn1Path, + [Parameter(Mandatory)] [string] $Vn1Version, + [string] $Publisher = 'CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US', + [int] $Port = 8765 +) + +$ErrorActionPreference = 'Stop' + +foreach ($p in @($MsixVnPath, $MsixVn1Path)) { + if (-not (Test-Path $p)) { throw "MSIX not found: $p" } +} + +$tmp = Join-Path ([System.IO.Path]::GetTempPath()) "openclaw-appinstaller-test-$(Get-Random)" +New-Item -ItemType Directory -Force -Path $tmp | Out-Null + +try { + Copy-Item $MsixVnPath (Join-Path $tmp 'vN.msix') + Copy-Item $MsixVn1Path (Join-Path $tmp 'vNplus1.msix') + + $baseUri = "http://127.0.0.1:$Port" + $repoRoot = Split-Path -Parent $PSScriptRoot + + function Render-AppInstaller { + param([string]$Version, [string]$MsixFileName, [string]$OutputPath) + & "$repoRoot\scripts\render-appinstaller.ps1" ` + -Version $Version ` + -Publisher $Publisher ` + -MsixX64Uri "$baseUri/$MsixFileName" ` + -MsixArm64Uri "$baseUri/$MsixFileName" ` + -AppInstallerUri "$baseUri/openclaw.appinstaller" ` + -OutputPath $OutputPath + } + + Render-AppInstaller -Version $VnVersion -MsixFileName 'vN.msix' -OutputPath (Join-Path $tmp 'openclaw.appinstaller') + + # Spin up a HttpListener; HttpListenerPrefix needs http://+:Port/ for + # admin-less, http://127.0.0.1:Port/ for non-admin in restricted ACLs. + $listener = [System.Net.HttpListener]::new() + $listener.Prefixes.Add("$baseUri/") + $listener.Start() + Write-Host "Listening on $baseUri/" -ForegroundColor Cyan + + $listenerJob = Start-Job -ScriptBlock { + param($prefix, $root) + Add-Type -AssemblyName System.Net.HttpListener + $l = [System.Net.HttpListener]::new() + $l.Prefixes.Add("$prefix/") + $l.Start() + while ($l.IsListening) { + $ctx = $l.GetContext() + $name = [System.IO.Path]::GetFileName($ctx.Request.Url.LocalPath) + $path = Join-Path $root $name + if (Test-Path $path) { + $bytes = [System.IO.File]::ReadAllBytes($path) + $ctx.Response.ContentType = if ($name.EndsWith('.appinstaller')) { 'application/appinstaller' } else { 'application/octet-stream' } + $ctx.Response.ContentLength64 = $bytes.Length + $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length) + } else { + $ctx.Response.StatusCode = 404 + } + $ctx.Response.Close() + } + } -ArgumentList $baseUri, $tmp + + try { + # Step 2: install vN via the .appinstaller URL. + Write-Host "Installing vN via $baseUri/openclaw.appinstaller ..." -ForegroundColor Cyan + Add-AppxPackage -AppInstallerFile "$baseUri/openclaw.appinstaller" -ForceApplicationShutdown + $pkg = Get-AppxPackage -Name 'OpenClaw.Companion*' | Select-Object -First 1 + if ($pkg.Version -ne $VnVersion) { + throw "Expected vN install to land version $VnVersion, got $($pkg.Version)" + } + Write-Host " vN installed: $($pkg.Version)" -ForegroundColor Green + + # Step 3: re-render the .appinstaller in place pointing at vN+1. + Render-AppInstaller -Version $Vn1Version -MsixFileName 'vNplus1.msix' -OutputPath (Join-Path $tmp 'openclaw.appinstaller') + + # Step 4: trigger the in-app update path via PackageManager. + Write-Host "Triggering upgrade to vN+1 via PackageManager.AddPackageByAppInstallerFileAsync ..." -ForegroundColor Cyan + Add-Type -AssemblyName 'Windows.Management.Deployment.PackageManager, ContentType=WindowsRuntime' + $pm = [Windows.Management.Deployment.PackageManager,Windows.Management.Deployment,ContentType=WindowsRuntime]::new() + $op = $pm.AddPackageByAppInstallerFileAsync( + [Uri]"$baseUri/openclaw.appinstaller", + [Windows.Management.Deployment.AddPackageByAppInstallerOptions]::ForceTargetAppShutdown, + $pm.GetDefaultPackageVolume()) + $result = $op.AsTask().GetAwaiter().GetResult() + if (-not $result.IsRegistered) { + throw "Upgrade failed: $($result.ErrorText) (HRESULT 0x$('{0:X8}' -f $result.ExtendedErrorCode.HResult))" + } + Write-Host " PackageManager reported IsRegistered=$($result.IsRegistered)" -ForegroundColor Green + + # Step 5: assert. + $pkg = Get-AppxPackage -Name 'OpenClaw.Companion*' | Select-Object -First 1 + if ($pkg.Version -ne $Vn1Version) { + throw "Expected upgrade to land version $Vn1Version, got $($pkg.Version)" + } + Write-Host "vN+1 verified at $($pkg.Version)" -ForegroundColor Green + + Write-Host "`nAppInstaller upgrade simulation: PASS" -ForegroundColor Green + } + finally { + if ($listenerJob) { Stop-Job $listenerJob -ErrorAction SilentlyContinue; Remove-Job $listenerJob -Force -ErrorAction SilentlyContinue } + if ($listener.IsListening) { $listener.Stop(); $listener.Close() } + } +} +finally { + if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue } +} diff --git a/scripts/test-msix-install.ps1 b/scripts/test-msix-install.ps1 new file mode 100644 index 000000000..e084b14c3 --- /dev/null +++ b/scripts/test-msix-install.ps1 @@ -0,0 +1,166 @@ +<# +.SYNOPSIS + End-to-end smoke test for the OpenClaw Companion MSIX install / launch / + health-check / uninstall cycle. Runnable locally on a developer Windows box + and from CI (windows-latest runner). + +.DESCRIPTION + Designed to be the automated counterpart to the manual runbook in + docs/WINDOWS_NODE_TESTING.md. Each step is independent, prints PASS/FAIL, + and the script exits non-zero on the first failure. + + Steps: + 1. Install the MSIX via Add-AppxPackage. + 2. Assert the package shows up in Get-AppxPackage with the expected + Publisher and a 4-part Version. + 3. Launch the tray (Start-Process via the package family activation alias) + and wait for the singleton named-pipe ("OpenClawTray-DeepLink") to come + up — that's the readiness signal. + 4. Send an `openclaw://health` deep link through the pipe. + 5. Stop the tray process(es). + 6. Remove-AppxPackage and assert no orphan files remain in + %APPDATA%\OpenClawTray\ or %LOCALAPPDATA%\OpenClawTray\. + +.PARAMETER MsixPath + Path to the .msix produced by build-msix CI job (or by a local + `msbuild /p:PackageMsix=true` invocation). + +.PARAMETER ExpectedPublisher + Publisher subject the package must declare. Defaults to the Trusted Signing + cert subject used by CI. + +.PARAMETER KeepInstall + Don't run the uninstall step at the end. Useful when debugging an install + problem and you want the package to stay registered between runs. + +.EXAMPLE + ./scripts/test-msix-install.ps1 -MsixPath .\OpenClawCompanion-0.5.3-win-x64.msix + +.NOTES + This script does NOT exercise the AppInstaller (`.appinstaller`) flow — + for that, see scripts/test-appinstaller-update.ps1 which spins up a local + HTTP server and walks the vN -> vN+1 upgrade path. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $MsixPath, + [string] $ExpectedPublisher = 'CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US', + [switch] $KeepInstall +) + +$ErrorActionPreference = 'Stop' +$script:failed = 0 + +function Assert-True { + param([bool]$Condition, [string]$Message) + if ($Condition) { + Write-Host " PASS: $Message" -ForegroundColor Green + } else { + Write-Host " FAIL: $Message" -ForegroundColor Red + $script:failed++ + } +} + +function Section { param([string]$Title) Write-Host "`n=== $Title ===" -ForegroundColor Cyan } + +if (-not (Test-Path $MsixPath)) { + throw "MSIX not found: $MsixPath" +} + +Section 'Step 1: Install MSIX' +try { + Add-AppxPackage -Path $MsixPath -ForceApplicationShutdown -ErrorAction Stop + Assert-True $true "Add-AppxPackage exited cleanly" +} catch { + Assert-True $false "Add-AppxPackage failed: $($_.Exception.Message)" + exit 1 +} + +Section 'Step 2: Assert package presence' +$pkg = Get-AppxPackage -Name 'OpenClaw.Companion*' | Select-Object -First 1 +Assert-True ($null -ne $pkg) "Get-AppxPackage finds OpenClaw.Companion*" +if ($pkg) { + Assert-True ($pkg.Publisher -eq $ExpectedPublisher) "Publisher matches: $($pkg.Publisher)" + $versionParts = $pkg.Version.Split('.') + Assert-True ($versionParts.Length -eq 4) "Version is 4-part: $($pkg.Version)" +} + +Section 'Step 3: Launch + wait for singleton named pipe' +if ($pkg) { + # Activate via the package family — same path users hit from Start menu. + $appId = ($pkg.PackageFamilyName + '!App') + Start-Process -FilePath "shell:AppsFolder\$appId" -ErrorAction SilentlyContinue + # Wait up to 30s for the OpenClawTray-DeepLink named pipe to appear. + $deadline = (Get-Date).AddSeconds(30) + $pipeUp = $false + while ((Get-Date) -lt $deadline) { + $pipes = [System.IO.Directory]::GetFiles('\\.\pipe\') 2>$null + if ($pipes -and ($pipes | Where-Object { $_ -match 'OpenClawTray-DeepLink' })) { + $pipeUp = $true + break + } + Start-Sleep -Milliseconds 500 + } + Assert-True $pipeUp "Named pipe 'OpenClawTray-DeepLink' came up within 30s" +} + +Section 'Step 4: Health deep link round-trip' +if ($pkg -and $pipeUp) { + try { + $client = [System.IO.Pipes.NamedPipeClientStream]::new('.', 'OpenClawTray-DeepLink', 'Out') + $client.Connect(5000) + $writer = [System.IO.StreamWriter]::new($client) + $writer.WriteLine('openclaw://health') + $writer.Flush() + $writer.Dispose() + $client.Dispose() + Assert-True $true "Wrote openclaw://health to the deep-link pipe" + } catch { + Assert-True $false "Pipe write failed: $($_.Exception.Message)" + } +} + +Section 'Step 5: Stop tray process' +Get-Process -Name 'OpenClaw.Tray.WinUI' -ErrorAction SilentlyContinue | + ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue } +Assert-True $true "Tray processes stopped (best-effort)" + +Section 'Step 6: Uninstall + orphan check' +if ($KeepInstall) { + Write-Host " (skipping uninstall due to -KeepInstall)" -ForegroundColor Yellow +} elseif ($pkg) { + try { + Remove-AppxPackage -Package $pkg.PackageFullName -ErrorAction Stop + Assert-True $true "Remove-AppxPackage exited cleanly" + } catch { + Assert-True $false "Remove-AppxPackage failed: $($_.Exception.Message)" + } + + $stillThere = Get-AppxPackage -Name 'OpenClaw.Companion*' -ErrorAction SilentlyContinue + Assert-True ($null -eq $stillThere) "Package removed from Get-AppxPackage" + + # File orphans: MSIX uninstall removes the package container but does NOT + # touch the historical %APPDATA%\OpenClawTray\ / %LOCALAPPDATA%\OpenClawTray\ + # folders. We assert that the smoke-test install didn't write to them + # (a fresh install on a clean profile shouldn't create them at all). This + # is the case the in-app Reset & remove flow targets. + $appDataOrphan = Test-Path (Join-Path $env:APPDATA 'OpenClawTray') + $localAppDataOrphan = Test-Path (Join-Path $env:LOCALAPPDATA 'OpenClawTray') + if ($appDataOrphan -or $localAppDataOrphan) { + Write-Host " WARNING: orphan folders detected (likely from a prior install):" -ForegroundColor Yellow + if ($appDataOrphan) { Write-Host " %APPDATA%\OpenClawTray\" } + if ($localAppDataOrphan) { Write-Host " %LOCALAPPDATA%\OpenClawTray\" } + Write-Host " Run 'openclaw-winnode --purge-wsl-orphans --confirm-destructive' to clean." + } else { + Assert-True $true "No orphan %APPDATA% / %LOCALAPPDATA% folders" + } +} + +Section 'Summary' +if ($script:failed -gt 0) { + Write-Host "$script:failed assertion(s) failed." -ForegroundColor Red + exit 1 +} +Write-Host "All assertions passed." -ForegroundColor Green +exit 0 diff --git a/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs b/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs new file mode 100644 index 000000000..5fe06e059 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs @@ -0,0 +1,92 @@ +using System; +using System.IO; + +namespace OpenClaw.Tray.Tests; + +/// +/// Pin the contract of the orphan-purger CLI without taking a compile-time +/// dependency on the WinNode CLI assembly. Real WSL / registry / file-system +/// probing is integration-test territory; the assertions here lock down the +/// public surface (orphan-kind constants, prefix, exit-code policy) so the +/// recovery CLI flag stays stable for the support recipe in +/// docs/uninstall-msix.md. +/// +/// Source-text assertions follow the same pattern as the historical +/// InstallerIssAssertionTests (now removed with Inno sunset) — Tray.Tests +/// is net10.0 and cannot transitively load the CLI's internal types, so we +/// pin the contract by reading the source. +/// +public sealed class OrphanPurgerContractTests +{ + private static string GetRepositoryRoot() + { + var envRepoRoot = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (!string.IsNullOrWhiteSpace(envRepoRoot) && Directory.Exists(envRepoRoot)) + return envRepoRoot; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if ((Directory.Exists(Path.Combine(directory.FullName, ".git")) || + File.Exists(Path.Combine(directory.FullName, ".git"))) && + File.Exists(Path.Combine(directory.FullName, "README.md"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new InvalidOperationException( + "Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path."); + } + + private static string LoadOrphanPurgerSource() => + File.ReadAllText(Path.Combine( + GetRepositoryRoot(), "src", "OpenClaw.WinNode.Cli", "OrphanPurger.cs")); + + [Fact] + public void OrphanWslDistroPrefix_IsTheOpenclawPrefix() + { + // The local-gateway flow names every distro it installs with an + // openclaw- prefix. Drift here means a real orphan goes undetected + // (we silently miss what we promised to clean) — pin it. + Assert.Contains("OrphanWslDistroPrefix = \"openclaw-\"", LoadOrphanPurgerSource()); + } + + [Theory] + [InlineData("\"wsl-distro\"", "WSL distro orphans")] + [InlineData("\"appdata-folder\"", "%APPDATA% orphans")] + [InlineData("\"localappdata-folder\"", "%LOCALAPPDATA% orphans")] + [InlineData("\"registry-uri-scheme\"", "openclaw:// URI scheme registration")] + [InlineData("\"registry-run-key\"", "HKCU Run autostart entry")] + public void OrphanKinds_AreAllReported(string kindLiteral, string reason) + { + // Every kind we promise to detect in docs/uninstall-msix.md must show + // up as a Kind on an OrphanItem somewhere in the source. If you remove + // one, update the doc table in the same change. + var src = LoadOrphanPurgerSource(); + Assert.True(src.Contains(kindLiteral), + $"Missing orphan kind {kindLiteral} (covers: {reason})"); + } + + [Fact] + public void ExitCodePolicy_IsDocumented() + { + // The exit-code mapping is the contract scripts and support docs key + // off. The wording is set in source comments; if the meanings change, + // re-check docs/uninstall-msix.md AND scripts/test-msix-install.ps1. + var src = LoadOrphanPurgerSource(); + Assert.Contains("if (failed.Count > 0) return 2;", src); + Assert.Contains("if (!confirmDestructive && orphans.Count > 0) return 1;", src); + Assert.Contains("return 0;", src); + } + + [Fact] + public void DryRunIsTheDefault() + { + // Pin: --purge-wsl-orphans without --confirm-destructive must NOT + // delete anything. Inverting this would surprise a support user who + // ran the diagnostic to "see what's there". + var src = LoadOrphanPurgerSource(); + Assert.Contains("if (confirmDestructive)", src); + } +} + From 6aea0125d45aeea7d565924e4309c086981bfd7c Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Tue, 19 May 2026 04:17:01 -0700 Subject: [PATCH 07/56] msix(t3-followup): OrphanPurger detects PascalCase OpenClawGateway + both URI key cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While prepping for the manual test pass on Mike's box we found two real bugs in OrphanPurger that would have caused --purge-wsl-orphans to falsely report 'no orphans' against a real OpenClaw install: 1. WSL distro detection used a case-sensitive 'openclaw-' prefix. The historical local-gateway installer registers the distro as 'OpenClawGateway' (PascalCase, no dash) and we miss it. Pivoted to a case-insensitive substring match against an OrphanWslDistroPatterns array — currently a single 'openclaw' entry that catches both the PascalCase legacy form and the newer kebab-case 'openclaw-local' / 'openclaw-staging' variants. 2. URI scheme key detection only enumerated HKCU\Software\Classes\openclaw. Mike's box has both 'openclaw' AND 'OpenClaw' keys present simultaneously (the registry is case-insensitive for lookup but stores both literals). Switched to an OrphanUriSchemeKeys array so both are scrubbed; RemoveAsync now derives the subkey from the detected OrphanItem.Name instead of hard-coding the lowercase form. OrphanWslDistroPrefix const is retained for backward compatibility with existing OrphanPurgerContractTests assertions and any external recipes that grep for the symbol; the new code paths use OrphanWslDistroPatterns. Tests: added two regressions in OrphanPurgerContractTests that pin the case-insensitive WSL detection (including the 'OpenClawGateway' name in the docstring) and the two-variant URI scheme coverage. Validation: ./build.ps1 OK, Shared.Tests 1776 passed / 28 skipped, Tray.Tests 1125 passed (+2 from the new orphan tests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.WinNode.Cli/OrphanPurger.cs | 88 ++++++++++++++++--- .../OrphanPurgerContractTests.cs | 32 ++++++- 2 files changed, 107 insertions(+), 13 deletions(-) diff --git a/src/OpenClaw.WinNode.Cli/OrphanPurger.cs b/src/OpenClaw.WinNode.Cli/OrphanPurger.cs index a57997003..887d8ea5e 100644 --- a/src/OpenClaw.WinNode.Cli/OrphanPurger.cs +++ b/src/OpenClaw.WinNode.Cli/OrphanPurger.cs @@ -30,15 +30,53 @@ namespace OpenClaw.WinNode.Cli; /// internal static class OrphanPurger { - /// Names of WSL distros we know we install via the local-gateway flow. + /// + /// Substrings that identify a WSL distro as belonging to the OpenClaw + /// local-gateway flow. We match these case-insensitively against the + /// distro name returned by wsl --list --quiet. + /// /// - /// The local-gateway installer uses openclaw-local as the canonical - /// distro name. We pattern-match the prefix instead of an exact name so a - /// future openclaw-staging / openclaw-arm64 variant is also - /// caught. + /// The local-gateway installer has used two naming conventions across the + /// project's history: + /// + /// OpenClawGateway — the original PascalCase + /// name used by the WSL gateway installer (still in production as of + /// 2026-05; observed on Mike's dev box during the MSIX-E2E manual test + /// prep). + /// openclaw-* — the newer kebab-case + /// convention adopted for variants like openclaw-local, + /// openclaw-staging. + /// + /// Match is case-insensitive because wsl --list --quiet echoes + /// the user-specified case verbatim and we cannot rely on either form. /// + internal static readonly string[] OrphanWslDistroPatterns = new[] + { + "openclaw", // matches both "openclaw-*" and "OpenClawGateway" case-insensitively + }; + + /// + /// Retained for backward compatibility with OrphanPurgerContractTests + /// and for any external script that pattern-matches the historical + /// "openclaw-" prefix. New detection logic should use + /// . + /// internal const string OrphanWslDistroPrefix = "openclaw-"; + /// + /// Registry subkeys under HKCU\Software\Classes we treat as + /// orphan URI scheme registrations. Both the lowercase + /// openclaw form (which the unpackaged DeepLinkHandler writes) + /// and the PascalCase OpenClaw form (observed in the wild from + /// older builds) are listed because Windows Explorer-driven user + /// scrubbers can leave one but not the other. + /// + internal static readonly string[] OrphanUriSchemeKeys = new[] + { + @"Software\Classes\openclaw", + @"Software\Classes\OpenClaw", + }; + public record OrphanItem(string Kind, string Name, string Detail); public record PurgeReport( IReadOnlyList Orphans, @@ -144,7 +182,19 @@ private static IEnumerable DetectWslDistros(TextWriter stderr) { var line = rawLine.Trim().Trim('\u0000'); if (line.Length == 0) continue; - if (!line.StartsWith(OrphanWslDistroPrefix, StringComparison.OrdinalIgnoreCase)) continue; + // Match against every documented pattern, case-insensitive. See + // OrphanWslDistroPatterns for why we accept both PascalCase and + // kebab-case forms. + var matched = false; + foreach (var pattern in OrphanWslDistroPatterns) + { + if (line.Contains(pattern, StringComparison.OrdinalIgnoreCase)) + { + matched = true; + break; + } + } + if (!matched) continue; yield return new OrphanItem( Kind: "wsl-distro", Name: line, @@ -188,12 +238,18 @@ private static IEnumerable DetectRegistryOrphans() { // openclaw:// URI scheme (unpackaged-only; PR #310 path). Packaged // installs use the windows.protocol manifest extension and there is - // nothing in HKCU\Software\Classes for them. - if (TryRegistryKeyExists(Registry.CurrentUser, @"Software\Classes\openclaw", out var detail)) + // nothing in HKCU\Software\Classes for them. We check both casing + // variants because the registry is case-insensitive for lookup but + // can hold both keys simultaneously if different scrubbing scripts + // touched them. + foreach (var subkey in OrphanUriSchemeKeys) { - yield return new OrphanItem("registry-uri-scheme", - @"HKCU\Software\Classes\openclaw", - detail); + if (TryRegistryKeyExists(Registry.CurrentUser, subkey, out var detail)) + { + yield return new OrphanItem("registry-uri-scheme", + $@"HKCU\{subkey}", + detail); + } } // HKCU\...\Run entry for the legacy auto-start path (now superseded by @@ -249,7 +305,15 @@ private static async Task RemoveAsync(OrphanItem orphan, TextWriter stderr) Directory.Delete(orphan.Name, recursive: true); break; case "registry-uri-scheme": - Registry.CurrentUser.DeleteSubKeyTree(@"Software\Classes\openclaw", throwOnMissingSubKey: false); + // Strip the HKCU\ prefix re-attached at detection time to + // recover the original subkey path. We delete *that* subtree + // rather than the hard-coded lowercase variant so the + // PascalCase key (HKCU\Software\Classes\OpenClaw) also gets + // removed when it's the one detected. + var subkey = orphan.Name.StartsWith(@"HKCU\") + ? orphan.Name[5..] + : orphan.Name; + Registry.CurrentUser.DeleteSubKeyTree(subkey, throwOnMissingSubKey: false); break; case "registry-run-key": using (var key = Registry.CurrentUser.OpenSubKey( diff --git a/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs b/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs index 5fe06e059..2e727b2b9 100644 --- a/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs @@ -47,10 +47,40 @@ public void OrphanWslDistroPrefix_IsTheOpenclawPrefix() { // The local-gateway flow names every distro it installs with an // openclaw- prefix. Drift here means a real orphan goes undetected - // (we silently miss what we promised to clean) — pin it. + // (we silently miss what we promised to clean) — pin it. Retained + // for backward compat after the 2026-05 case-insensitive fix added + // OrphanWslDistroPatterns. Assert.Contains("OrphanWslDistroPrefix = \"openclaw-\"", LoadOrphanPurgerSource()); } + [Fact] + public void WslDistroDetection_IsCaseInsensitive_AndCatchesLegacyOpenClawGateway() + { + // Regression: during MSIX-E2E manual test prep we found Mike's box + // had an OpenClawGateway (PascalCase, no dash) distro installed by + // the historical local-gateway flow. The original "openclaw-" + // case-sensitive prefix would silently miss it, meaning a user who + // ran --purge-wsl-orphans would be told "no orphans" while a 2.6 GB + // .vhdx orphan was still on disk. Pin the case-insensitive substring + // strategy so future refactors can't reintroduce the bug. + var src = LoadOrphanPurgerSource(); + Assert.Contains("OrphanWslDistroPatterns", src); + Assert.Contains("StringComparison.OrdinalIgnoreCase", src); + Assert.Contains("OpenClawGateway", src); // documented in the remarks + } + + [Fact] + public void UriSchemeDetection_CoversBothCaseVariants() + { + // Same source as the WSL bug: the registry holds both + // HKCU\Software\Classes\openclaw AND HKCU\Software\Classes\OpenClaw + // simultaneously on some boxes; we have to enumerate both. + var src = LoadOrphanPurgerSource(); + Assert.Contains(@"Software\Classes\openclaw", src); + Assert.Contains(@"Software\Classes\OpenClaw", src); + Assert.Contains("OrphanUriSchemeKeys", src); + } + [Theory] [InlineData("\"wsl-distro\"", "WSL distro orphans")] [InlineData("\"appdata-folder\"", "%APPDATA% orphans")] From 5e6b5ea2b76aa2c1c33f444a97b6a3ab9695365f Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 18 May 2026 17:37:59 -0700 Subject: [PATCH 08/56] Clean up WinAppSDK ghost frames after tray tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../WinAppSdkGhostWindowCleanup.cs | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs diff --git a/tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs b/tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs new file mode 100644 index 000000000..3e98e1844 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs @@ -0,0 +1,144 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Loader; +using System.Text; +using Xunit.Sdk; + +[assembly: OpenClaw.Tray.Tests.WinAppSdkGhostWindowCleanupAttribute] + +namespace OpenClaw.Tray.Tests; + +internal sealed class WinAppSdkGhostWindowCleanupAttribute : BeforeAfterTestAttribute +{ + public override void After(MethodInfo methodUnderTest) => + WinAppSdkGhostWindowCleanup.CleanupBlankFrames(); +} + +/// +/// Cleans up Windows App SDK shell-frame ghosts created during tray validation. +/// Some WinUI/AppWindow surfaces can leave an explorer-owned, blank +/// ApplicationFrameWindow behind when the testhost exits. Those windows are +/// visually disruptive on a developer workstation even though all tests pass. +/// This test-only hook hides and closes blank shell frames during validation and +/// again when the test process exits. +/// +internal static class WinAppSdkGhostWindowCleanup +{ + private const uint WM_CLOSE = 0x0010; + private const uint SMTO_ABORTIFHUNG = 0x0002; + private const int SW_HIDE = 0; + + [ModuleInitializer] + public static void Initialize() + { + if (!OperatingSystem.IsWindows()) + return; + + CleanupBlankFrames(); + AppDomain.CurrentDomain.ProcessExit += (_, _) => CleanupBlankFrames(); + AssemblyLoadContext.Default.Unloading += _ => CleanupBlankFrames(); + } + + public static void CleanupBlankFrames() + { + if (!OperatingSystem.IsWindows()) + return; + + foreach (var hwnd in EnumerateBlankApplicationFrameWindows()) + { + _ = ShowWindow(hwnd, SW_HIDE); + _ = SendMessageTimeout(hwnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero, SMTO_ABORTIFHUNG, 1000, out _); + } + } + + private static IEnumerable EnumerateBlankApplicationFrameWindows() + { + var windows = new List(); + _ = EnumWindows((hwnd, lParam) => + { + if (!IsWindowVisible(hwnd)) + return true; + + var className = new StringBuilder(256); + _ = GetClassName(hwnd, className, className.Capacity); + if (!string.Equals(className.ToString(), "ApplicationFrameWindow", StringComparison.Ordinal)) + return true; + + var title = new StringBuilder(512); + _ = GetWindowText(hwnd, title, title.Capacity); + if (!string.IsNullOrWhiteSpace(title.ToString())) + return true; + + _ = GetWindowThreadProcessId(hwnd, out var pid); + try + { + using var owner = System.Diagnostics.Process.GetProcessById((int)pid); + if (!string.Equals(owner.ProcessName, "explorer", StringComparison.OrdinalIgnoreCase)) + return true; + } + catch (ArgumentException) + { + return true; + } + catch (InvalidOperationException) + { + return true; + } + + if (!GetWindowRect(hwnd, out var rect)) + return true; + + var width = rect.Right - rect.Left; + var height = rect.Bottom - rect.Top; + if (width > 100 && height > 100) + windows.Add(hwnd); + + return true; + }, IntPtr.Zero); + + return windows; + } + + private delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc callback, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr hwnd); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr hwnd, StringBuilder text, int count); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetClassName(IntPtr hwnd, StringBuilder text, int count); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid); + + [DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr hwnd, out RECT rect); + + [DllImport("user32.dll", SetLastError = true)] + private static extern IntPtr SendMessageTimeout( + IntPtr hwnd, + uint msg, + IntPtr wParam, + IntPtr lParam, + uint flags, + uint timeout, + out IntPtr result); + + [DllImport("user32.dll")] + private static extern bool ShowWindow(IntPtr hwnd, int nCmdShow); + + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } +} From 05b430a4510c40e36e0b4acdc6966b7ae835e3fe Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 18 May 2026 19:16:03 -0700 Subject: [PATCH 09/56] Harden tray test ghost window cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../WinAppSdkGhostWindowCleanup.cs | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs b/tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs index 3e98e1844..b8a0001b4 100644 --- a/tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs +++ b/tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs @@ -11,6 +11,9 @@ namespace OpenClaw.Tray.Tests; internal sealed class WinAppSdkGhostWindowCleanupAttribute : BeforeAfterTestAttribute { + public override void Before(MethodInfo methodUnderTest) => + WinAppSdkGhostWindowCleanup.CleanupBlankFrames(); + public override void After(MethodInfo methodUnderTest) => WinAppSdkGhostWindowCleanup.CleanupBlankFrames(); } @@ -28,6 +31,8 @@ internal static class WinAppSdkGhostWindowCleanup private const uint WM_CLOSE = 0x0010; private const uint SMTO_ABORTIFHUNG = 0x0002; private const int SW_HIDE = 0; + private static int s_cleanupInProgress; + private static System.Threading.Timer? s_cleanupTimer; [ModuleInitializer] public static void Initialize() @@ -36,8 +41,14 @@ public static void Initialize() return; CleanupBlankFrames(); - AppDomain.CurrentDomain.ProcessExit += (_, _) => CleanupBlankFrames(); - AssemblyLoadContext.Default.Unloading += _ => CleanupBlankFrames(); + s_cleanupTimer = new System.Threading.Timer( + _ => CleanupBlankFrames(), + state: null, + dueTime: System.TimeSpan.FromSeconds(1), + period: System.TimeSpan.FromSeconds(1)); + + AppDomain.CurrentDomain.ProcessExit += (_, _) => CleanupBlankFramesRepeatedly(); + AssemblyLoadContext.Default.Unloading += _ => CleanupBlankFramesRepeatedly(); } public static void CleanupBlankFrames() @@ -45,10 +56,30 @@ public static void CleanupBlankFrames() if (!OperatingSystem.IsWindows()) return; - foreach (var hwnd in EnumerateBlankApplicationFrameWindows()) + if (System.Threading.Interlocked.Exchange(ref s_cleanupInProgress, 1) == 1) + return; + + try + { + foreach (var hwnd in EnumerateBlankApplicationFrameWindows()) + { + _ = ShowWindow(hwnd, SW_HIDE); + _ = SendMessageTimeout(hwnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero, SMTO_ABORTIFHUNG, 1000, out _); + } + } + finally + { + System.Threading.Volatile.Write(ref s_cleanupInProgress, 0); + } + } + + private static void CleanupBlankFramesRepeatedly() + { + var deadline = System.DateTime.UtcNow.AddSeconds(5); + while (System.DateTime.UtcNow < deadline) { - _ = ShowWindow(hwnd, SW_HIDE); - _ = SendMessageTimeout(hwnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero, SMTO_ABORTIFHUNG, 1000, out _); + CleanupBlankFrames(); + System.Threading.Thread.Sleep(250); } } @@ -74,7 +105,8 @@ private static IEnumerable EnumerateBlankApplicationFrameWindows() try { using var owner = System.Diagnostics.Process.GetProcessById((int)pid); - if (!string.Equals(owner.ProcessName, "explorer", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(owner.ProcessName, "explorer", StringComparison.OrdinalIgnoreCase) && + !string.Equals(owner.ProcessName, "ApplicationFrameHost", StringComparison.OrdinalIgnoreCase)) return true; } catch (ArgumentException) From 2fccd24e8149f4c2cc27b11cd5cf152550d46694 Mon Sep 17 00:00:00 2001 From: Scott Hanselman Date: Mon, 18 May 2026 19:40:02 -0700 Subject: [PATCH 10/56] Limit ghost window cleanup to test-created frames Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../WinAppSdkGhostWindowCleanup.cs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs b/tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs index b8a0001b4..7aae185cf 100644 --- a/tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs +++ b/tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs @@ -31,6 +31,8 @@ internal static class WinAppSdkGhostWindowCleanup private const uint WM_CLOSE = 0x0010; private const uint SMTO_ABORTIFHUNG = 0x0002; private const int SW_HIDE = 0; + private static readonly object s_baselineLock = new(); + private static HashSet s_baselineBlankFrames = []; private static int s_cleanupInProgress; private static System.Threading.Timer? s_cleanupTimer; @@ -40,7 +42,7 @@ public static void Initialize() if (!OperatingSystem.IsWindows()) return; - CleanupBlankFrames(); + RecordBaselineBlankFrames(); s_cleanupTimer = new System.Threading.Timer( _ => CleanupBlankFrames(), state: null, @@ -63,6 +65,9 @@ public static void CleanupBlankFrames() { foreach (var hwnd in EnumerateBlankApplicationFrameWindows()) { + if (IsBaselineBlankFrame(hwnd)) + continue; + _ = ShowWindow(hwnd, SW_HIDE); _ = SendMessageTimeout(hwnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero, SMTO_ABORTIFHUNG, 1000, out _); } @@ -73,6 +78,22 @@ public static void CleanupBlankFrames() } } + private static void RecordBaselineBlankFrames() + { + lock (s_baselineLock) + { + s_baselineBlankFrames = EnumerateBlankApplicationFrameWindows().ToHashSet(); + } + } + + private static bool IsBaselineBlankFrame(IntPtr hwnd) + { + lock (s_baselineLock) + { + return s_baselineBlankFrames.Contains(hwnd); + } + } + private static void CleanupBlankFramesRepeatedly() { var deadline = System.DateTime.UtcNow.AddSeconds(5); From 5cfe67c2f8e6c61967299cc28704cd3d7020f44e Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 18 May 2026 19:50:08 -0700 Subject: [PATCH 11/56] Clean up Terminal ghost frames during tray tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> # Conflicts: # tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs --- .../WinAppSdkGhostWindowCleanup.cs | 97 ++++++++++++++++--- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs b/tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs index 7aae185cf..05a466318 100644 --- a/tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs +++ b/tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs @@ -19,20 +19,24 @@ public override void After(MethodInfo methodUnderTest) => } /// -/// Cleans up Windows App SDK shell-frame ghosts created during tray validation. +/// Cleans up desktop-frame ghosts created during tray validation. /// Some WinUI/AppWindow surfaces can leave an explorer-owned, blank -/// ApplicationFrameWindow behind when the testhost exits. Those windows are -/// visually disruptive on a developer workstation even though all tests pass. -/// This test-only hook hides and closes blank shell frames during validation and -/// again when the test process exits. +/// ApplicationFrameWindow behind when the testhost exits; the validation +/// host can also leave generic Windows Terminal frames around local runs. Those +/// windows are visually disruptive on a developer workstation even though all +/// tests pass. This test-only hook hides and closes newly-created ghost frames +/// during validation and again when the test process exits. /// internal static class WinAppSdkGhostWindowCleanup { private const uint WM_CLOSE = 0x0010; + private const uint WM_SYSCOMMAND = 0x0112; + private const uint SC_CLOSE = 0xF060; private const uint SMTO_ABORTIFHUNG = 0x0002; private const int SW_HIDE = 0; private static readonly object s_baselineLock = new(); private static HashSet s_baselineBlankFrames = []; + private static HashSet s_baselineTerminalFrames = []; private static int s_cleanupInProgress; private static System.Threading.Timer? s_cleanupTimer; @@ -42,7 +46,8 @@ public static void Initialize() if (!OperatingSystem.IsWindows()) return; - RecordBaselineBlankFrames(); + RecordBaselineGhostFrames(); + CleanupBlankFrames(); s_cleanupTimer = new System.Threading.Timer( _ => CleanupBlankFrames(), state: null, @@ -65,11 +70,14 @@ public static void CleanupBlankFrames() { foreach (var hwnd in EnumerateBlankApplicationFrameWindows()) { - if (IsBaselineBlankFrame(hwnd)) - continue; + if (!IsBaselineBlankFrame(hwnd)) + HideAndClose(hwnd); + } - _ = ShowWindow(hwnd, SW_HIDE); - _ = SendMessageTimeout(hwnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero, SMTO_ABORTIFHUNG, 1000, out _); + foreach (var hwnd in EnumerateTerminalGhostWindows()) + { + if (!IsBaselineTerminalFrame(hwnd)) + HideAndClose(hwnd); } } finally @@ -78,11 +86,12 @@ public static void CleanupBlankFrames() } } - private static void RecordBaselineBlankFrames() + private static void RecordBaselineGhostFrames() { lock (s_baselineLock) { s_baselineBlankFrames = EnumerateBlankApplicationFrameWindows().ToHashSet(); + s_baselineTerminalFrames = EnumerateTerminalGhostWindows().ToHashSet(); } } @@ -94,6 +103,14 @@ private static bool IsBaselineBlankFrame(IntPtr hwnd) } } + private static bool IsBaselineTerminalFrame(IntPtr hwnd) + { + lock (s_baselineLock) + { + return s_baselineTerminalFrames.Contains(hwnd); + } + } + private static void CleanupBlankFramesRepeatedly() { var deadline = System.DateTime.UtcNow.AddSeconds(5); @@ -153,6 +170,61 @@ private static IEnumerable EnumerateBlankApplicationFrameWindows() return windows; } + private static IEnumerable EnumerateTerminalGhostWindows() + { + var windows = new List(); + _ = EnumWindows((hwnd, lParam) => + { + if (!IsWindowVisible(hwnd)) + return true; + + var className = new StringBuilder(256); + _ = GetClassName(hwnd, className, className.Capacity); + if (!string.Equals(className.ToString(), "CASCADIA_HOSTING_WINDOW_CLASS", StringComparison.Ordinal)) + return true; + + var title = new StringBuilder(512); + _ = GetWindowText(hwnd, title, title.Capacity); + if (!string.Equals(title.ToString(), "Terminal", StringComparison.Ordinal)) + return true; + + _ = GetWindowThreadProcessId(hwnd, out var pid); + try + { + using var owner = System.Diagnostics.Process.GetProcessById((int)pid); + if (!string.Equals(owner.ProcessName, "WindowsTerminal", StringComparison.OrdinalIgnoreCase)) + return true; + } + catch (ArgumentException) + { + return true; + } + catch (InvalidOperationException) + { + return true; + } + + if (!GetWindowRect(hwnd, out var rect)) + return true; + + var width = rect.Right - rect.Left; + var height = rect.Bottom - rect.Top; + if (width >= 1000 && height >= 500) + windows.Add(hwnd); + + return true; + }, IntPtr.Zero); + + return windows; + } + + private static void HideAndClose(IntPtr hwnd) + { + _ = ShowWindow(hwnd, SW_HIDE); + _ = PostMessage(hwnd, WM_SYSCOMMAND, new IntPtr(SC_CLOSE), IntPtr.Zero); + _ = SendMessageTimeout(hwnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero, SMTO_ABORTIFHUNG, 1000, out _); + } + private delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam); [DllImport("user32.dll")] @@ -186,6 +258,9 @@ private static extern IntPtr SendMessageTimeout( [DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hwnd, int nCmdShow); + [DllImport("user32.dll", SetLastError = true)] + private static extern bool PostMessage(IntPtr hwnd, uint msg, IntPtr wParam, IntPtr lParam); + [StructLayout(LayoutKind.Sequential)] private struct RECT { From 6e34c902f4606ed02e4853fcf080e39a4e843a73 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Tue, 19 May 2026 05:39:37 -0700 Subject: [PATCH 12/56] msix(t1-followup): fix Privacy-list blue icon background + Notifications-list missing entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs surfaced during manual MSIX E2E testing on a clean cloud devbox. Both are in scope for this MSIX-only-distribution branch; the other two issues Mike found (trackpad scroll, screen-recording-toggle left-nav blip) will be filed separately. Bug 1 (Settings -> Privacy icon has blue background) Settings > Privacy > Camera/Microphone/Location renders the per-app icon at small sizes (16, 20 px). We shipped unplated variants for 24/32/48/256 but were missing 16 and 20 (and the natural 44 unplated). When Windows cannot find a fitting unplated size it falls back to the plated tile with the manifest BackgroundColor, which renders as the system accent (blue) square behind the lobster. Fix: generate Square44x44Logo.targetsize-{16,20,44}_altform-unplated.png from the existing 256px master via high-quality bicubic downscale, preserving the corner alpha=0 transparency. Bug 2 (no OpenClaw Companion entry in Settings -> Notifications) MSIX packaged apps that do NOT declare windows.toastNotificationActivation in their manifest only appear in Settings > Notifications AFTER the first toast is delivered under package identity (and even then it is often delayed by several minutes). Users who install but have not yet seen a toast cannot pre-configure notification preferences. Fix: declare the canonical activator pair in Package.appxmanifest: - registering OpenClaw.Tray.WinUI.exe as a COM ExeServer with class id D4E7F816-9D6A-4A49-B1BC-C1CE71282B04 - pointing at the same ToastActivatorCLSID App.OnLaunched gains a -ToastActivator short-circuit (Environment.Exit(0) before the singleton mutex check) so Windows-spawned activator instances do not fight the running tray. We do NOT currently consume toast click callbacks (no CoRegisterClassObject) — clicks fall through to the standard tray activation path, which is acceptable for now and tracked for a follow-up. Tests (tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs): + Tray_DeclaresToastNotificationActivationExtension: pins both halves of the COM-server / toast-activation pair and asserts the two CLSIDs match, plus that App.xaml.cs has the -ToastActivator short-circuit. + Tray_PrivacyListIcon_HasAllRequiredUnplatedTargetSizes: pins the full set of unplated PNG variants Windows requests for the Privacy list (16, 20, 24, 32, 44, 48, 256). Regression here re-introduces the blue background. Validation: ./build.ps1 OK, Shared.Tests 1776 passed / 28 skipped, Tray.Tests 1127 passed (+2 from the new manifest tests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 21 +++++++ ...x44Logo.targetsize-16_altform-unplated.png | Bin 0 -> 885 bytes ...x44Logo.targetsize-20_altform-unplated.png | Bin 0 -> 1158 bytes ...x44Logo.targetsize-44_altform-unplated.png | Bin 0 -> 3785 bytes src/OpenClaw.Tray.WinUI/Package.appxmanifest | 33 ++++++++++- .../MsixManifestAssertionTests.cs | 56 ++++++++++++++++++ 6 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-16_altform-unplated.png create mode 100644 src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-20_altform-unplated.png create mode 100644 src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-44_altform-unplated.png diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index dc080fabe..3ba5100f2 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -576,6 +576,27 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) return; // Environment.Exit called inside; defensive return } + // ----------------------------------------------------------------------- + // Toast activator launch path. The manifest declares a windows.comServer + + // windows.toastNotificationActivation pair (CLSID D4E7F816-…) so the app + // shows up in Settings > Notifications immediately on install. When the + // user clicks an actionable toast, Windows spawns + // OpenClaw.Tray.WinUI.exe -ToastActivator + // expecting us to register the COM class with the matching CLSID and + // handle the activation. We don't currently consume click callbacks, but + // we still need to short-circuit this launch — otherwise Windows starts + // a second tray instance that immediately fights the single-instance + // mutex check below and either crashes or steals the user's running + // tray's state. + // ----------------------------------------------------------------------- + if (_startupArgs.Contains("-ToastActivator", StringComparer.OrdinalIgnoreCase)) + { + // Windows already activated (or will already activate) the primary + // tray instance via the toast's argument string; nothing else to do. + Environment.Exit(0); + return; + } + // Check for protocol activation (MSIX packaged apps receive deep links this way) string? protocolUri = GetProtocolActivationUri(); diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-16_altform-unplated.png b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-16_altform-unplated.png new file mode 100644 index 0000000000000000000000000000000000000000..516e2c9bd8c06b9f6c44141523d146b5f06efa9e GIT binary patch literal 885 zcmV-*1B(2KP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0}4q*K~y+TeUV#8 z6k!y{2h1MqLR1h1U7&{$im*G8>}E@A?8UvzIOEQYGdts~qpQ|-G1D!v9?XX#sTb)+ zSrOY?4-#~tNXuN!a$VigU3XV^U0tOz>?O++GnCK}{osQ?=luVN?|dJE{03TDGQo#s z$hsfH7N<$;+CRNGVO|HWotcUcd>PQvt>^_39O{jVcx$3W*PO1h5>XsIo}$o8QNo@R zC+xc>6O~%zDu205~eVS@rAF)ordShQuN^nN^a@L=nX-N z;gf_zm?F4!5>97Jl;PpHV0kviVeK)lcs|+kI19N$yH^e1&Y3Ys$=r-kJ{LPGdgZ}` zW^v0$Ty(@tJQcSHo8{UEyy)x{TRJyd#Lq>F#V@1RudhU!_f#(bXsG%cw|iiksDUA| zwq7<*e3Tq%{^}sOUnVIxmTdJ(lz1w?hGQSDN4UO>L zUa#!-%BpRRa~RB43ov$^y(QQD+b(z$hc80f&;*jX=ElM!`Vh!?lieUn2Tz0FR#rF0 z==OD>CU}Ao@SG+fQO(2mtFB#L`^j_RJTG+STqyM+KIO;$qFu|i^_%IRopvHM;@}`b zmqA(-VD?lABn2utg3|MS23MOpw?IdZZDUt1?R$@wf0GK_5W&kJMv0K*1V}Kh*|14` z6v)Nbt8)q=mu9uZ?G}2BTT6SiPx#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D1RF_2K~y+TwUS*- zTXh)5>zp&2I=%I7aVpWxZHZYj39KTemA15np0D<_r|s#-Ij6S3#BLp-Ylj2Gxu_S@ zun*>@%c9xzLNC-gCnFeLKY+GSN>6EN3!@WcVM_?0drg$|DVrcL?}%O_&EYtj z)?x?q7`8j1A$@71q<4mujpUt#6AoKp#_2p#6h?L08%s7CGOY2_JYAp6a>S)slH6M8 z#t9P=)En_>grah|N&X-som-^0*6l z?(pf=E+#WdauhK>XEh}z)okTz9<6P^+e{%UK-th^IqGB@Ou zK8RADvxWS+P`ELn5&txA#Q<7a_^Z|Stw11nq|k--dOpu8ybzPR;SSOXX}#N16#gK% z@I-Le(JzNzJGz{Dy%TLmgB@-&49qvuFnfRop1|+U zlMF;<_CTrQUxfBBFWlT~{>HDetVA#ej4N4~&~T8}u`p?{zzk!CG{GzxQc}Y%0U9LLtf(qzMefasC@ZQj4G=dC*OSV zU)O~W1Iz|-e2XRW$R_a(o z6h$}1sp{_0dPPs)Zr#bye(PAW-V6y`3n?7|w+s|SC5A}LD5YK5%S2WjE^kg9qQ z7SukNGCCH&0(i9a{(p&;yPg`TZoc?ixvRZ@$MJLhFP%6&R@2%Mle^9b%H~;_u07*qoM6N<$f`Nz~^#A|> literal 0 HcmV?d00001 diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-44_altform-unplated.png b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-44_altform-unplated.png new file mode 100644 index 0000000000000000000000000000000000000000..cc00a0afcbeb3961c390fa2a3fbef1903758c6de GIT binary patch literal 3785 zcmV;)4mRPx#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D4qiz_K~z{r?U;FR z9Mzr2mF$vKvDt%A$+CtGNeIT+*bvJWvUOP+X*8Pq?zyLX&TieZMv@O~*~YT5WeT&d z-K`C~soEo(glsKI7Q9d~C^kNjbM=6%fUANr$pVt$dd9`eF_83d@m}Sju2mJ)wUh3HWt?462wr2a>mI<29pRXm~`EW0TjY}looifOKmMj`9S~Qp@m2)XVB)=sYTZ~#}nlvk~Em#RG zSv8n4s@_^sD!;WdzT7V7fAEZQW^S*3fbB1|I!|2RUyG}|8!jvyKD_0YWZ6uS_RZ5) z-SQG6!+Ui~L|Yu4?ESR1*o%5GW?K|93$Gr%uRSN{t0}E|aFS9_jL0RGV**v~giibF z9HqmORQmWzzO>Z1ZOLXp+MxW&q(t)6h(s%$A+=XWbc#3G@o~E1+tW065e*{DxRgEY zz1b$?KfGHj$8wDVS6sD=?;m}B?=Lj?XrlPhF`dkTCOcwc3DL*RC_G<8RVsu3Z;2=#VnW zkub=yOzRPAwj5^1(prvZ-YUkmS{3Fy7-_1Ud~`x@czfP#{;SnFqFSB5n>7FDNP;8A%&rG68N`}-y9A3?F(zwn6g=HTom`^znHo|?ImYT6KOFD; z(f3y8$na}RH79B|BWX}0uG3)2ZpK6#eJUeDl=jUV#aOJ-B68HF%Z^g4TX0li4Rq^2E;uQ(y_Qs3Zd>qg6g+d>VnM`ULsD$f^dy9ISGL~@ zZhn(Bt=Nh1YE8ENk!D!RV6bJybaQ%5pKgip}D zw*9{nrO(34Z^d9`+pSjKic#Z1^tT=QM8PqAX544k9jpX6BmXx$mrw`2+*sKVINo+YyiC zbs|u76az&E(N}x`?xJ>t_#JrN(00o&Jfx}m=d_uJ{}ppkTU>_Igc1{WBbFK|yrx%R zkBEIn{QPFTZZr}reo>_~FGL6s)A8{_aVc^h+K4?| zJ{D;Q{%@xP_Pk=`tl5Oqb`8+(z?D|phcm6r&BIYrATtf;?Zk!kyYbG3eRymA-gnPF zcKA&Hqeol=PqyE(?f>4K*=BrWu*8f3sRaqE1s;PgbL3=ks};Y9YvmYJiO{E%;iYl` z`Un#y>`u(q*fD6+43oW-~gdt9wO8jR4Nc4G?+5kFlMHaqMR6YI51yt z!~1PE%(*DcwOFv+X2nz!HMzhr_eEr4&9Fp#%`cT9WUwGDZAMQ~?K9c>pPAm=+I1K6 zoWJYgP#DzM;M1AWT~>wj6~ZyM$$B=k-sP4tbTt#*t$EEuOPn5-o+Sw~{J&VrP|1iw^+ zkk*2*rW#{{77P~E@5|QxTwLB-cWSVp9-cBg&KHQ#t+k+^ZiHLmz_7@X*-j&B1~Dy- zxPi|6(j#UkMo2r-Rx3uG6vmuXruevlL_lFdnrK3TzZ1cthLhQPcZJJOHB@!yGv_XB z)nh=-pqH$NTgbr2b0Q$2Zx$WZ(1__6#P!aMC5(2AkXEE93(__UX@Z82TZaI*>1=5G zuAA?PcNL#z{Y%5TO*Gpc+|1;NZ3OQgRqK5gkTG25Yf;d zJ%9soy$w?ojgZWFE?m@-d4nF(t?w8nHXg{<``iqvH~cnfD8!(A+d8)2C)VzbRMnxc zzzKJW1KtW6zA7h%MGS&61|cPbu#&yfnQNx4aAHQ-V9Tf>Iu!a|2n4HI_DGY_@F0W(t5d~>bao`IeIqJ=-=jmdxsOAatDTZ zPWS~30wVSoUoi%LA%h_vjZrOyA)cxIV!q&TLc&3m$o2I)a_$L|<$J;w&dvA1I|7c} zdjb?^B;~A{9a5>j=Hoy9u&=V@O<$1`-mN5h`6akiC`Qj_27TL{7$|nYQ|7>6xf4T` ztkD_sav8YG*r{T`s8N@R^%ltB=M}v?D$Kbzs4RPMnh_#MR($#!@@FCGC@-9=6(VgF zUYb;yGhcr$a=G8^*+f0vE#h48N(<1vLx9U$E$G=oqc@*Me*ukwBGwr67glFhmbgxh z<$4uHt?JWnZ`9?E3UlvebA=U(4QZ<|KH=nFo3!)p&N>`0lu6U1@OM$I9PTRO<%{|H zJ?9>G9Jy4Wo*a^K(I+iNzlekWayj~o_2@4m(O+W5AXkr&QiU;Ego#=)=4%KHlo?}P zk68~~D$wq8^HitD^#n#Nf*HS-vn^Zqj!EhY@AC-+kNRYaQ|?M)s&BgyXP>ZKIr|iO z#v_z1d!=G{6$1D*JOp(-M2rF?C;?K=DvVVNFiJ}?W>X!kOedDQe^*Cq$g zTW(tlh)E1fjR+`o2ooAa^#l?o0!h})S|m(bjF}Ax%S~fvAF*#g|CnXX>+6-@d}C9^ zpADpY@(l|^t z##>nnUsW{%A_sy}D?$niVU_tKGa;m8w{8->h2|^$8)>|gZnrYGR4m>JZ(C5?qMs>Eo3?(1PxCqgTc}ocq`bQ zkHJut6GK(@%xd8m*)gKE!c%N*J^P4Faxvd{@?5#{XMG~k$&jkd5LMl!>5xsx_TobU zbF1}D5n0wHa4Ne?=~vu^H5fE$uMcZv@QEmRxz!jfuYreK4Zny+OiyCGR*Q(%h@Piu zbeA*#a!y1G&xouQ0sT(fP<7WgvUTnnK|`ZsjBG_**Nljw5&c|d^3u}`r_BaL zjVi<~Y9wtMq!|q+>IqCXsc@yqgqY5D>g;C5)mzCdN0m)T>YEWa?u6e|_r;tgR`8nY z9tj&8wgoixo7jF*A=(g-n?}Nl8U&>dLXuNA}Mv7_v{M+xHfSmTPcJHW*2P94C6Ekp^H)1%i6W%SI=-=FdzTCs`ZaI!X zekaBhE{tlbv8;E&r)_a(=las{7VbCpm+gL^{rJ&S;Cj{b + IgnorableNamespaces="uap uap5 com desktop rescap"> + + + + + + + + + + diff --git a/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs b/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs index af02d938f..8036011d9 100644 --- a/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs @@ -99,6 +99,9 @@ public void Tray_DeclaresOpenclawProtocolExtension() Assert.Equal("openclaw", (string?)protocol!.Attribute("Name")); } + private const string AppxComNs = "http://schemas.microsoft.com/appx/manifest/com/windows10"; + private const string AppxDesktopNs = "http://schemas.microsoft.com/appx/manifest/desktop/windows10"; + [Fact] public void Tray_DeclaresStartupTaskExtensionMatchingAutoStartManager() { @@ -113,6 +116,59 @@ public void Tray_DeclaresStartupTaskExtensionMatchingAutoStartManager() Assert.Equal("false", (string?)startupTask.Attribute("Enabled")); } + [Fact] + public void Tray_DeclaresToastNotificationActivationExtension() + { + // Without windows.toastNotificationActivation, MSIX packaged apps do NOT + // appear in Settings > Notifications until they fire a toast under package + // identity (and even then it can be delayed by several minutes). The pair + // of + registers the activator CLSID and + // makes the entry appear immediately on install. The two CLSIDs MUST + // match each other; this test pins both halves of the contract. + var doc = LoadTrayManifest(); + + var comClass = doc.Descendants(XName.Get("Class", AppxComNs)).SingleOrDefault(); + Assert.NotNull(comClass); + var comClassId = (string?)comClass!.Attribute("Id"); + + var toastActivation = doc.Descendants(XName.Get("ToastNotificationActivation", AppxDesktopNs)).SingleOrDefault(); + Assert.NotNull(toastActivation); + var toastClsid = (string?)toastActivation!.Attribute("ToastActivatorCLSID"); + + Assert.False(string.IsNullOrEmpty(comClassId), "COM class Id missing from manifest"); + Assert.False(string.IsNullOrEmpty(toastClsid), "ToastActivatorCLSID missing from manifest"); + Assert.Equal(comClassId, toastClsid); + + // App.OnLaunched MUST short-circuit '-ToastActivator' or Windows-spawned + // activator instances will fight the singleton mutex. Pin the early-exit. + var appXamlCs = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + Assert.Contains("\"-ToastActivator\"", appXamlCs); + } + + [Fact] + public void Tray_PrivacyListIcon_HasAllRequiredUnplatedTargetSizes() + { + // Settings > Privacy lists (Camera, Microphone, Location) render the per-app + // icon at small sizes (16, 20, 24, 32, 48 px). When a fitting altform-unplated + // variant is missing, Windows falls back to the plated tile with the manifest + // BackgroundColor as fill — which appears as a system-accent (blue) square + // behind our lobster. Reported by Mike during the MSIX-E2E manual test pass. + // + // Required minimum set covers the sizes Settings > Privacy is documented to + // request. Add more if you observe additional blue-background fallbacks. + var assetsDir = Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Assets"); + + foreach (var size in new[] { 16, 20, 24, 32, 44, 48, 256 }) + { + var fileName = $"Square44x44Logo.targetsize-{size}_altform-unplated.png"; + Assert.True(File.Exists(Path.Combine(assetsDir, fileName)), + $"Missing unplated icon variant '{fileName}'. Settings > Privacy will fall back to the plated tile, rendering with the manifest BackgroundColor."); + } + } + [Fact] public void Tray_TargetDeviceFamily_IsDesktopOnly_OnSupportedFloor() { From 9fdff05272342bf79809e684524c8740fe0a134f Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Tue, 19 May 2026 10:10:40 -0700 Subject: [PATCH 13/56] fix(uninstall): prune empty wsl\ and %LOCALAPPDATA%\OpenClawTray\ post-uninstall (#467) LocalGatewayUninstall Step 5a deleted %LOCALAPPDATA%\OpenClawTray\wsl\\ but never touched the parent wsl\ or the grandparent OpenClawTray\. After a clean full uninstall (gateway-remove + MSIX-uninstall) the user sees two phantom empty folders left behind. Bug reported as #467 with full repro and root cause diagnosis. Fix: - New Step 5b: 'Prune empty wsl\ parent directory' fires right after the VHD cleanup. Empty-guard ensures a sibling distro (e.g., openclaw-staging) under the same wsl\ parent is NEVER wiped. - New Step 12a: 'Prune empty %LOCALAPPDATA%\OpenClawTray\' fires at the very end (after Preserve mcp-token.txt, before Compute postconditions). Same empty-guard logic: only removes the directory if all per-artifact steps left it empty. If non-OpenClaw files remain, the step records Skipped with the remaining entry names in Detail so operators can see what's blocking the prune (catches new artifact-writers we forgot to wire into explicit deletes). Both steps are idempotent and safe to re-run. %APPDATA%\OpenClawTray\ (roaming) is intentionally NOT pruned because mcp-token.txt lives there and is preserved by design (Step 12). Tests (LocalGatewayUninstallTests.cs +4 regression tests): - FullUninstall_PrunesEmptyWslParent_Issue467 - FullUninstall_PrunesEmptyLocalAppDataRoot_Issue467 - FullUninstall_PreservesLocalAppDataRoot_WhenNonEmpty_Issue467 (defensive) - FullUninstall_PreservesWslParent_WhenSiblingDistroPresent_Issue467 (defensive) Validation: ./build.ps1 OK, Shared.Tests 1776 passed / 28 skipped, Tray.Tests 1131 passed (+4). Closes #467. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LocalGatewayUninstall.cs | 69 ++++++++++ .../LocalGatewayUninstallTests.cs | 119 ++++++++++++++++++ 2 files changed, 188 insertions(+) diff --git a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs index 8261562aa..9e1127bf0 100644 --- a/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs +++ b/src/OpenClaw.Tray.WinUI/Services/LocalGatewaySetup/LocalGatewayUninstall.cs @@ -420,6 +420,36 @@ await RunStepAsync("VHD parent dir cleanup", options, ct, () => return Task.CompletedTask; }); + // ------------------------------------------------------------------ + // Step 5b — Prune now-empty %LOCALAPPDATA%\OpenClawTray\wsl\ + // Step 5a deletes the per-distro VHD directory but never touches its + // parent. If wsl\ is now empty (no other distros) it would otherwise + // remain as a phantom folder visible to users who go looking after + // uninstall (issue #467). Empty-guard means we never accidentally + // delete a parent that still holds another OpenClaw distro's data. + // ------------------------------------------------------------------ + await RunStepAsync("Prune empty wsl\\ parent directory", options, ct, () => + { + var wslParent = Path.Combine(_localDataPath, "wsl"); + if (Directory.Exists(wslParent) && !Directory.EnumerateFileSystemEntries(wslParent).Any()) + { + Directory.Delete(wslParent); + RecordStep("Prune empty wsl\\ parent directory", UninstallStepStatus.Executed, + $"Removed empty {wslParent}."); + } + else if (!Directory.Exists(wslParent)) + { + RecordStep("Prune empty wsl\\ parent directory", UninstallStepStatus.Skipped, + "Directory absent."); + } + else + { + RecordStep("Prune empty wsl\\ parent directory", UninstallStepStatus.Skipped, + "Directory not empty (additional OpenClaw WSL state remains)."); + } + return Task.CompletedTask; + }); + // ------------------------------------------------------------------ // Step 6 — Reset autostart // CRITICAL ORDERING (v3 §B): persist settings BEFORE deleting registry. @@ -652,6 +682,45 @@ await RunStepAsync("Reset onboarding settings", options, ct, () => RecordStep("Preserve mcp-token.txt", UninstallStepStatus.Skipped, "mcp-token.txt preserved unconditionally (v3 §F). Not a gateway artifact."); + // ------------------------------------------------------------------ + // Step 12a — Prune now-empty %LOCALAPPDATA%\OpenClawTray\ + // After all the per-artifact deletes above, the local-app-data root + // is often empty but still on disk, leaving a phantom folder behind + // the user sees post-uninstall (issue #467). Empty-guard means we + // never delete a folder that still holds preserved logs / the user's + // unrelated files. Note: %APPDATA%\OpenClawTray\ (roaming) is + // intentionally NOT pruned here because mcp-token.txt lives there + // and is preserved by design. + // ------------------------------------------------------------------ + await RunStepAsync("Prune empty %LOCALAPPDATA%\\OpenClawTray\\", options, ct, () => + { + if (Directory.Exists(_localDataPath) && !Directory.EnumerateFileSystemEntries(_localDataPath).Any()) + { + Directory.Delete(_localDataPath); + RecordStep("Prune empty %LOCALAPPDATA%\\OpenClawTray\\", UninstallStepStatus.Executed, + $"Removed empty {_localDataPath}."); + } + else if (!Directory.Exists(_localDataPath)) + { + RecordStep("Prune empty %LOCALAPPDATA%\\OpenClawTray\\", UninstallStepStatus.Skipped, + "Directory absent."); + } + else + { + // Enumerate first-level remaining entries so the operator (or + // a follow-up support recipe) can see exactly what's blocking + // the prune. Helps catch new artifact-writers that weren't + // added to the explicit-delete steps above. + var remaining = Directory.EnumerateFileSystemEntries(_localDataPath) + .Take(8) + .Select(p => Path.GetFileName(p)) + .ToArray(); + RecordStep("Prune empty %LOCALAPPDATA%\\OpenClawTray\\", UninstallStepStatus.Skipped, + $"Directory not empty; remaining entries: {string.Join(", ", remaining)}."); + } + return Task.CompletedTask; + }); + // ------------------------------------------------------------------ // Step 13 — Compute postconditions // ------------------------------------------------------------------ diff --git a/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs b/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs index 995b80e73..8e2adc07a 100644 --- a/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalGatewayUninstallTests.cs @@ -1434,4 +1434,123 @@ public async Task DryRun_SuccessTrue_PostconditionsSkipped() Assert.True(result.Success); Assert.Empty(result.Errors); } + + // ----------------------------------------------------------------------- + // Issue #467: prune empty %LOCALAPPDATA%\OpenClawTray\wsl\ and the local + // root if they're empty after the per-artifact deletes. Bug repro: after + // a full clean uninstall, the user found %LOCALAPPDATA%\OpenClawTray\wsl\ + // and %LOCALAPPDATA%\OpenClawTray\ left behind as phantom empty folders. + // ----------------------------------------------------------------------- + + [WindowsFact] + public async Task FullUninstall_PrunesEmptyWslParent_Issue467() + { + using var env = new UninstallTestEnv(); + var vhdDir = Path.Combine(env.LocalDataDir, "wsl", "OpenClawGateway"); + Directory.CreateDirectory(vhdDir); + // Drop a fake .vhdx so the dir is non-empty initially. + File.WriteAllText(Path.Combine(vhdDir, "ext4.vhdx"), "fake-vhdx-bytes"); + + var engine = env.BuildEngine(); + var result = await engine.RunAsync(new LocalGatewayUninstallOptions + { + DryRun = false, + ConfirmDestructive = true + }); + + Assert.True(result.Success, $"Uninstall failed: {string.Join("; ", result.Errors)}"); + // wsl\ parent must be gone — that's the new Step 5b behavior. + Assert.False(Directory.Exists(Path.Combine(env.LocalDataDir, "wsl")), + "wsl\\ parent directory should be pruned when empty after VHD removal."); + var step = result.Steps.FirstOrDefault(s => s.Name == "Prune empty wsl\\ parent directory"); + Assert.NotNull(step); + Assert.Equal(UninstallStepStatus.Executed, step!.Status); + } + + [WindowsFact] + public async Task FullUninstall_PrunesEmptyLocalAppDataRoot_Issue467() + { + using var env = new UninstallTestEnv(); + var vhdDir = Path.Combine(env.LocalDataDir, "wsl", "OpenClawGateway"); + Directory.CreateDirectory(vhdDir); + File.WriteAllText(Path.Combine(vhdDir, "ext4.vhdx"), "fake-vhdx-bytes"); + + var engine = env.BuildEngine(); + var result = await engine.RunAsync(new LocalGatewayUninstallOptions + { + DryRun = false, + ConfirmDestructive = true + }); + + Assert.True(result.Success, $"Uninstall failed: {string.Join("; ", result.Errors)}"); + Assert.False(Directory.Exists(env.LocalDataDir), + "%LOCALAPPDATA%\\OpenClawTray\\ should be pruned when empty after full uninstall."); + var step = result.Steps.FirstOrDefault(s => s.Name == "Prune empty %LOCALAPPDATA%\\OpenClawTray\\"); + Assert.NotNull(step); + Assert.Equal(UninstallStepStatus.Executed, step!.Status); + } + + [WindowsFact] + public async Task FullUninstall_PreservesLocalAppDataRoot_WhenNonEmpty_Issue467() + { + // Defensive: if a future writer (or a user) drops a file directly under + // %LOCALAPPDATA%\OpenClawTray\ that none of the explicit-delete steps + // own, we MUST NOT recursively wipe it. The empty-guard on Step 12a + // protects against this; this test pins the protection. + using var env = new UninstallTestEnv(); + var vhdDir = Path.Combine(env.LocalDataDir, "wsl", "OpenClawGateway"); + Directory.CreateDirectory(vhdDir); + File.WriteAllText(Path.Combine(vhdDir, "ext4.vhdx"), "fake-vhdx-bytes"); + var unknownFile = Path.Combine(env.LocalDataDir, "user-dropped-something.txt"); + File.WriteAllText(unknownFile, "do not delete me"); + + var engine = env.BuildEngine(); + var result = await engine.RunAsync(new LocalGatewayUninstallOptions + { + DryRun = false, + ConfirmDestructive = true + }); + + Assert.True(result.Success, $"Uninstall failed: {string.Join("; ", result.Errors)}"); + Assert.True(Directory.Exists(env.LocalDataDir), + "%LOCALAPPDATA%\\OpenClawTray\\ MUST be preserved when it still holds non-OpenClaw files."); + Assert.True(File.Exists(unknownFile), + "Unknown user file under the root must not be touched."); + Assert.False(Directory.Exists(Path.Combine(env.LocalDataDir, "wsl")), + "wsl\\ subfolder is independently empty-pruned regardless of root state."); + + var step = result.Steps.FirstOrDefault(s => s.Name == "Prune empty %LOCALAPPDATA%\\OpenClawTray\\"); + Assert.NotNull(step); + Assert.Equal(UninstallStepStatus.Skipped, step!.Status); + Assert.Contains("remaining entries", step.Detail ?? ""); + } + + [WindowsFact] + public async Task FullUninstall_PreservesWslParent_WhenSiblingDistroPresent_Issue467() + { + // Defensive: a hypothetical "openclaw-staging" sibling distro would + // also live under %LOCALAPPDATA%\OpenClawTray\wsl\. Removing the + // configured distro's VHD must NOT wipe the sibling. + using var env = new UninstallTestEnv(); + var configuredVhd = Path.Combine(env.LocalDataDir, "wsl", "OpenClawGateway"); + var siblingVhd = Path.Combine(env.LocalDataDir, "wsl", "openclaw-staging"); + Directory.CreateDirectory(configuredVhd); + Directory.CreateDirectory(siblingVhd); + File.WriteAllText(Path.Combine(configuredVhd, "ext4.vhdx"), "fake-vhdx-bytes"); + File.WriteAllText(Path.Combine(siblingVhd, "ext4.vhdx"), "sibling-vhdx-bytes"); + + var engine = env.BuildEngine(); + var result = await engine.RunAsync(new LocalGatewayUninstallOptions + { + DryRun = false, + ConfirmDestructive = true, + DistroName = "OpenClawGateway" + }); + + Assert.True(result.Success, $"Uninstall failed: {string.Join("; ", result.Errors)}"); + Assert.False(Directory.Exists(configuredVhd), "Configured distro's VHD dir must be removed."); + Assert.True(Directory.Exists(siblingVhd), "Sibling distro's VHD dir must NOT be touched."); + Assert.True(Directory.Exists(Path.Combine(env.LocalDataDir, "wsl")), + "wsl\\ parent must be preserved when a sibling distro remains."); + } } From de5e73e8924f7492ea067b39ce1ec37df25df5dd Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Tue, 19 May 2026 10:26:13 -0700 Subject: [PATCH 14/56] fix(tests): standalone ghost-window recovery script + auto-cleanup in build.ps1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mike observed a new ghost Terminal frame appearing during the MSIX-E2E test pass even though the cherry-picked WinAppSdkGhostWindowCleanup (4 commits from wsl-keepalive-lifecycle) is in place. Root cause: The in-process cleanup only fires inside the testhost lifetime. It does NOT catch ghosts created by: - msbuild MSIX packaging (MakeAppx.exe, signtool.exe, WindowsAppSDK XAML markup compiler) — these run outside the testhost - testhost processes killed abnormally (SIGKILL, hung Ctrl+C, OOM) — the ProcessExit / AssemblyLoadContext.Unloading hooks never fire This adds a three-layer safety net so we never strand windows on a developer workstation OR on a future CI step that builds the MSIX without running tests: 1. scripts/cleanup-ghost-windows.ps1 - Standalone PowerShell recovery tool. Mirrors the in-process C# cleanup filter EXACTLY (CASCADIA_HOSTING_WINDOW_CLASS + title == 'Terminal' + owner WindowsTerminal + size >= 1000x500). Uses the proven close sequence that survived the 2026-05-19 manual test: ShowWindow(SW_HIDE) -> PostMessage(SYSCOMMAND,SC_CLOSE) -> SendMessageTimeout(WM_CLOSE, SMTO_ABORTIFHUNG, 1000ms). A plain SendMessage WM_SYSCOMMAND alone did NOT close the orphans during testing — Windows Terminal swallows it. - Up to 5 passes with a 500ms delay between (mirrors the C# CleanupBlankFramesRepeatedly logic). - Supports -WhatIf for safe enumeration and -Quiet for use in scripts. 2. build.ps1 hook - On a successful build the script runs automatically (-Quiet). MSIX packaging ghosts get cleaned before the developer notices. 3. AGENTS.md note - Documents the manual recovery path for any future agent / developer who sees Terminal windows piling up after an interrupted test run. Why we did not just make the in-process cleanup more aggressive: the baseline exclusion is deliberate to protect the developer's REAL Terminal windows (which start with title 'Terminal' until they type anything). Closing baseline frames would risk false positives. The standalone script is the right place for the looser-but-still-safe behavior because it's manually invoked or build-gated, not automatically firing in every test run. Tests (tests/OpenClaw.Tray.Tests/GhostWindowCleanupScriptContractTests.cs): + FilterMatchesProductionCleanup theory: pins the 4 filter constants that the script and in-process cleanup MUST share (class, owner, min size). + TitleFilter_IsExactlyTerminal_InBothImplementations: language-native quote-style match for the exact title comparison in each file. + CloseSequence_MatchesProvenMessageOrder: pins ShowWindow + PostMessage + SendMessageTimeout and the 4 message/flag constants by hex value. + Script_IsInvokedFromBuildPs1: pins the build.ps1 wiring so a refactor can't silently drop it. + Script_RejectsTinyWindowsToProtectUserTerminals: pins the 1000x500 min size as the safety guard against closing real user Terminals. Validation: ./build.ps1 OK (auto-cleanup runs at end), Shared.Tests 1776 passed / 28 skipped, Tray.Tests 1139 passed (+8 new contract tests). Manual verification on Mike's box: before fix, 1 leaked ghost; after running scripts/cleanup-ghost-windows.ps1, 0 ghosts. Subsequent ./build.ps1 + test runs left no leaks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 1 + build.ps1 | 10 + scripts/cleanup-ghost-windows.ps1 | 175 ++++++++++++++++++ .../GhostWindowCleanupScriptContractTests.cs | 131 +++++++++++++ 4 files changed, 317 insertions(+) create mode 100644 scripts/cleanup-ghost-windows.ps1 create mode 100644 tests/OpenClaw.Tray.Tests/GhostWindowCleanupScriptContractTests.cs diff --git a/AGENTS.md b/AGENTS.md index 6c288035f..94b419323 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ Notes: - `$env:OPENCLAW_REPO_ROOT='D:\github\moltbot-windows-hub.'` - Tray tests must isolate `SettingsManager` from real user settings. Do not use `new SettingsManager()` in tests unless the test intentionally reads `%APPDATA%\OpenClawTray\settings.json`; pass a temp settings directory or set `OPENCLAW_TRAY_DATA_DIR` before the test process starts. - Prefer isolated worktrees for PR validation. Use `git-wt` for worktree workflows; `wt.exe` may resolve to WorkTrunk instead of Windows Terminal, so use the full Windows Terminal path when explicitly launching Terminal. +- **Windows Terminal ghost frames**: tray tests and MSIX packaging tools (`MakeAppx`, `signtool`, the WindowsAppSDK markup compiler) can leak blank "Terminal" windows that survive testhost / msbuild exit. The in-process cleanup at `tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs` catches most of them; `build.ps1` invokes the manual fallback at the end of every build. If you see blank Terminal windows piling up after a manual / interrupted test run, run `scripts/cleanup-ghost-windows.ps1` (no admin needed; safe — only touches `CASCADIA_HOSTING_WINDOW_CLASS` windows ≥1000×500 with title literally `Terminal` owned by `WindowsTerminal`). - Do not claim completion without reporting validation results. ## Architecture Context for New Agents diff --git a/build.ps1 b/build.ps1 index 8d422ddcf..abb5ed1dc 100644 --- a/build.ps1 +++ b/build.ps1 @@ -253,6 +253,16 @@ if ($failCount -eq 0) { Write-Warning "Unable to determine WinUI target framework from $winUIProjectPath" } } + + # Auto-cleanup any Terminal ghost frames that snuck through during MSIX + # packaging / XAML compilation / signtool. The in-process test cleanup + # (tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs) only runs + # inside the testhost lifetime; build-time tooling that spawns Cascadia + # hosting windows leaks ghosts that this script catches. + $ghostCleanup = Join-Path $PSScriptRoot "scripts\cleanup-ghost-windows.ps1" + if (Test-Path $ghostCleanup) { + & $ghostCleanup -Quiet + } } else { Write-Host "❌ $failCount build(s) failed" -ForegroundColor Red exit 1 diff --git a/scripts/cleanup-ghost-windows.ps1 b/scripts/cleanup-ghost-windows.ps1 new file mode 100644 index 000000000..ce48fc22c --- /dev/null +++ b/scripts/cleanup-ghost-windows.ps1 @@ -0,0 +1,175 @@ +<# +.SYNOPSIS + Closes orphan Windows Terminal "ghost" frames left behind by tray tests + or MSIX packaging tools. + +.DESCRIPTION + A safety-net for the case where the in-process cleanup in + tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs cannot catch a + ghost — either because: + - the ghost was created by msbuild / MakeAppx.exe / signtool.exe spawning + a console child during MSIX packaging (runs outside the test process), or + - a testhost was killed abnormally (SIGKILL, hung Ctrl+C, OOM) before its + ProcessExit / AssemblyLoadContext.Unloading hooks could fire. + + Mirrors the production cleanup's filter so we don't ever close the user's + real Terminal windows by accident: window class must be + CASCADIA_HOSTING_WINDOW_CLASS, title must be EXACTLY "Terminal" (a real + interactive Terminal updates its title to the running command or cwd as + soon as the user types anything), owner must be the WindowsTerminal + process, and size must be at least 1000x500. + + The close sequence is the one we proved works against stuck Terminal + frames during MSIX-E2E manual testing: ShowWindow(SW_HIDE) -> + PostMessage(WM_SYSCOMMAND, SC_CLOSE) -> + SendMessageTimeout(WM_CLOSE, SMTO_ABORTIFHUNG, 1000ms). A plain + SendMessage(WM_SYSCOMMAND, SC_CLOSE) alone does NOT work — WindowsTerminal + swallows it on these orphan frames. + +.PARAMETER WhatIf + Lists matching ghosts without closing them. + +.PARAMETER Quiet + Suppress per-window status lines; only print the summary. + +.EXAMPLE + ./scripts/cleanup-ghost-windows.ps1 + +.EXAMPLE + ./scripts/cleanup-ghost-windows.ps1 -WhatIf + +.NOTES + This script is also invoked automatically at the end of ./build.ps1 on + Windows, after MSIX packaging steps that are known to spawn console + children. Running it manually is the recommended recovery if you see + blank Terminal frames piling up after running tests in this repo. +#> + +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [switch] $Quiet +) + +if (-not $IsWindows -and $PSVersionTable.Platform -ne 'Win32NT') { + if (-not $Quiet) { Write-Host "Not on Windows; nothing to do." } + return +} + +Add-Type -MemberDefinition @' +[System.Runtime.InteropServices.DllImport("user32.dll")] +public static extern bool EnumWindows(EnumWindowsProc enumProc, System.IntPtr lParam); + +[System.Runtime.InteropServices.DllImport("user32.dll", CharSet=System.Runtime.InteropServices.CharSet.Unicode)] +public static extern int GetWindowText(System.IntPtr hWnd, System.Text.StringBuilder text, int count); + +[System.Runtime.InteropServices.DllImport("user32.dll", CharSet=System.Runtime.InteropServices.CharSet.Unicode)] +public static extern int GetClassName(System.IntPtr hWnd, System.Text.StringBuilder text, int count); + +[System.Runtime.InteropServices.DllImport("user32.dll")] +public static extern uint GetWindowThreadProcessId(System.IntPtr hWnd, out uint lpdwProcessId); + +[System.Runtime.InteropServices.DllImport("user32.dll")] +public static extern bool IsWindowVisible(System.IntPtr hWnd); + +[System.Runtime.InteropServices.DllImport("user32.dll")] +public static extern bool ShowWindow(System.IntPtr hWnd, int nCmdShow); + +[System.Runtime.InteropServices.DllImport("user32.dll")] +public static extern bool PostMessage(System.IntPtr hWnd, uint msg, System.IntPtr wParam, System.IntPtr lParam); + +[System.Runtime.InteropServices.DllImport("user32.dll")] +public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.IntPtr wParam, System.IntPtr lParam, uint flags, uint timeout, out System.IntPtr result); + +[System.Runtime.InteropServices.DllImport("user32.dll")] +public static extern bool GetWindowRect(System.IntPtr hWnd, out RECT rect); + +[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] +public struct RECT { public int Left, Top, Right, Bottom; } + +public delegate bool EnumWindowsProc(System.IntPtr hWnd, System.IntPtr lParam); +'@ -Name TerminalGhostCleanupNative -Namespace OpenClawCleanup -ErrorAction SilentlyContinue + +function Find-GhostFrames { + $found = New-Object System.Collections.ArrayList + $proc = [OpenClawCleanup.TerminalGhostCleanupNative+EnumWindowsProc]{ + param($hWnd, $lParam) + if (-not [OpenClawCleanup.TerminalGhostCleanupNative]::IsWindowVisible($hWnd)) { return $true } + + $cls = New-Object System.Text.StringBuilder 256 + [OpenClawCleanup.TerminalGhostCleanupNative]::GetClassName($hWnd, $cls, 256) | Out-Null + if ($cls.ToString() -ne 'CASCADIA_HOSTING_WINDOW_CLASS') { return $true } + + $sb = New-Object System.Text.StringBuilder 256 + [OpenClawCleanup.TerminalGhostCleanupNative]::GetWindowText($hWnd, $sb, 256) | Out-Null + if ($sb.ToString() -ne 'Terminal') { return $true } + + $ownerPid = 0 + [OpenClawCleanup.TerminalGhostCleanupNative]::GetWindowThreadProcessId($hWnd, [ref]$ownerPid) | Out-Null + try { + $owner = Get-Process -Id $ownerPid -ErrorAction Stop + if ($owner.ProcessName -ne 'WindowsTerminal') { return $true } + } catch { return $true } + + $rect = New-Object OpenClawCleanup.TerminalGhostCleanupNative+RECT + if (-not [OpenClawCleanup.TerminalGhostCleanupNative]::GetWindowRect($hWnd, [ref]$rect)) { return $true } + $w = $rect.Right - $rect.Left + $h = $rect.Bottom - $rect.Top + if ($w -lt 1000 -or $h -lt 500) { return $true } + + $null = $found.Add([PSCustomObject]@{ + HWND = $hWnd; OwnerPid = $ownerPid; Width = $w; Height = $h + }) + return $true + } + [OpenClawCleanup.TerminalGhostCleanupNative]::EnumWindows($proc, [System.IntPtr]::Zero) | Out-Null + return $found +} + +function Close-GhostFrame { + param([System.IntPtr] $HWnd) + # Proven sequence: hide first so the screen doesn't strobe, then PostMessage + # SYSCOMMAND/SC_CLOSE (queued, non-blocking), then SendMessageTimeout WM_CLOSE + # with SMTO_ABORTIFHUNG so we never block on a hung Terminal frame. + [OpenClawCleanup.TerminalGhostCleanupNative]::ShowWindow($HWnd, 0) | Out-Null + [OpenClawCleanup.TerminalGhostCleanupNative]::PostMessage($HWnd, 0x0112, [System.IntPtr]0xF060, [System.IntPtr]::Zero) | Out-Null + $result = [System.IntPtr]::Zero + [OpenClawCleanup.TerminalGhostCleanupNative]::SendMessageTimeout($HWnd, 0x0010, [System.IntPtr]::Zero, [System.IntPtr]::Zero, 0x0002, 1000, [ref]$result) | Out-Null +} + +# Up to 5 passes with a short delay between, mirroring the C# CleanupBlankFramesRepeatedly. +$totalClosed = 0 +for ($pass = 1; $pass -le 5; $pass++) { + $ghosts = Find-GhostFrames + if ($ghosts.Count -eq 0) { break } + + if ($pass -eq 1 -and -not $Quiet) { + Write-Host ("Found {0} ghost Terminal frame(s):" -f $ghosts.Count) -ForegroundColor Yellow + } + + foreach ($g in $ghosts) { + if ($PSCmdlet.ShouldProcess("HWND $($g.HWND) (Owner WindowsTerminal PID $($g.OwnerPid), $($g.Width)x$($g.Height))", "Close")) { + Close-GhostFrame -HWnd $g.HWND + $totalClosed++ + if (-not $Quiet) { + Write-Host (" Pass {0}: closed HWND {1} ({2}x{3})" -f $pass, $g.HWND, $g.Width, $g.Height) -ForegroundColor Green + } + } + } + + Start-Sleep -Milliseconds 500 +} + +$remaining = (Find-GhostFrames).Count +if ($remaining -gt 0) { + Write-Host ("WARNING: {0} ghost frame(s) still present after {1} passes. Try running again or reboot." -f $remaining, 5) -ForegroundColor Red + exit 1 +} + +if (-not $Quiet) { + if ($totalClosed -eq 0) { + Write-Host "No ghost Terminal frames detected." + } else { + Write-Host ("Closed {0} ghost Terminal frame(s)." -f $totalClosed) -ForegroundColor Green + } +} +exit 0 diff --git a/tests/OpenClaw.Tray.Tests/GhostWindowCleanupScriptContractTests.cs b/tests/OpenClaw.Tray.Tests/GhostWindowCleanupScriptContractTests.cs new file mode 100644 index 000000000..76d45eeab --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/GhostWindowCleanupScriptContractTests.cs @@ -0,0 +1,131 @@ +using System; +using System.IO; + +namespace OpenClaw.Tray.Tests; + +/// +/// Pins the contract of scripts/cleanup-ghost-windows.ps1, the +/// standalone manual-recovery tool for Windows Terminal ghost frames. +/// +/// We can't actually invoke the script from a unit test (it manipulates +/// real OS windows and requires PowerShell). So we pin its surface as +/// source-text assertions: filter criteria match the production +/// exactly (class name, title, +/// owner process, size) so the safety-net script can never close a window +/// the in-process cleanup wouldn't have closed, and the close-message +/// sequence matches the one we proved works against real ghost frames +/// during MSIX-E2E manual testing on Mike's dev box (2026-05-19). +/// +/// Drift here is dangerous: a too-loose filter could close the user's +/// real Terminal windows; a missing close-message could leave ghosts +/// permanently visible. +/// +public sealed class GhostWindowCleanupScriptContractTests +{ + private static string GetRepositoryRoot() + { + var envRepoRoot = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (!string.IsNullOrWhiteSpace(envRepoRoot) && Directory.Exists(envRepoRoot)) + return envRepoRoot; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if ((Directory.Exists(Path.Combine(directory.FullName, ".git")) || + File.Exists(Path.Combine(directory.FullName, ".git"))) && + File.Exists(Path.Combine(directory.FullName, "README.md"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new InvalidOperationException( + "Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path."); + } + + private static string LoadScript() => + File.ReadAllText(Path.Combine( + GetRepositoryRoot(), "scripts", "cleanup-ghost-windows.ps1")); + + [Theory] + [InlineData("CASCADIA_HOSTING_WINDOW_CLASS", "Window class filter")] + [InlineData("WindowsTerminal", "Owner process filter")] + [InlineData("1000", "Minimum width")] + [InlineData("500", "Minimum height")] + public void FilterMatchesProductionCleanup(string token, string reason) + { + // Each criterion MUST also be in the C# in-process cleanup. If they + // diverge, the safety-net script could close windows the production + // path wouldn't (false positive risk against real user Terminals). + var script = LoadScript(); + var prod = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "tests", "OpenClaw.Tray.Tests", "WinAppSdkGhostWindowCleanup.cs")); + + Assert.True(script.Contains(token), $"Script missing filter token '{token}' ({reason})"); + Assert.True(prod.Contains(token), $"In-process cleanup missing filter token '{token}' ({reason})"); + } + + [Fact] + public void TitleFilter_IsExactlyTerminal_InBothImplementations() + { + // Special-cased from the FilterMatchesProductionCleanup theory because + // PowerShell uses single quotes for string literals ('Terminal') and + // C# uses double quotes ("Terminal"); the token "Terminal" itself is + // too common (it appears in comments, doc, the class name, etc.) for + // a bare-substring match to be meaningful. We assert each + // implementation uses the language-native exact-equals comparison + // against the literal string Terminal. + var script = LoadScript(); + var prod = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "tests", "OpenClaw.Tray.Tests", "WinAppSdkGhostWindowCleanup.cs")); + + // PowerShell: -ne 'Terminal' (return early if NOT a ghost-titled window) + Assert.Matches(@"-ne\s+'Terminal'", script); + // C#: title.ToString(), "Terminal", StringComparison.Ordinal + Assert.Matches(@"title\.ToString\(\)\s*,\s*""Terminal""\s*,\s*StringComparison\.Ordinal", prod); + } + + [Fact] + public void CloseSequence_MatchesProvenMessageOrder() + { + // The proven sequence from MSIX-E2E manual testing: + // 1. ShowWindow(SW_HIDE) - hides immediately so no strobe + // 2. PostMessage(WM_SYSCOMMAND, SC_CLOSE) - queued, non-blocking + // 3. SendMessageTimeout(WM_CLOSE, SMTO_ABORTIFHUNG, 1000ms) + // + // A plain SendMessage(WM_SYSCOMMAND, SC_CLOSE) alone does NOT work — + // WindowsTerminal swallows it on orphan frames. We saw this in the + // session 2026-05-19 (first attempted close: 0/9 ghosts removed; + // second attempt with this sequence: 9/9 removed in one pass). + var script = LoadScript(); + Assert.Contains("ShowWindow", script); + Assert.Contains("PostMessage", script); + Assert.Contains("SendMessageTimeout", script); + Assert.Contains("0x0112", script); // WM_SYSCOMMAND + Assert.Contains("0xF060", script); // SC_CLOSE + Assert.Contains("0x0010", script); // WM_CLOSE + Assert.Contains("0x0002", script); // SMTO_ABORTIFHUNG + } + + [Fact] + public void Script_IsInvokedFromBuildPs1() + { + // build.ps1 invokes the script after a successful build so MSIX + // packaging tool ghosts (MakeAppx, signtool, WindowsAppSDK markup + // compiler) are cleaned up before the developer notices. Pin the + // wiring so a future refactor of build.ps1 can't silently drop it. + var buildPs1 = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "build.ps1")); + Assert.Contains("cleanup-ghost-windows.ps1", buildPs1); + } + + [Fact] + public void Script_RejectsTinyWindowsToProtectUserTerminals() + { + // The 1000x500 minimum size is a deliberate safety guard: if a real + // user happens to have a small Terminal window with title "Terminal" + // (i.e. they just opened it and haven't typed anything yet), we must + // NOT close it. Production code enforces the same minimum. + var script = LoadScript(); + Assert.Matches(@"-lt\s+1000", script); // PowerShell less-than check on width + Assert.Matches(@"-lt\s+500", script); // height + } +} From 9fdf9999d3db5133a5c093fa7e4fa529fe5043ac Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Tue, 19 May 2026 14:47:36 -0700 Subject: [PATCH 15/56] fix(tests): ghost-cleanup script gains -Daemon and -InstallScheduledTask modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During PR creation Mike saw two new ghost Terminal frames appear, even though the cherry-picked WinAppSdkGhostWindowCleanup is in place AND build.ps1 wires the cleanup script at end-of-build. Root cause was a coverage gap, not a logic bug: On Win11 with Windows Terminal as the default terminal app (HKCU\Console\%%Startup\DelegationConsole = WT CLSID — the new Win11 default), EVERY console-spawning child process allocates a Cascadia hosting frame. Most close cleanly. A small fraction leak under timing conditions. Our cleanup only fires from triggers we wired: 1. testhost lifetime (in-process [ModuleInitializer] + xUnit attribute) 2. build.ps1 end-of-build Cascadia frames from gh / git / dotnet / pwsh invoked OUTSIDE of build.ps1 (e.g., 'gh pr create' creating the PR for this branch) are NOT caught by either trigger and leak indefinitely until reboot or manual cleanup. Fix: give scripts/cleanup-ghost-windows.ps1 two new modes for high-shell- activity sessions where the wired triggers aren't enough. -Daemon [-PollSeconds N] Foreground watcher; polls every N seconds (default 30, range 5..3600) and cleans any ghosts found. Use this when you're about to do a lot of shell work; Ctrl+C to stop. Useful for agent-driven branches like this one. -InstallScheduledTask Registers a Windows scheduled task (OpenClaw-Ghost-Terminal-Cleanup) that runs the script every 5 minutes under the current user, hidden window, 2-minute execution timeout, no admin needed. Idempotent (drops any prior registration first). Survives reboot, no shell session needed. Uninstall with -UninstallScheduledTask. Validated install/uninstall round-trip on Mike's box during commit prep: PT5M repetition, State=Ready, clean uninstall. AGENTS.md documents the Win11-default-terminal-app trigger explicitly and points future agents at the two escalation modes. Tests (+5): + Script_ExposesEscalationModesForOutOfBandLeaks theory pins -Daemon, -PollSeconds, -InstallScheduledTask, -UninstallScheduledTask presence in the script source. + Script_ScheduledTaskName_IsStable pins the task name so the AGENTS.md / support-recipe references can't silently drift between installer and uninstaller. This commit does NOT change the filter logic or close sequence — the 1000x500 + class + title + owner guards still protect real user Terminal windows. Only adds new invocation modes. Validation: ./build.ps1 OK, Shared.Tests 1776 passed / 28 skipped, Tray.Tests 1144 passed (+5). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 3 + scripts/cleanup-ghost-windows.ps1 | 178 +++++++++++++++--- .../GhostWindowCleanupScriptContractTests.cs | 28 +++ 3 files changed, 183 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 94b419323..4b3574ce2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,9 @@ Notes: - Tray tests must isolate `SettingsManager` from real user settings. Do not use `new SettingsManager()` in tests unless the test intentionally reads `%APPDATA%\OpenClawTray\settings.json`; pass a temp settings directory or set `OPENCLAW_TRAY_DATA_DIR` before the test process starts. - Prefer isolated worktrees for PR validation. Use `git-wt` for worktree workflows; `wt.exe` may resolve to WorkTrunk instead of Windows Terminal, so use the full Windows Terminal path when explicitly launching Terminal. - **Windows Terminal ghost frames**: tray tests and MSIX packaging tools (`MakeAppx`, `signtool`, the WindowsAppSDK markup compiler) can leak blank "Terminal" windows that survive testhost / msbuild exit. The in-process cleanup at `tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs` catches most of them; `build.ps1` invokes the manual fallback at the end of every build. If you see blank Terminal windows piling up after a manual / interrupted test run, run `scripts/cleanup-ghost-windows.ps1` (no admin needed; safe — only touches `CASCADIA_HOSTING_WINDOW_CLASS` windows ≥1000×500 with title literally `Terminal` owned by `WindowsTerminal`). + - **Win11 default-terminal-app trigger**: on a stock Win11 install, `HKCU\Console\%%Startup\DelegationConsole` is set to the Windows Terminal CLSID, which means EVERY console-spawning child (gh, git, dotnet, pwsh, an agent's tool invocations) allocates a Cascadia frame. Most close cleanly; a small fraction leak. For high-shell-activity sessions, prefer one of: + - `./scripts/cleanup-ghost-windows.ps1 -Daemon` (foreground watcher, Ctrl+C to stop) + - `./scripts/cleanup-ghost-windows.ps1 -InstallScheduledTask` (registers a hidden 5-minute task under the current user; uninstall with `-UninstallScheduledTask`) - Do not claim completion without reporting validation results. ## Architecture Context for New Agents diff --git a/scripts/cleanup-ghost-windows.ps1 b/scripts/cleanup-ghost-windows.ps1 index ce48fc22c..e92d0a0b7 100644 --- a/scripts/cleanup-ghost-windows.ps1 +++ b/scripts/cleanup-ghost-windows.ps1 @@ -26,18 +26,64 @@ SendMessage(WM_SYSCOMMAND, SC_CLOSE) alone does NOT work — WindowsTerminal swallows it on these orphan frames. + ## WHY YOU MAY STILL SEE GHOSTS AFTER A FIX + + On Win11 with Windows Terminal as the default terminal app (the new Win11 + default, registry value + HKCU\Console\%%Startup\DelegationConsole = {2EACA947-7F5F-4CFA-BA87-8F7FBEEFBE69}), + EVERY console-spawning child process gets a Cascadia hosting frame + allocated. Most close cleanly when the child exits; a small fraction + leak under timing conditions. The in-process test cleanup + (tests/OpenClaw.Tray.Tests/WinAppSdkGhostWindowCleanup.cs) only fires + inside the testhost lifetime. build.ps1's end-of-build hook only fires + after a successful build. NEITHER catches leaks from: + + - `gh`, `git`, `dotnet`, `pwsh`, or any other CLI you run from your + shell outside of `./build.ps1` + - Agent tooling that spawns fresh PowerShell sessions (each session's + console allocation is a fresh opportunity to leak) + - Killed-with-Ctrl+C / OOM processes that never ran teardown + + For high-shell-activity sessions (e.g., agent-driven work on this branch), + use `-Daemon` to run a background watcher, or `-InstallScheduledTask` for + always-on protection without keeping a console alive. + .PARAMETER WhatIf Lists matching ghosts without closing them. .PARAMETER Quiet Suppress per-window status lines; only print the summary. +.PARAMETER Daemon + Run continuously in the foreground, polling every -PollSeconds seconds + and cleaning any ghosts found. Use this when you're about to do a lot + of shell work and don't want to remember to clean up. Stop with Ctrl+C. + +.PARAMETER PollSeconds + Daemon mode polling interval. Default 30s; minimum 5s. + +.PARAMETER InstallScheduledTask + Register a Windows scheduled task that runs this script every 5 minutes + under the current user. Idempotent — replaces any existing task with the + same name. Use -UninstallScheduledTask to remove it. + +.PARAMETER UninstallScheduledTask + Unregister the scheduled task created by -InstallScheduledTask. + .EXAMPLE ./scripts/cleanup-ghost-windows.ps1 .EXAMPLE ./scripts/cleanup-ghost-windows.ps1 -WhatIf +.EXAMPLE + # Background watcher for the duration of a shell session: + ./scripts/cleanup-ghost-windows.ps1 -Daemon + +.EXAMPLE + # One-time setup for developers doing heavy shell work on this branch: + ./scripts/cleanup-ghost-windows.ps1 -InstallScheduledTask + .NOTES This script is also invoked automatically at the end of ./build.ps1 on Windows, after MSIX packaging steps that are known to spawn console @@ -45,9 +91,24 @@ blank Terminal frames piling up after running tests in this repo. #> -[CmdletBinding(SupportsShouldProcess = $true)] +[CmdletBinding(SupportsShouldProcess = $true, DefaultParameterSetName = 'OneShot')] param( - [switch] $Quiet + [Parameter(ParameterSetName = 'OneShot')] + [Parameter(ParameterSetName = 'Daemon')] + [switch] $Quiet, + + [Parameter(ParameterSetName = 'Daemon', Mandatory = $true)] + [switch] $Daemon, + + [Parameter(ParameterSetName = 'Daemon')] + [ValidateRange(5, 3600)] + [int] $PollSeconds = 30, + + [Parameter(ParameterSetName = 'InstallTask', Mandatory = $true)] + [switch] $InstallScheduledTask, + + [Parameter(ParameterSetName = 'UninstallTask', Mandatory = $true)] + [switch] $UninstallScheduledTask ) if (-not $IsWindows -and $PSVersionTable.Platform -ne 'Win32NT') { @@ -136,40 +197,105 @@ function Close-GhostFrame { [OpenClawCleanup.TerminalGhostCleanupNative]::SendMessageTimeout($HWnd, 0x0010, [System.IntPtr]::Zero, [System.IntPtr]::Zero, 0x0002, 1000, [ref]$result) | Out-Null } -# Up to 5 passes with a short delay between, mirroring the C# CleanupBlankFramesRepeatedly. -$totalClosed = 0 -for ($pass = 1; $pass -le 5; $pass++) { - $ghosts = Find-GhostFrames - if ($ghosts.Count -eq 0) { break } +function Invoke-OneShotCleanup { + param([switch] $Quiet) + $totalClosed = 0 + for ($pass = 1; $pass -le 5; $pass++) { + $ghosts = Find-GhostFrames + if ($ghosts.Count -eq 0) { break } - if ($pass -eq 1 -and -not $Quiet) { - Write-Host ("Found {0} ghost Terminal frame(s):" -f $ghosts.Count) -ForegroundColor Yellow - } + if ($pass -eq 1 -and -not $Quiet) { + Write-Host ("Found {0} ghost Terminal frame(s):" -f $ghosts.Count) -ForegroundColor Yellow + } - foreach ($g in $ghosts) { - if ($PSCmdlet.ShouldProcess("HWND $($g.HWND) (Owner WindowsTerminal PID $($g.OwnerPid), $($g.Width)x$($g.Height))", "Close")) { - Close-GhostFrame -HWnd $g.HWND - $totalClosed++ - if (-not $Quiet) { - Write-Host (" Pass {0}: closed HWND {1} ({2}x{3})" -f $pass, $g.HWND, $g.Width, $g.Height) -ForegroundColor Green + foreach ($g in $ghosts) { + if ($PSCmdlet.ShouldProcess("HWND $($g.HWND) (Owner WindowsTerminal PID $($g.OwnerPid), $($g.Width)x$($g.Height))", "Close")) { + Close-GhostFrame -HWnd $g.HWND + $totalClosed++ + if (-not $Quiet) { + Write-Host (" Pass {0}: closed HWND {1} ({2}x{3})" -f $pass, $g.HWND, $g.Width, $g.Height) -ForegroundColor Green + } } } + + Start-Sleep -Milliseconds 500 + } + + $remaining = (Find-GhostFrames).Count + if ($remaining -gt 0) { + Write-Host ("WARNING: {0} ghost frame(s) still present after 5 passes. Try running again or reboot." -f $remaining) -ForegroundColor Red + return @{ Closed = $totalClosed; Remaining = $remaining } + } + + if (-not $Quiet -and $totalClosed -eq 0) { + Write-Host "No ghost Terminal frames detected." + } elseif (-not $Quiet -and $totalClosed -gt 0) { + Write-Host ("Closed {0} ghost Terminal frame(s)." -f $totalClosed) -ForegroundColor Green } + return @{ Closed = $totalClosed; Remaining = 0 } +} - Start-Sleep -Milliseconds 500 +# ----------------------------------------------------------------------------- +# Scheduled-task installer +# ----------------------------------------------------------------------------- +$taskName = 'OpenClaw-Ghost-Terminal-Cleanup' + +if ($InstallScheduledTask) { + # Idempotent: drop any existing registration first. + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + + $scriptPath = $MyInvocation.MyCommand.Path + $action = New-ScheduledTaskAction ` + -Execute 'pwsh.exe' ` + -Argument "-NoProfile -WindowStyle Hidden -File `"$scriptPath`" -Quiet" + $trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) ` + -RepetitionInterval (New-TimeSpan -Minutes 5) + $settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -StartWhenAvailable ` + -ExecutionTimeLimit (New-TimeSpan -Minutes 2) + $principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive + + Register-ScheduledTask -TaskName $taskName ` + -Action $action -Trigger $trigger -Settings $settings -Principal $principal ` + -Description "Auto-close orphan Windows Terminal 'Terminal' frames left by OpenClaw tests and msbuild. See scripts/cleanup-ghost-windows.ps1." | Out-Null + + Write-Host "Installed scheduled task '$taskName' (runs every 5 minutes, current user, hidden)." -ForegroundColor Green + Write-Host " Uninstall: ./scripts/cleanup-ghost-windows.ps1 -UninstallScheduledTask" + exit 0 } -$remaining = (Find-GhostFrames).Count -if ($remaining -gt 0) { - Write-Host ("WARNING: {0} ghost frame(s) still present after {1} passes. Try running again or reboot." -f $remaining, 5) -ForegroundColor Red - exit 1 +if ($UninstallScheduledTask) { + $existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + if ($null -eq $existing) { + Write-Host "Scheduled task '$taskName' is not registered; nothing to remove." + exit 0 + } + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false | Out-Null + Write-Host "Unregistered scheduled task '$taskName'." -ForegroundColor Green + exit 0 } -if (-not $Quiet) { - if ($totalClosed -eq 0) { - Write-Host "No ghost Terminal frames detected." - } else { - Write-Host ("Closed {0} ghost Terminal frame(s)." -f $totalClosed) -ForegroundColor Green +# ----------------------------------------------------------------------------- +# Daemon mode +# ----------------------------------------------------------------------------- +if ($Daemon) { + Write-Host "Daemon mode: polling every $PollSeconds s. Ctrl+C to stop." -ForegroundColor Cyan + $cycle = 0 + while ($true) { + $cycle++ + $r = Invoke-OneShotCleanup -Quiet:$Quiet + if ($r.Closed -gt 0 -and -not $Quiet) { + Write-Host ("[{0}] cycle {1}: closed {2}" -f (Get-Date -Format HH:mm:ss), $cycle, $r.Closed) -ForegroundColor Green + } + Start-Sleep -Seconds $PollSeconds } } + +# ----------------------------------------------------------------------------- +# Default: one-shot +# ----------------------------------------------------------------------------- +$result = Invoke-OneShotCleanup -Quiet:$Quiet +if ($result.Remaining -gt 0) { exit 1 } exit 0 diff --git a/tests/OpenClaw.Tray.Tests/GhostWindowCleanupScriptContractTests.cs b/tests/OpenClaw.Tray.Tests/GhostWindowCleanupScriptContractTests.cs index 76d45eeab..dfd17af58 100644 --- a/tests/OpenClaw.Tray.Tests/GhostWindowCleanupScriptContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/GhostWindowCleanupScriptContractTests.cs @@ -128,4 +128,32 @@ public void Script_RejectsTinyWindowsToProtectUserTerminals() Assert.Matches(@"-lt\s+1000", script); // PowerShell less-than check on width Assert.Matches(@"-lt\s+500", script); // height } + + [Theory] + [InlineData("-Daemon", "long-running watcher for shell-heavy sessions")] + [InlineData("-PollSeconds", "configurable daemon poll interval")] + [InlineData("-InstallScheduledTask", "background recovery without keeping a console alive")] + [InlineData("-UninstallScheduledTask", "matching uninstaller")] + public void Script_ExposesEscalationModesForOutOfBandLeaks(string switchName, string reason) + { + // We can't catch every Cascadia frame leak from our wired triggers + // (testhost + build.ps1); ad-hoc shell invocations of gh/git/dotnet + // outside of build.ps1 leak too. The escalation modes let a developer + // who does heavy shell work on this branch keep their box clean. + // Drift here breaks the AGENTS.md guidance and the recovery story + // documented in commit de5e73e and beyond. + var script = LoadScript(); + Assert.True(script.Contains(switchName), + $"Cleanup script missing '{switchName}' switch ({reason})."); + } + + [Fact] + public void Script_ScheduledTaskName_IsStable() + { + // Pin the task name so the uninstaller matches what the installer + // registered. AGENTS.md and any future support recipes that refer + // to the task name will break silently if this drifts. + var script = LoadScript(); + Assert.Contains("OpenClaw-Ghost-Terminal-Cleanup", script); + } } From f8393accef96ab9ae8428c7a048f8b6731a1f972 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Tue, 19 May 2026 20:55:55 -0700 Subject: [PATCH 16/56] fix(msix): harden appinstaller update channel Use architecture-specific AppInstaller metadata, embed update settings in the MSIX package, avoid force-shutdown updates by default, and add release-hosting validation coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 70 +++++--- DEVELOPMENT.md | 15 +- docs/MSIX_E2E_TEST_RUNBOOK.md | 46 +++--- docs/RELEASING.md | 96 ++++++----- docs/SETUP.md | 15 +- docs/VERSIONING.md | 17 +- .../openclaw-companion.appinstaller.template | 32 +--- scripts/render-appinstaller.ps1 | 74 ++++++--- scripts/test-appinstaller-update.ps1 | 11 +- scripts/validate-appinstaller-hosting.ps1 | 154 ++++++++++++++++++ src/OpenClaw.Tray.WinUI/App.xaml.cs | 25 ++- .../OpenClaw.Tray.WinUI.csproj | 3 + src/OpenClaw.Tray.WinUI/Package.appxmanifest | 8 +- .../Services/AppInstallerUpdateService.cs | 61 ++++--- .../AppInstallerTemplateAssertionTests.cs | 84 ++++++---- .../MsixManifestAssertionTests.cs | 24 +++ 16 files changed, 491 insertions(+), 244 deletions(-) create mode 100644 scripts/validate-appinstaller-hosting.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6b88fe14..56fb8dc33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -316,6 +316,25 @@ jobs: $xml.Save((Resolve-Path $manifest)) Write-Host "Patched CommandPalette MSIX manifest to identity $cmdpalIdentity, publisher '$publisher', version $version" + - name: Render embedded AppInstaller + shell: pwsh + run: | + $version = "${{ needs.test.outputs.majorMinorPatch }}.0" + $assetVersion = "${{ needs.test.outputs.semVer }}" + $tag = "${{ github.ref_name }}" + $arch = if ("${{ matrix.rid }}" -eq "win-arm64") { "arm64" } else { "x64" } + $publisher = "CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US" + $base = "https://github.com/${{ github.repository }}/releases/download/$tag" + $msixUri = "$base/OpenClawCompanion-$assetVersion-${{ matrix.rid }}.msix" + $appInstallerUri = "https://openclaw.github.io/openclaw-windows-node/openclaw-$arch.appinstaller" + ./scripts/render-appinstaller.ps1 ` + -Version $version ` + -Publisher $publisher ` + -ProcessorArchitecture $arch ` + -MsixUri $msixUri ` + -AppInstallerUri $appInstallerUri ` + -OutputPath "src/OpenClaw.Tray.WinUI/openclaw.appinstaller" + - name: Build MSIX Package run: > msbuild src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -488,11 +507,9 @@ jobs: timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 - # Render the .appinstaller XML that drives non-Store auto-update for the MSIX. - # Windows AppInstaller polls the URL embedded in this file (see docs/RELEASING.md - # for the four update triggers). The rendered file is attached to the GitHub - # release alongside the .msix bundles AND must also be published at a stable - # URL so Windows AppInstaller has a non-tagged URL to poll over time. + # Render the hosted .appinstaller XML files that Windows AppInstaller polls + # after install. Each architecture has its own stable URL because the release + # currently publishes separate .msix packages, not a multi-arch .msixbundle. - name: Render AppInstaller if: steps.msix-x64.outcome == 'success' shell: pwsh @@ -503,21 +520,22 @@ jobs: $base = "https://github.com/${{ github.repository }}/releases/download/$tag" $x64Uri = "$base/OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix" $arm64Uri = "$base/OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix" - # The stable channel URL is served by GitHub Pages from the gh-pages branch. - # Publishing TO gh-pages is intentionally separate (see follow-up issue); - # the URL still has to be embedded here so installs from this tag know where - # to poll for the next update. - $appInstallerUri = "https://openclaw.github.io/openclaw-windows-node/latest.appinstaller" ./scripts/render-appinstaller.ps1 ` -Version $version ` -Publisher $publisher ` - -MsixX64Uri $x64Uri ` - -MsixArm64Uri $arm64Uri ` - -AppInstallerUri $appInstallerUri ` - -OutputPath "OpenClawCompanion-${{ needs.test.outputs.semVer }}.appinstaller" - # Also copy to a stable filename so the gh-pages publishing step (or a - # manual operator running the release) has a predictable filename. - Copy-Item "OpenClawCompanion-${{ needs.test.outputs.semVer }}.appinstaller" "latest.appinstaller" + -ProcessorArchitecture x64 ` + -MsixUri $x64Uri ` + -AppInstallerUri "https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller" ` + -OutputPath "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.appinstaller" + ./scripts/render-appinstaller.ps1 ` + -Version $version ` + -Publisher $publisher ` + -ProcessorArchitecture arm64 ` + -MsixUri $arm64Uri ` + -AppInstallerUri "https://openclaw.github.io/openclaw-windows-node/openclaw-arm64.appinstaller" ` + -OutputPath "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.appinstaller" + Copy-Item "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.appinstaller" "openclaw-x64.appinstaller" + Copy-Item "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.appinstaller" "openclaw-arm64.appinstaller" - name: Create Release uses: softprops/action-gh-release@v3 @@ -526,18 +544,22 @@ jobs: files: | OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix - OpenClawCompanion-${{ needs.test.outputs.semVer }}.appinstaller - latest.appinstaller + OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.appinstaller + OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.appinstaller + openclaw-x64.appinstaller + openclaw-arm64.appinstaller prerelease: ${{ contains(github.ref_name, '-') }} make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }} body: | ## OpenClaw Companion ${{ github.ref_name }} ### Downloads - - **AppInstaller (recommended)**: `latest.appinstaller` — install once and Windows auto-updates from then on (see docs/RELEASING.md for the 4 update triggers) - - **Tag-pinned AppInstaller**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}.appinstaller` - - **MSIX x64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix` — offline / power-user install (no auto-update) - - **MSIX ARM64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix` — Windows on ARM (Surface, Snapdragon) + - **MSIX x64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix` — Intel / AMD 64-bit, with embedded AppInstaller metadata + - **MSIX ARM64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix` — Windows on ARM, with embedded AppInstaller metadata + - **AppInstaller x64**: `openclaw-x64.appinstaller` — stable hosted update source for Intel / AMD 64-bit + - **AppInstaller ARM64**: `openclaw-arm64.appinstaller` — stable hosted update source for Windows on ARM + - **Tag-pinned AppInstaller x64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.appinstaller` + - **Tag-pinned AppInstaller ARM64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.appinstaller` ### Features - 🦞 System tray integration with gateway status @@ -553,6 +575,6 @@ jobs: - PowerToys (for Command Palette extension) ### Quick Start - 1. Click the `latest.appinstaller` link above — Windows AppInstaller will install the signed MSIX and wire up auto-update. + 1. Install the signed MSIX for your architecture. On supported Windows builds, the package includes embedded AppInstaller metadata for automatic background updates. 2. Launch from Start Menu or system tray. 3. Right-click tray icon → Settings to configure. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 418927a24..a4e086f45 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -612,18 +612,21 @@ When a tag is pushed (e.g., `git tag v1.2.3 && git push origin v1.2.3`): 1. **Build & Sign:** - MSIX packages built for x64 and ARM64 - - Both `.msix` files and the rendered `.appinstaller` are signed with the Azure Trusted Signing certificate + - Both `.msix` files are signed with the Azure Trusted Signing certificate after the architecture-specific AppInstaller metadata is embedded 2. **Render AppInstaller:** - - `scripts/render-appinstaller.ps1` produces both `OpenClawCompanion-X.Y.Z.appinstaller` (per-tag) and `latest.appinstaller` (stable filename for the gh-pages URL) - - See [`docs/RELEASING.md`](./docs/RELEASING.md) for the four AppInstaller update triggers + - `scripts/render-appinstaller.ps1` produces architecture-specific AppInstaller files: + `OpenClawCompanion-X.Y.Z-win-x64.appinstaller`, + `OpenClawCompanion-X.Y.Z-win-arm64.appinstaller`, + `openclaw-x64.appinstaller`, and `openclaw-arm64.appinstaller` + - See [`docs/RELEASING.md`](./docs/RELEASING.md) for the AppInstaller update flow 3. **GitHub Release:** - Automatic release created with tag name - Attached assets: - - `latest.appinstaller` (recommended user download) - - `OpenClawCompanion-X.Y.Z.appinstaller` (tag-pinned) - - `OpenClawCompanion-X.Y.Z-win-x64.msix` and `-win-arm64.msix` (offline / power-user) + - `OpenClawCompanion-X.Y.Z-win-x64.msix` and `-win-arm64.msix` + - `openclaw-x64.appinstaller` and `openclaw-arm64.appinstaller` (stable update sources) + - `OpenClawCompanion-X.Y.Z-win-x64.appinstaller` and `-win-arm64.appinstaller` (tag-pinned) - Release notes auto-generated from commits ### Monitoring CI diff --git a/docs/MSIX_E2E_TEST_RUNBOOK.md b/docs/MSIX_E2E_TEST_RUNBOOK.md index e326e03b9..7c632c3f4 100644 --- a/docs/MSIX_E2E_TEST_RUNBOOK.md +++ b/docs/MSIX_E2E_TEST_RUNBOOK.md @@ -24,10 +24,12 @@ multi-launch behaviour, real WSL distros, and the dirty-uninstall recovery. ## Scenarios -### 1. Clean install via `.appinstaller` +### 1. Clean install via signed MSIX 1. Open the GitHub release page in Edge. -2. Click `latest.appinstaller`. +2. Download the signed MSIX for the machine architecture: + `OpenClawCompanion-X.Y.Z-win-x64.msix` or + `OpenClawCompanion-X.Y.Z-win-arm64.msix`. 3. **Assert** Windows AppInstaller opens with: - Publisher: `CN=Scott Hanselman, O=Scott Hanselman, …` (no "untrusted") - DisplayName: `OpenClaw Companion` @@ -36,8 +38,9 @@ multi-launch behaviour, real WSL distros, and the dirty-uninstall recovery. 5. **Assert** the tray icon appears in the notification area within 5 s. 6. **Assert** `Get-AppxPackage OpenClaw.Companion*` returns one row with the expected `Publisher` and a 4-part `Version`. -7. **Assert** Settings → Apps shows "OpenClaw Companion" with the AppInstaller - source URL visible under "Installed from". +7. **Assert** `Package.GetAppInstallerInfo()` or an equivalent package query + reports the embedded architecture-specific AppInstaller URL on Windows builds + that support embedded App Installer metadata. ### 2. First-run permission consent (packaged path) @@ -128,23 +131,21 @@ support recipe works. ### 8. `.appinstaller` auto-update (vN → vN+1) -1. Install vN via `latest.appinstaller`. +1. Install vN via the signed MSIX on Windows 11 24H2 and via the hosted + architecture-specific `.appinstaller` on a downlevel Windows target. 2. Publish vN+1 by tagging `vX.Y.Z+1` and re-uploading the rendered - `latest.appinstaller` to GitHub Pages (the release pipeline produces the - file; the gh-pages publish is currently manual — see RELEASING.md). -3. **Trigger 1 (OnLaunch, passive):** Launch the tray. Wait up to 24 h - (or temporarily set `HoursBetweenUpdateChecks="0"` in a test render to - force the check on this launch). **Assert** the tray exits and relaunches - on the next start at vN+1. -4. **Trigger 2 (in-app, on demand):** From a fresh vN install, click - tray menu → "Check for updates". **Assert** the tray exits within ~5 s - and Windows restarts it at vN+1. **Assert** the in-app status surfaces - `UpdateQueued` (or `Current` if vN+1 wasn't published yet). -5. **Trigger 3 (Windows background scan):** Reinstall vN, sign out, sign - back in, give Windows 1–2 minutes. **Assert** the tray eventually - updates to vN+1 without any user interaction. Note: this trigger is - best-effort per Microsoft docs; do not fail the release if it doesn't - fire — just record the observation. + `openclaw-x64.appinstaller` / `openclaw-arm64.appinstaller` files to GitHub + Pages (the release pipeline produces the files; the gh-pages publish is + currently manual — see RELEASING.md). +3. **Trigger 1 (AutomaticBackgroundTask):** Leave the tray running and give + Windows enough time to poll the stable URL. **Assert** no App Installer UI + appears during normal launch. +4. **Trigger 2 (in-app, on demand):** From a fresh vN install, click tray menu + → "Check for updates". **Assert** the tray is not force-closed by default and + the in-app status surfaces `Ready` / "restart when convenient" or `Current` + if vN+1 was not published. +5. **Trigger 3 (explicit restart):** If a manual "Update now" affordance is + exposed, invoke it and assert Windows applies vN+1 after the explicit restart. ### 9. Sideload trust on a stock no-dev-mode box @@ -173,5 +174,6 @@ Record outcomes per scenario in the release tracking issue with: - Pass / Fail / Skip - Notes for any partial passes or unexpected dialogs -Promote `latest.appinstaller` to GitHub Pages only after scenarios 1, 2, 5, -6, 7, 8 (triggers 1 and 2), 9, and 10 all pass on at least one VM. +Promote `openclaw-x64.appinstaller` and `openclaw-arm64.appinstaller` to +GitHub Pages only after scenarios 1, 2, 5, 6, 7, 8 (triggers 1 and 2), 9, and +10 all pass on at least one VM. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 280fdd491..84096b994 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -53,54 +53,62 @@ After pushing a tag, confirm in GitHub Actions: ## Non-Store auto-update via `.appinstaller` OpenClaw Companion ships outside the Microsoft Store but still wants -silent-ish updates. The supported pattern is a hosted `.appinstaller` XML file -that Windows AppInstaller polls; the CI release job renders one per tag from +quiet updates. The supported pattern is a signed MSIX with embedded +`.appinstaller` metadata plus a hosted `.appinstaller` XML file that Windows +AppInstaller polls. The CI release job renders one file per architecture from `installer/openclaw-companion.appinstaller.template` via -`scripts/render-appinstaller.ps1` and attaches it to the GitHub release as -both `OpenClawCompanion-X.Y.Z.appinstaller` (per-tag) and `latest.appinstaller` -(stable filename for the published gh-pages URL). - -### Four ways an `.appinstaller` install gets to a new version - -When a user installs by clicking the `.appinstaller` (not the raw `.msix`), -Windows AppInstaller persists the source URL and the embedded -`` block. After that the following triggers can update the -install: - -1. **OnLaunch (passive)** — `HoursBetweenUpdateChecks="24" ShowPrompt="true" - UpdateBlocksActivation="false"`. Windows polls the URL no more than once per - 24 hours at app launch, prompts the user, and applies the update on the - *next* launch. This is the default path most users will see. -2. **OnLaunch (blocking)** — same poll, but with `UpdateBlocksActivation="true"` - the app waits while the update applies. We leave this OFF because it adds - user-visible cold-start latency. -3. **In-app, on demand** — the tray's "Check for updates" menu (when running - packaged) calls `PackageManager.AddPackageByAppInstallerFileAsync` against - `https://openclaw.github.io/openclaw-windows-node/latest.appinstaller`. This - bypasses the 24 h poll window and applies any newer published version - immediately (and restarts the app). -4. **Windows background scan** — Windows historically re-polls on user sign-in - and on Start-menu launches. This is best-effort and not contractually - guaranteed; never depend on it as the only update path for a particular - user cohort. +`scripts/render-appinstaller.ps1` and attaches both tag-pinned and stable +filenames to the GitHub release: + +- `OpenClawCompanion-X.Y.Z-win-x64.appinstaller` +- `OpenClawCompanion-X.Y.Z-win-arm64.appinstaller` +- `openclaw-x64.appinstaller` +- `openclaw-arm64.appinstaller` + +The `.appinstaller` policy intentionally uses only: + +```xml + + + +``` + +Do not add `OnLaunch` or `ForceUpdateFromAnyVersion` to production output. +Updates should happen quietly in the background and bad releases should be +fixed by shipping a higher roll-forward version. + +### How an install gets to a new version + +1. **Embedded App Installer metadata** — on Windows builds that support + `uap13:AutoUpdate`, double-clicking the signed MSIX seeds the stable + architecture-specific `.appinstaller` URL. +2. **Hosted `.appinstaller` install** — users or enterprise tools can install + from `openclaw-x64.appinstaller` or `openclaw-arm64.appinstaller`, which + records the same stable source URL. +3. **Windows background task** — `AutomaticBackgroundTask` lets Windows poll + that source URL without cold-start App Installer UI. +4. **In-app, on demand** — the tray's "Check for updates" command asks Windows + to fetch the architecture-specific `.appinstaller` and avoids force-closing + the tray by default. If an update is accepted while the app is in use, the UI + should tell the user to restart OpenClaw when convenient. ### Important caveats for the release operator -- The `Version` attribute in the rendered `.appinstaller` AND the `Version` - attribute inside `` AND the `` of the - attached MSIX must all match exactly. The CI rendering step asserts this; - if you hand-edit the rendered file before publishing, re-validate manually. -- The release notes "Quick Start" link should point at the **`.appinstaller`** - URL, not the raw `.msix`. A user who installs from a raw `.msix` does not - get the AppInstaller poll wired up and is stuck on that version until they - re-install via `.appinstaller`. -- The `latest.appinstaller` URL on GitHub Pages must keep pointing at the - currently shipping stable; pre-release alpha builds use their tag-specific - filename and never overwrite `latest.appinstaller`. -- Publishing `latest.appinstaller` to GitHub Pages is **a separate step** from - attaching it to the release. Until that's automated, the release operator - copies the file from the GitHub release into the `gh-pages` branch by hand - after the release artifacts are validated. +- The `Version` attribute in the rendered `.appinstaller`, the `Version` + attribute inside ``, and the `` of the + matching MSIX must all match exactly. +- The rendered `` must match the MSIX URL: + x64 files point at x64 MSIX assets and arm64 files point at ARM64 assets. +- The hosted stable URLs must serve correct headers before promotion: + `.appinstaller` as `application/appinstaller`, `.msix` as `application/msix`, + `Content-Length`, and MSIX range requests. +- Validate those headers before promotion: + `.\scripts\validate-appinstaller-hosting.ps1 -AppInstallerUri https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller` + and repeat for `openclaw-arm64.appinstaller`. +- Publishing `openclaw-x64.appinstaller` and `openclaw-arm64.appinstaller` to + GitHub Pages is **a separate step** from attaching them to the release. Until + that's automated, the release operator copies the files from the GitHub + release into the `gh-pages` branch by hand after validation. ## If you need to retag diff --git a/docs/SETUP.md b/docs/SETUP.md index ba89246a0..c8fa04975 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -14,19 +14,20 @@ Before installing, make sure you have: ### 1. Download the Installer -Go to the [Releases page](https://github.com/openclaw/openclaw-windows-node/releases) and download the **AppInstaller** file: +Go to the [Releases page](https://github.com/openclaw/openclaw-windows-node/releases) and download the signed **MSIX** for your architecture: | File | Description | |------|-------------| -| `latest.appinstaller` | Recommended — install once, Windows auto-updates from then on | -| `OpenClawCompanion-X.Y.Z-win-x64.msix` | Offline / power-user install for Intel / AMD (no auto-update) | -| `OpenClawCompanion-X.Y.Z-win-arm64.msix` | Offline / power-user install for ARM64 (Surface Pro X, Snapdragon laptops; no auto-update) | +| `OpenClawCompanion-X.Y.Z-win-x64.msix` | Recommended for Intel / AMD 64-bit; includes embedded AppInstaller metadata on supported Windows builds | +| `OpenClawCompanion-X.Y.Z-win-arm64.msix` | Recommended for ARM64 (Surface Pro X, Snapdragon laptops); includes embedded AppInstaller metadata on supported Windows builds | +| `openclaw-x64.appinstaller` | Stable hosted update source / alternate install path for Intel / AMD 64-bit | +| `openclaw-arm64.appinstaller` | Stable hosted update source / alternate install path for ARM64 | -If you're unsure, click **`latest.appinstaller`** — Windows AppInstaller will pick the right architecture for your machine and wire up automatic updates. +If you're unsure which architecture you need, most Intel/AMD PCs use x64 and Snapdragon/Surface-on-ARM devices use ARM64. A future MSIX bundle can collapse this to one download, but the current release uses architecture-specific packages. -### 2. Run the AppInstaller +### 2. Install the MSIX -Click `latest.appinstaller`. Windows AppInstaller opens, shows the publisher (Scott Hanselman, code-signed via Azure Trusted Signing), and offers to install. +Double-click the signed MSIX. Windows AppInstaller opens, shows the publisher (Scott Hanselman, code-signed via Azure Trusted Signing), and offers to install. On supported Windows builds, the MSIX also seeds the stable hosted `.appinstaller` URL for background updates. The install runs without requiring administrator privileges. diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 674f018d0..5a0a16df5 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -33,15 +33,11 @@ dotnet build -p:Version=${{ needs.test.outputs.semVer }} This `-p:Version=...` argument overrides the `` property in the csproj, and consequently also sets `FileVersion` and `AssemblyVersion` to match. -### Auto-Updater Version Detection +### AppInstaller Version Detection -The Updatum auto-updater determines the current application version by reading the **AssemblyVersion** from the running executable using: +Windows AppInstaller compares the 4-part MSIX package identity version from `Package.appxmanifest` with the `Version` attributes in the hosted `.appinstaller` and its `` entry. CI patches the manifest to `${majorMinorPatch}.0` before packaging and renders the `.appinstaller` with the same value. -```csharp -Assembly.GetExecutingAssembly().GetName().Version -``` - -This is why it's critical that `AssemblyVersion` (and `FileVersion`) match the semantic version - otherwise, the updater will get confused and keep offering the same update repeatedly. +The tray still reports its managed assembly version in diagnostics, so `AssemblyVersion` and `FileVersion` should continue to match the semantic version for supportability. ## Historical Issue @@ -56,9 +52,8 @@ Previously, the csproj files had hardcoded values: This caused a version mismatch: - The semantic version was 0.3.0 - But the file and assembly versions were stuck at 0.2.0 -- Updatum would read 0.2.0 from the running EXE -- It would see 0.4.0 available on GitHub -- It would offer to update from "0.2.0" to "0.4.0" even though the user was already on 0.3.0 or 0.4.0 +- The updater would read/report 0.2.0 from the running EXE +- It could offer or diagnose an update from "0.2.0" to "0.4.0" even though the user was already on 0.3.0 or 0.4.0 ## Solution @@ -74,5 +69,5 @@ By removing the hardcoded `FileVersion` and `AssemblyVersion` properties, they n ## References - [Microsoft Docs: Assembly Versioning](https://learn.microsoft.com/en-us/dotnet/standard/assembly/versioning) -- [Updatum Library](https://github.com/sn4k3/Updatum) +- [Microsoft Docs: App Installer file overview](https://learn.microsoft.com/en-us/windows/msix/app-installer/app-installer-file-overview) - [GitVersion Documentation](https://gitversion.net/) diff --git a/installer/openclaw-companion.appinstaller.template b/installer/openclaw-companion.appinstaller.template index 69b1ce70f..bf95ed34a 100644 --- a/installer/openclaw-companion.appinstaller.template +++ b/installer/openclaw-companion.appinstaller.template @@ -8,45 +8,27 @@ MSIX exactly) {{PUBLISHER}} - quoted publisher subject from the MSIX manifest e.g. "CN=Scott Hanselman, O=Scott Hanselman, …" - {{MSIX_X64_URI}} - absolute URL of the x64 .msix attached to the - release (https://github.com/.../releases/.../*.msix) - {{MSIX_ARM64_URI}} - absolute URL of the arm64 .msix + {{PROCESSOR_ARCHITECTURE}} - x64 or arm64; must match the MSIX package + {{MSIX_URI}} - absolute URL of the matching architecture .msix {{APPINSTALLER_URI}} - absolute URL of THIS rendered .appinstaller file on the stable channel (e.g. GitHub Pages) - See docs/RELEASING.md for the four AppInstaller update triggers and how - ShowPrompt / UpdateBlocksActivation / HoursBetweenUpdateChecks behave. + See docs/RELEASING.md for the AppInstaller update flow and hosting caveats. --> - + ProcessorArchitecture="{{PROCESSOR_ARCHITECTURE}}" + Uri="{{MSIX_URI}}" /> - + - - true diff --git a/scripts/render-appinstaller.ps1 b/scripts/render-appinstaller.ps1 index 87b680252..12ed347bc 100644 --- a/scripts/render-appinstaller.ps1 +++ b/scripts/render-appinstaller.ps1 @@ -19,38 +19,43 @@ Publisher subject from the MSIX manifest, with quoting preserved. Example: "CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US" -.PARAMETER MsixX64Uri - Absolute https:// URL of the x64 .msix release asset. +.PARAMETER ProcessorArchitecture + MSIX processor architecture for this AppInstaller file. Must be x64 or arm64. -.PARAMETER MsixArm64Uri - Absolute https:// URL of the arm64 .msix release asset. +.PARAMETER MsixUri + Absolute https:// URL of the matching architecture .msix release asset. .PARAMETER AppInstallerUri Absolute https:// URL of THIS rendered .appinstaller file on the stable - channel (e.g. https://openclaw.github.io/openclaw-windows-node/latest.appinstaller). + channel (e.g. https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller). Embedded inside the AppInstaller so Windows AppInstaller knows where to poll. .PARAMETER OutputPath Destination path for the rendered .appinstaller file. +.PARAMETER AllowHttpForLocalTest + Allows http:// loopback URIs for local AppInstaller smoke tests. Production + release rendering must omit this switch and use https:// URLs. + .EXAMPLE ./scripts/render-appinstaller.ps1 ` -Version 0.5.3.0 ` -Publisher 'CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US' ` - -MsixX64Uri https://github.com/.../v0.5.3/OpenClawCompanion-0.5.3-win-x64.msix ` - -MsixArm64Uri https://github.com/.../v0.5.3/OpenClawCompanion-0.5.3-win-arm64.msix ` - -AppInstallerUri https://openclaw.github.io/openclaw-windows-node/latest.appinstaller ` - -OutputPath OpenClawCompanion-0.5.3.appinstaller + -ProcessorArchitecture x64 ` + -MsixUri https://github.com/.../v0.5.3/OpenClawCompanion-0.5.3-win-x64.msix ` + -AppInstallerUri https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller ` + -OutputPath OpenClawCompanion-0.5.3-win-x64.appinstaller #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Version, [Parameter(Mandatory)] [string] $Publisher, - [Parameter(Mandatory)] [string] $MsixX64Uri, - [Parameter(Mandatory)] [string] $MsixArm64Uri, + [Parameter(Mandatory)] [ValidateSet('x64', 'arm64')] [string] $ProcessorArchitecture, + [Parameter(Mandatory)] [string] $MsixUri, [Parameter(Mandatory)] [string] $AppInstallerUri, - [Parameter(Mandatory)] [string] $OutputPath + [Parameter(Mandatory)] [string] $OutputPath, + [switch] $AllowHttpForLocalTest ) $ErrorActionPreference = 'Stop' @@ -68,15 +73,19 @@ foreach ($p in $parts) { } } -# Validate URIs are absolute https://. AppInstaller refuses to poll http:// in -# Windows 11 23H2+ (security policy) and a relative URL crashes the renderer. +# Validate URIs are absolute https:// for production. Local smoke tests may use +# http://127.0.0.1 with -AllowHttpForLocalTest. foreach ($pair in @( - @{ Name = 'MsixX64Uri'; Value = $MsixX64Uri }, - @{ Name = 'MsixArm64Uri'; Value = $MsixArm64Uri }, + @{ Name = 'MsixUri'; Value = $MsixUri }, @{ Name = 'AppInstallerUri'; Value = $AppInstallerUri } )) { $u = $null - if (-not [Uri]::TryCreate($pair.Value, 'Absolute', [ref]$u) -or $u.Scheme -ne 'https') { + if (-not [Uri]::TryCreate($pair.Value, 'Absolute', [ref]$u)) { + throw "$($pair.Name) must be an absolute URL. Got: '$($pair.Value)'" + } + + $isAllowedHttpLoopback = $AllowHttpForLocalTest -and $u.Scheme -eq 'http' -and $u.IsLoopback + if ($u.Scheme -ne 'https' -and -not $isAllowedHttpLoopback) { throw "$($pair.Name) must be an absolute https:// URL. Got: '$($pair.Value)'" } } @@ -93,11 +102,14 @@ $template = Get-Content $templatePath -Raw # regex-metacharacter surprises from values like a publisher subject that # contains literal commas/quotes or a URI with a percent-encoded character. $rendered = $template -$rendered = $rendered.Replace('{{VERSION}}', $Version) -$rendered = $rendered.Replace('{{PUBLISHER}}', $Publisher) -$rendered = $rendered.Replace('{{MSIX_X64_URI}}', $MsixX64Uri) -$rendered = $rendered.Replace('{{MSIX_ARM64_URI}}', $MsixArm64Uri) -$rendered = $rendered.Replace('{{APPINSTALLER_URI}}', $AppInstallerUri) +$rendered = $rendered.Replace('{{VERSION}}', $Version) +$rendered = $rendered.Replace('{{PUBLISHER}}', $Publisher) +$rendered = $rendered.Replace('{{PROCESSOR_ARCHITECTURE}}', $ProcessorArchitecture) +$rendered = $rendered.Replace('{{MSIX_URI}}', $MsixUri) +$rendered = $rendered.Replace('{{APPINSTALLER_URI}}', $AppInstallerUri) +if ($rendered -match '\{\{[A-Z0-9_]+\}\}') { + throw "Rendered XML still contains unresolved template token(s): $($Matches[0])" +} # Validate the rendered XML parses. A bad template / bad substitution surfaces # here instead of at deploy time when Windows refuses to install. @@ -105,8 +117,18 @@ $rendered = $rendered.Replace('{{APPINSTALLER_URI}}', $AppInstallerUri) if ($xml.AppInstaller.Version -ne $Version) { throw "Rendered XML has Version '$($xml.AppInstaller.Version)' but expected '$Version'. Substitution failure." } -if ($xml.AppInstaller.MainBundle.Publisher -ne $Publisher) { - throw "Rendered XML has Publisher '$($xml.AppInstaller.MainBundle.Publisher)' but expected '$Publisher'." +$mainPackage = $xml.AppInstaller.MainPackage +if ($null -eq $mainPackage) { + throw "Rendered XML must contain exactly one MainPackage element." +} +if ($mainPackage.Publisher -ne $Publisher) { + throw "Rendered XML has Publisher '$($mainPackage.Publisher)' but expected '$Publisher'." +} +if ($mainPackage.ProcessorArchitecture -ne $ProcessorArchitecture) { + throw "Rendered XML has ProcessorArchitecture '$($mainPackage.ProcessorArchitecture)' but expected '$ProcessorArchitecture'." +} +if ($mainPackage.Uri -ne $MsixUri) { + throw "Rendered XML has package Uri '$($mainPackage.Uri)' but expected '$MsixUri'." } $outDir = Split-Path -Parent $OutputPath @@ -118,6 +140,6 @@ Set-Content -Path $OutputPath -Value $rendered -Encoding UTF8 Write-Host "Rendered AppInstaller: $OutputPath" Write-Host " Version: $Version" Write-Host " Publisher: $Publisher" -Write-Host " MSIX x64 URI: $MsixX64Uri" -Write-Host " MSIX ARM64 URI: $MsixArm64Uri" +Write-Host " Architecture: $ProcessorArchitecture" +Write-Host " MSIX URI: $MsixUri" Write-Host " AppInstaller URI: $AppInstallerUri" diff --git a/scripts/test-appinstaller-update.ps1 b/scripts/test-appinstaller-update.ps1 index 83cc9927b..02949e1ce 100644 --- a/scripts/test-appinstaller-update.ps1 +++ b/scripts/test-appinstaller-update.ps1 @@ -9,7 +9,7 @@ XML and the PackageManager.AddPackageByAppInstallerFileAsync wiring *without* needing a real GitHub release / GitHub Pages cycle. Run this before a release tag goes out; if it fails, the same failure will happen - to every user that installs from latest.appinstaller. + to every user that installs from the stable architecture-specific AppInstaller URL. Steps: 1. Launch a tiny HTTP server (HttpListener) on localhost:8765 that serves @@ -75,10 +75,11 @@ try { & "$repoRoot\scripts\render-appinstaller.ps1" ` -Version $Version ` -Publisher $Publisher ` - -MsixX64Uri "$baseUri/$MsixFileName" ` - -MsixArm64Uri "$baseUri/$MsixFileName" ` + -ProcessorArchitecture x64 ` + -MsixUri "$baseUri/$MsixFileName" ` -AppInstallerUri "$baseUri/openclaw.appinstaller" ` - -OutputPath $OutputPath + -OutputPath $OutputPath ` + -AllowHttpForLocalTest } Render-AppInstaller -Version $VnVersion -MsixFileName 'vN.msix' -OutputPath (Join-Path $tmp 'openclaw.appinstaller') @@ -131,7 +132,7 @@ try { $pm = [Windows.Management.Deployment.PackageManager,Windows.Management.Deployment,ContentType=WindowsRuntime]::new() $op = $pm.AddPackageByAppInstallerFileAsync( [Uri]"$baseUri/openclaw.appinstaller", - [Windows.Management.Deployment.AddPackageByAppInstallerOptions]::ForceTargetAppShutdown, + [Windows.Management.Deployment.AddPackageByAppInstallerOptions]::None, $pm.GetDefaultPackageVolume()) $result = $op.AsTask().GetAwaiter().GetResult() if (-not $result.IsRegistered) { diff --git a/scripts/validate-appinstaller-hosting.ps1 b/scripts/validate-appinstaller-hosting.ps1 new file mode 100644 index 000000000..e6b0a6665 --- /dev/null +++ b/scripts/validate-appinstaller-hosting.ps1 @@ -0,0 +1,154 @@ +<# +.SYNOPSIS + Validates hosted AppInstaller and MSIX URLs before promoting a release. + +.DESCRIPTION + Windows AppInstaller is strict about hosted metadata and package assets. This + script checks the stable .appinstaller URL, parses its MainPackage URI when + -MsixUri is not provided, then validates the MSIX endpoint. It is intended for + release operators before copying openclaw-x64.appinstaller or + openclaw-arm64.appinstaller to the stable hosting branch/location. + +.PARAMETER AppInstallerUri + Stable hosted .appinstaller URL, e.g. + https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller. + +.PARAMETER MsixUri + Optional MSIX URL. When omitted, the script fetches AppInstallerUri and reads + the MainPackage Uri attribute. + +.EXAMPLE + ./scripts/validate-appinstaller-hosting.ps1 ` + -AppInstallerUri https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [Uri] $AppInstallerUri, + [Uri] $MsixUri +) + +$ErrorActionPreference = 'Stop' + +function Get-HeaderValue { + param( + [Parameter(Mandatory)] $Response, + [Parameter(Mandatory)] [string] $Name + ) + + $value = $Response.Headers[$Name] + if ($value -is [array]) { return $value[0] } + return $value +} + +function Invoke-Head { + param([Parameter(Mandatory)] [Uri] $Uri) + + try { + return Invoke-WebRequest -Uri $Uri -Method Head -MaximumRedirection 5 -UseBasicParsing + } + catch { + throw "HEAD $Uri failed: $($_.Exception.Message)" + } +} + +function Assert-HttpsUri { + param( + [Parameter(Mandatory)] [Uri] $Uri, + [Parameter(Mandatory)] [string] $Description + ) + + if ($Uri.Scheme -ne 'https') { + throw "$Description must use https: $Uri" + } +} + +function Assert-ContentType { + param( + [Parameter(Mandatory)] $Response, + [Parameter(Mandatory)] [Uri] $Uri, + [Parameter(Mandatory)] [string] $Expected + ) + + $contentType = Get-HeaderValue -Response $Response -Name 'Content-Type' + if ([string]::IsNullOrWhiteSpace($contentType) -or + -not $contentType.StartsWith($Expected, [StringComparison]::OrdinalIgnoreCase)) { + throw "$Uri returned Content-Type '$contentType'; expected '$Expected'." + } + Write-Host " Content-Type OK: $contentType" +} + +function Assert-ContentLength { + param( + [Parameter(Mandatory)] $Response, + [Parameter(Mandatory)] [Uri] $Uri + ) + + $contentLength = Get-HeaderValue -Response $Response -Name 'Content-Length' + if ([string]::IsNullOrWhiteSpace($contentLength)) { + throw "$Uri did not return Content-Length." + } + $parsedContentLength = 0L + if (-not [long]::TryParse($contentLength, [ref]$parsedContentLength)) { + throw "$Uri returned non-numeric Content-Length '$contentLength'." + } + Write-Host " Content-Length OK: $contentLength" +} + +function Assert-MsixRangeRequest { + param([Parameter(Mandatory)] [Uri] $Uri) + + try { + $response = Invoke-WebRequest -Uri $Uri ` + -Method Get ` + -Headers @{ Range = 'bytes=0-0' } ` + -MaximumRedirection 5 ` + -UseBasicParsing + } + catch { + throw "Range GET $Uri failed: $($_.Exception.Message)" + } + + if ($response.StatusCode -ne 206) { + throw "$Uri did not honor range request. Expected HTTP 206, got HTTP $($response.StatusCode)." + } + + $contentRange = Get-HeaderValue -Response $response -Name 'Content-Range' + if ([string]::IsNullOrWhiteSpace($contentRange)) { + throw "$Uri returned HTTP 206 but omitted Content-Range." + } + Write-Host " Range request OK: $contentRange" +} + +Write-Host "Validating AppInstaller hosting: $AppInstallerUri" +Assert-HttpsUri -Uri $AppInstallerUri -Description 'AppInstallerUri' +$appInstallerHead = Invoke-Head -Uri $AppInstallerUri +Assert-ContentType -Response $appInstallerHead -Uri $AppInstallerUri -Expected 'application/appinstaller' +Assert-ContentLength -Response $appInstallerHead -Uri $AppInstallerUri + +if ($null -eq $MsixUri) { + $appInstallerBody = Invoke-WebRequest -Uri $AppInstallerUri -Method Get -MaximumRedirection 5 -UseBasicParsing + [xml]$appInstallerXml = $appInstallerBody.Content + $namespaceManager = [System.Xml.XmlNamespaceManager]::new($appInstallerXml.NameTable) + $namespaceManager.AddNamespace('ai', 'http://schemas.microsoft.com/appx/appinstaller/2018') + $mainPackage = $appInstallerXml.SelectSingleNode('/ai:AppInstaller/ai:MainPackage', $namespaceManager) + if ($null -eq $mainPackage) { + $mainPackage = $appInstallerXml.SelectSingleNode('/AppInstaller/MainPackage') + } + + $mainPackageUri = if ($null -eq $mainPackage) { $null } else { $mainPackage.GetAttribute('Uri') } + if ([string]::IsNullOrWhiteSpace($mainPackageUri)) { + throw "$AppInstallerUri does not contain a MainPackage Uri." + } + $MsixUri = [Uri]$mainPackageUri + Write-Host "Discovered MSIX URI from AppInstaller: $MsixUri" +} + +Write-Host "Validating MSIX hosting: $MsixUri" +Assert-HttpsUri -Uri $MsixUri -Description 'MsixUri' +$msixHead = Invoke-Head -Uri $MsixUri +Assert-ContentType -Response $msixHead -Uri $MsixUri -Expected 'application/msix' +Assert-ContentLength -Response $msixHead -Uri $MsixUri +Assert-MsixRangeRequest -Uri $MsixUri + +Write-Host "AppInstaller hosting validation passed." -ForegroundColor Green diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 3ba5100f2..d3c02851d 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3271,16 +3271,12 @@ private void OnSettingsHotkeyPressed(object? sender, EventArgs e) private async Task CheckForUpdatesAsync() { - // Packaged apps under MSIX don't need an in-app startup poll — Windows - // AppInstaller polls our hosted .appinstaller per the OnLaunch settings - // embedded in the AppInstaller XML and applies updates on the NEXT - // launch (see docs/RELEASING.md for the four update triggers). Calling - // Updatum here would silently double-publish a "vN+1 available" toast - // for the upgrade the OS already has staged. Return true so the caller - // launches the app normally. + // Packaged apps under MSIX don't need an in-app startup poll. Windows + // AppInstaller polls our hosted .appinstaller with AutomaticBackgroundTask + // and the tray only surfaces pending/manual update state on demand. if (OpenClawTray.Helpers.PackageHelper.IsPackaged) { - Logger.Info("Skipping in-app update check (packaged build; AppInstaller polls OnLaunch)"); + Logger.Info("Skipping startup update check (packaged build; AppInstaller polls in the background)"); _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Managed", @@ -3294,7 +3290,7 @@ private async Task CheckForUpdatesAsync() try { // Unpackaged builds (dev / debug / CI hosts) have no shipping update - // channel — there's no MSIX to apply and Updatum has been removed. + // channel — there's no MSIX to apply. // Just stamp the UpdateInfo so the diagnostics panel reflects the // current state and let the app launch. Logger.Info("Skipping update check (unpackaged build; no update channel available)"); @@ -3325,10 +3321,9 @@ private async Task CheckForUpdatesUserInitiatedAsync() { Logger.Info("Manual update check requested"); - // Packaged: bypass any in-app dance and go directly to - // PackageManager.AddPackageByAppInstallerFileAsync. The AppInstaller URL - // is the single source of truth; if a newer version is published Windows - // will restart the app, otherwise we surface "already up to date". + // Packaged: ask Windows AppInstaller to check the stable architecture URL. + // The default path avoids force-closing the tray; Windows can finish + // package registration when OpenClaw exits or the user explicitly restarts. if (OpenClawTray.Helpers.PackageHelper.IsPackaged) { _appState!.UpdateInfo = new UpdateCommandCenterInfo @@ -3345,10 +3340,10 @@ private async Task CheckForUpdatesUserInitiatedAsync() { AppInstallerUpdateService.UpdateOutcome.UpdateQueued => new UpdateCommandCenterInfo { - Status = "Updating", + Status = "Ready", CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", CheckedAt = DateTime.UtcNow, - Detail = outcome.DetailMessage ?? "update queued; Windows will restart the app" + Detail = outcome.DetailMessage ?? "update accepted; restart OpenClaw when convenient" }, AppInstallerUpdateService.UpdateOutcome.NoUpdateAvailable => new UpdateCommandCenterInfo { diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index a8bd1300a..3d6fc1094 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -72,6 +72,9 @@ + diff --git a/src/OpenClaw.Tray.WinUI/Package.appxmanifest b/src/OpenClaw.Tray.WinUI/Package.appxmanifest index 901e5ed77..94480a76c 100644 --- a/src/OpenClaw.Tray.WinUI/Package.appxmanifest +++ b/src/OpenClaw.Tray.WinUI/Package.appxmanifest @@ -4,10 +4,12 @@ xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5" + xmlns:uap13="http://schemas.microsoft.com/appx/manifest/uap/windows10/13" + xmlns:uap17="http://schemas.microsoft.com/appx/manifest/uap/windows10/17" xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10" xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" - IgnorableNamespaces="uap uap5 com desktop rescap"> + IgnorableNamespaces="uap uap5 uap13 uap17 com desktop rescap"> - + + + + - + From 13fae0971fc82a7f599ce2bc283844a0b2caacca Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Wed, 20 May 2026 21:51:58 -0700 Subject: [PATCH 22/56] fix(msix): address top AppInstaller review findings Remove the blue-lobster update test marker from production UI, classify packages-in-use update results as pending restart, keep toast activator cold launches on the normal startup path, bind a single listener in the AppInstaller smoke script, constrain WSL orphan distro matching, and use the installed package volume for AppInstaller updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/test-appinstaller-update.ps1 | 32 ++++-- src/OpenClaw.Tray.WinUI/App.xaml.cs | 26 ++--- .../SidebarIcons/SettingsBlueLobster.svg | 11 -- .../Pages/SettingsPage.xaml | 13 +-- .../Services/AppInstallerUpdateService.cs | 105 ++++++++++++++---- .../Windows/HubWindow.xaml | 2 +- src/OpenClaw.WinNode.Cli/OrphanPurger.cs | 41 +++---- .../AppInstallerTemplateAssertionTests.cs | 70 ++++++++++++ .../MsixManifestAssertionTests.cs | 11 +- .../OrphanPurgerContractTests.cs | 24 +++- 10 files changed, 234 insertions(+), 101 deletions(-) delete mode 100644 src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/SettingsBlueLobster.svg diff --git a/scripts/test-appinstaller-update.ps1 b/scripts/test-appinstaller-update.ps1 index 02949e1ce..531416d0e 100644 --- a/scripts/test-appinstaller-update.ps1 +++ b/scripts/test-appinstaller-update.ps1 @@ -84,16 +84,11 @@ try { Render-AppInstaller -Version $VnVersion -MsixFileName 'vN.msix' -OutputPath (Join-Path $tmp 'openclaw.appinstaller') - # Spin up a HttpListener; HttpListenerPrefix needs http://+:Port/ for - # admin-less, http://127.0.0.1:Port/ for non-admin in restricted ACLs. - $listener = [System.Net.HttpListener]::new() - $listener.Prefixes.Add("$baseUri/") - $listener.Start() - Write-Host "Listening on $baseUri/" -ForegroundColor Cyan - + # Spin up exactly one HttpListener in a background job. Binding the same + # prefix in both parent and job makes the smoke test fail before AppInstaller + # is exercised. $listenerJob = Start-Job -ScriptBlock { param($prefix, $root) - Add-Type -AssemblyName System.Net.HttpListener $l = [System.Net.HttpListener]::new() $l.Prefixes.Add("$prefix/") $l.Start() @@ -113,6 +108,26 @@ try { } } -ArgumentList $baseUri, $tmp + $listenerReady = $false + for ($i = 0; $i -lt 20; $i++) { + if ($listenerJob.State -eq 'Failed') { + Receive-Job $listenerJob -Keep | Out-String | Write-Error + throw "AppInstaller test HTTP listener failed to start." + } + + try { + Invoke-WebRequest "$baseUri/openclaw.appinstaller" -UseBasicParsing -TimeoutSec 2 | Out-Null + $listenerReady = $true + break + } catch { + Start-Sleep -Milliseconds 250 + } + } + if (-not $listenerReady) { + throw "AppInstaller test HTTP listener did not serve $baseUri/openclaw.appinstaller." + } + Write-Host "Listening on $baseUri/" -ForegroundColor Cyan + try { # Step 2: install vN via the .appinstaller URL. Write-Host "Installing vN via $baseUri/openclaw.appinstaller ..." -ForegroundColor Cyan @@ -151,7 +166,6 @@ try { } finally { if ($listenerJob) { Stop-Job $listenerJob -ErrorAction SilentlyContinue; Remove-Job $listenerJob -Force -ErrorAction SilentlyContinue } - if ($listener.IsListening) { $listener.Stop(); $listener.Close() } } } finally { diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index a61faec5d..c38d07d43 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -585,22 +585,11 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) // Toast activator launch path. The manifest declares a windows.comServer + // windows.toastNotificationActivation pair (CLSID D4E7F816-…) so the app // shows up in Settings > Notifications immediately on install. When the - // user clicks an actionable toast, Windows spawns - // OpenClaw.Tray.WinUI.exe -ToastActivator - // expecting us to register the COM class with the matching CLSID and - // handle the activation. We don't currently consume click callbacks, but - // we still need to short-circuit this launch — otherwise Windows starts - // a second tray instance that immediately fights the single-instance - // mutex check below and either crashes or steals the user's running - // tray's state. + // tray is cold and Windows starts us with -ToastActivator, continue + // through normal launch so the user gets visible app chrome instead of a + // no-op. If the tray is already running, the single-instance guard below + // exits this secondary process without fighting the primary instance. // ----------------------------------------------------------------------- - if (_startupArgs.Contains("-ToastActivator", StringComparer.OrdinalIgnoreCase)) - { - // Windows already activated (or will already activate) the primary - // tray instance via the toast's argument string; nothing else to do. - Environment.Exit(0); - return; - } // Check for protocol activation (MSIX packaged apps receive deep links this way) string? protocolUri = GetProtocolActivationUri(); @@ -3469,6 +3458,13 @@ private async Task CheckForUpdatesUserInitiatedAsync() CheckedAt = DateTime.UtcNow, Detail = outcome.DetailMessage ?? "update accepted; restart OpenClaw when convenient" }, + AppInstallerUpdateService.UpdateOutcome.UpdatePendingRestart => new UpdateCommandCenterInfo + { + Status = "Ready", + CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CheckedAt = DateTime.UtcNow, + Detail = outcome.DetailMessage ?? "update available; close and reopen OpenClaw to finish" + }, AppInstallerUpdateService.UpdateOutcome.NoUpdateAvailable => new UpdateCommandCenterInfo { Status = "Current", diff --git a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/SettingsBlueLobster.svg b/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/SettingsBlueLobster.svg deleted file mode 100644 index 9184a3c93..000000000 --- a/src/OpenClaw.Tray.WinUI/Assets/SidebarIcons/SettingsBlueLobster.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml index 377a83910..9fa67af3f 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml @@ -7,7 +7,6 @@ 16,14,16,14 4,18,4,4 - @@ -20,15 +19,9 @@ - - - - + Windows accepted the update request; registration may complete after restart. UpdateQueued, + /// An update is available but Windows needs OpenClaw to exit before registration can finish. + UpdatePendingRestart, /// No newer version is currently published at the AppInstaller URL. NoUpdateAvailable, /// The call ran but Windows reported a non-fatal failure (e.g. network). @@ -55,6 +58,9 @@ public enum UpdateOutcome public record UpdateResult(UpdateOutcome Outcome, string? DetailMessage); + internal const int HResultPackagesInUse = unchecked((int)0x80073D02); + internal const int HResultPackageAlreadyExists = unchecked((int)0x80073CFB); + /// /// Asks Windows to fetch the MSIX advertised at the AppInstaller URL. /// By default this does not force-close the tray; callers that expose an @@ -86,34 +92,91 @@ public static async Task TryApplyUpdateAsync( var deploymentOperation = manager.AddPackageByAppInstallerFileAsync( uri, options, - manager.GetDefaultPackageVolume()); + ResolveCurrentPackageVolume(manager)); var result = await deploymentOperation.AsTask(); + return ClassifyDeploymentResult( + result.IsRegistered, + result.ExtendedErrorCode?.HResult ?? 0, + result.ErrorText, + forceRestart); + } + catch (Exception ex) + { + return new UpdateResult(UpdateOutcome.Failed, ex.Message); + } + } - if (result.IsRegistered) - { - return new UpdateResult(UpdateOutcome.UpdateQueued, - forceRestart - ? "Update applied; Windows will restart the app." - : "Update accepted; restart OpenClaw when convenient to finish."); - } + internal static UpdateResult ClassifyDeploymentResult( + bool isRegistered, + int hResult, + string? errorText, + bool forceRestart) + { + if (isRegistered) + { + return new UpdateResult(UpdateOutcome.UpdateQueued, + forceRestart + ? "Update applied; Windows will restart the app." + : "Update accepted; restart OpenClaw when convenient to finish."); + } + + return hResult switch + { + HResultPackagesInUse => new UpdateResult(UpdateOutcome.UpdatePendingRestart, + "An update is available, but OpenClaw is running. Close and reopen OpenClaw to finish installing it."), + 0 or HResultPackageAlreadyExists => new UpdateResult(UpdateOutcome.NoUpdateAvailable, + "Already on the latest version published at the AppInstaller URL."), + _ => new UpdateResult(UpdateOutcome.Failed, + $"PackageManager reported HRESULT 0x{unchecked((uint)hResult):X8}: {errorText}") + }; + } + + private static global::Windows.Management.Deployment.PackageVolume ResolveCurrentPackageVolume( + global::Windows.Management.Deployment.PackageManager manager) + { + var fallback = manager.GetDefaultPackageVolume(); - // ExtendedErrorCode is the canonical "why didn't it install" surface. - // 0x80073D02 (E_PACKAGES_IN_USE) is the typical "no update available" - // shape from AppInstaller; treat unknown failures as Failed not - // NoUpdateAvailable so the UI never silently lies about being up to date. - var hr = (uint)result.ExtendedErrorCode.HResult; - return hr switch + try + { + var installedPath = global::Windows.ApplicationModel.Package.Current.InstalledLocation.Path; + foreach (var volume in manager.FindPackageVolumes()) { - 0x80073D02 => new UpdateResult(UpdateOutcome.NoUpdateAvailable, - "Already on the latest version published at the AppInstaller URL."), - _ => new UpdateResult(UpdateOutcome.Failed, - $"PackageManager reported HRESULT 0x{hr:X8}: {result.ErrorText}") - }; + if (PathIsUnderRoot(installedPath, volume.MountPoint)) + return volume; + } } - catch (Exception ex) + catch (COMException ex) { - return new UpdateResult(UpdateOutcome.Failed, ex.Message); + LogPackageVolumeFallback(ex); + } + catch (InvalidOperationException ex) + { + LogPackageVolumeFallback(ex); + } + catch (IOException ex) + { + LogPackageVolumeFallback(ex); } + catch (UnauthorizedAccessException ex) + { + LogPackageVolumeFallback(ex); + } + + return fallback; + } + + private static void LogPackageVolumeFallback(Exception ex) => + Logger.Warn($"Failed to resolve current package volume; falling back to default volume: {ex.Message}"); + + private static bool PathIsUnderRoot(string path, string? root) + { + if (string.IsNullOrWhiteSpace(root)) + return false; + + var normalizedRoot = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return path.Equals(normalizedRoot, StringComparison.OrdinalIgnoreCase) || + path.StartsWith(normalizedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + path.StartsWith(normalizedRoot + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); } } diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml index 2ba1fecf0..5783ef36b 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml +++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml @@ -119,7 +119,7 @@ - + diff --git a/src/OpenClaw.WinNode.Cli/OrphanPurger.cs b/src/OpenClaw.WinNode.Cli/OrphanPurger.cs index 887d8ea5e..3339bdbe8 100644 --- a/src/OpenClaw.WinNode.Cli/OrphanPurger.cs +++ b/src/OpenClaw.WinNode.Cli/OrphanPurger.cs @@ -31,9 +31,7 @@ namespace OpenClaw.WinNode.Cli; internal static class OrphanPurger { /// - /// Substrings that identify a WSL distro as belonging to the OpenClaw - /// local-gateway flow. We match these case-insensitively against the - /// distro name returned by wsl --list --quiet. + /// WSL distro names that belong to the OpenClaw local-gateway flow. /// /// /// The local-gateway installer has used two naming conventions across the @@ -49,17 +47,16 @@ internal static class OrphanPurger /// /// Match is case-insensitive because wsl --list --quiet echoes /// the user-specified case verbatim and we cannot rely on either form. + /// Matching is intentionally anchored/exact so a user-created distro named + /// my-openclaw-experiments is never destroyed by the cleanup tool. /// - internal static readonly string[] OrphanWslDistroPatterns = new[] - { - "openclaw", // matches both "openclaw-*" and "OpenClawGateway" case-insensitively - }; + internal const string LegacyOpenClawGatewayDistroName = "OpenClawGateway"; /// /// Retained for backward compatibility with OrphanPurgerContractTests /// and for any external script that pattern-matches the historical /// "openclaw-" prefix. New detection logic should use - /// . + /// . /// internal const string OrphanWslDistroPrefix = "openclaw-"; @@ -182,19 +179,7 @@ private static IEnumerable DetectWslDistros(TextWriter stderr) { var line = rawLine.Trim().Trim('\u0000'); if (line.Length == 0) continue; - // Match against every documented pattern, case-insensitive. See - // OrphanWslDistroPatterns for why we accept both PascalCase and - // kebab-case forms. - var matched = false; - foreach (var pattern in OrphanWslDistroPatterns) - { - if (line.Contains(pattern, StringComparison.OrdinalIgnoreCase)) - { - matched = true; - break; - } - } - if (!matched) continue; + if (!IsOpenClawOwnedWslDistroName(line)) continue; yield return new OrphanItem( Kind: "wsl-distro", Name: line, @@ -202,6 +187,10 @@ private static IEnumerable DetectWslDistros(TextWriter stderr) } } + internal static bool IsOpenClawOwnedWslDistroName(string distroName) => + distroName.Equals(LegacyOpenClawGatewayDistroName, StringComparison.OrdinalIgnoreCase) || + distroName.StartsWith(OrphanWslDistroPrefix, StringComparison.OrdinalIgnoreCase); + private static IEnumerable DetectFileOrphans(Func envLookup) { foreach (var candidate in new[] @@ -329,16 +318,18 @@ private static async Task RemoveAsync(OrphanItem orphan, TextWriter stderr) private static async Task RunWslUnregister(string distroName, TextWriter stderr) { - // We deliberately do NOT shell out via cmd /c — wsl.exe arguments don't - // need quoting in this case and going through cmd lets a maliciously - // named distro inject extra commands. - var psi = new ProcessStartInfo("wsl.exe", $"--unregister {distroName}") + // We deliberately do NOT shell out via cmd /c. ArgumentList keeps distro + // names with spaces as a single argv element without opening command + // injection risk. + var psi = new ProcessStartInfo("wsl.exe") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; + psi.ArgumentList.Add("--unregister"); + psi.ArgumentList.Add(distroName); var proc = Process.Start(psi) ?? throw new InvalidOperationException("wsl.exe failed to launch"); await proc.WaitForExitAsync(); diff --git a/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs b/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs index b92059252..af3388c9e 100644 --- a/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs @@ -131,6 +131,65 @@ public void InAppService_DoesNotForceShutdownByDefault() Assert.DoesNotContain("TryApplyUpdateAsync(forceRestart: true", app); } + [Fact] + public void InAppService_DoesNotReportPackagesInUseAsNoUpdateAvailable() + { + var service = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Services", "AppInstallerUpdateService.cs")); + var app = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + + Assert.Contains("HResultPackagesInUse", service); + Assert.Contains("UpdatePendingRestart", service); + Assert.Contains("UpdatePendingRestart", app); + Assert.DoesNotContain("0x80073D02 => new UpdateResult(UpdateOutcome.NoUpdateAvailable", service); + } + + [Fact] + public void InAppService_UsesCurrentPackageVolumeBeforeDefaultFallback() + { + var service = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Services", "AppInstallerUpdateService.cs")); + + Assert.Contains("ResolveCurrentPackageVolume(manager)", service); + Assert.Contains("Package.Current.InstalledLocation.Path", service); + Assert.Contains("manager.FindPackageVolumes()", service); + Assert.Contains("manager.GetDefaultPackageVolume()", service); + } + + [Fact] + public void ToastActivatorColdLaunch_DoesNotExitBeforeSingleInstanceGuard() + { + var app = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + + Assert.Contains("SingleInstanceLaunchGuard.Acquire", app); + Assert.DoesNotContain("Environment.Exit(0);", app); + } + + [Fact] + public void ProductionSettingsUi_DoesNotContainBlueLobsterTestMarker() + { + var hub = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Windows", "HubWindow.xaml")); + var settings = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Pages", "SettingsPage.xaml")); + + Assert.Contains("Assets/SidebarIcons/Settings.svg", hub); + Assert.DoesNotContain("SettingsBlueLobster", hub); + Assert.DoesNotContain("SettingsBlueLobster", settings); + Assert.DoesNotContain("Blue lobster update test icon", settings); + Assert.False(File.Exists(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Assets", "SidebarIcons", "SettingsBlueLobster.svg"))); + } + [Fact] public void HostingValidationScript_ChecksMimeLengthAndRange() { @@ -144,4 +203,15 @@ public void HostingValidationScript_ChecksMimeLengthAndRange() Assert.Contains("Range = 'bytes=0-0'", script); Assert.Contains("StatusCode -ne 206", script); } + + [Fact] + public void AppInstallerUpdateSmokeScript_BindsSingleHttpListenerAndSelfChecks() + { + var script = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), "scripts", "test-appinstaller-update.ps1")); + + Assert.DoesNotContain("$listener = [System.Net.HttpListener]::new()", script); + Assert.Contains("Invoke-WebRequest \"$baseUri/openclaw.appinstaller\"", script); + Assert.Contains("$listenerJob.State -eq 'Failed'", script); + } } diff --git a/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs b/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs index 07306ba02..8b10c73f5 100644 --- a/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs @@ -155,6 +155,8 @@ public void Tray_DeclaresToastNotificationActivationExtension() var comClass = doc.Descendants(XName.Get("Class", AppxComNs)).SingleOrDefault(); Assert.NotNull(comClass); var comClassId = (string?)comClass!.Attribute("Id"); + var exeServer = doc.Descendants(XName.Get("ExeServer", AppxComNs)).SingleOrDefault(); + Assert.NotNull(exeServer); var toastActivation = doc.Descendants(XName.Get("ToastNotificationActivation", AppxDesktopNs)).SingleOrDefault(); Assert.NotNull(toastActivation); @@ -163,12 +165,15 @@ public void Tray_DeclaresToastNotificationActivationExtension() Assert.False(string.IsNullOrEmpty(comClassId), "COM class Id missing from manifest"); Assert.False(string.IsNullOrEmpty(toastClsid), "ToastActivatorCLSID missing from manifest"); Assert.Equal(comClassId, toastClsid); + Assert.Equal("-ToastActivator", (string?)exeServer!.Attribute("Arguments")); - // App.OnLaunched MUST short-circuit '-ToastActivator' or Windows-spawned - // activator instances will fight the singleton mutex. Pin the early-exit. + // Cold toast activator launches must continue through normal startup so + // toast clicks are not silent no-ops. The single-instance guard handles + // the already-running case. var appXamlCs = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); - Assert.Contains("\"-ToastActivator\"", appXamlCs); + Assert.Contains("SingleInstanceLaunchGuard.Acquire", appXamlCs); + Assert.DoesNotContain("Environment.Exit(0);", appXamlCs); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs b/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs index 2e727b2b9..ac1d5cc94 100644 --- a/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs @@ -49,24 +49,36 @@ public void OrphanWslDistroPrefix_IsTheOpenclawPrefix() // openclaw- prefix. Drift here means a real orphan goes undetected // (we silently miss what we promised to clean) — pin it. Retained // for backward compat after the 2026-05 case-insensitive fix added - // OrphanWslDistroPatterns. + // exact legacy-name matching for OpenClawGateway. Assert.Contains("OrphanWslDistroPrefix = \"openclaw-\"", LoadOrphanPurgerSource()); } [Fact] - public void WslDistroDetection_IsCaseInsensitive_AndCatchesLegacyOpenClawGateway() + public void WslDistroDetection_IsCaseInsensitive_Anchored_AndCatchesLegacyOpenClawGateway() { // Regression: during MSIX-E2E manual test prep we found Mike's box // had an OpenClawGateway (PascalCase, no dash) distro installed by // the historical local-gateway flow. The original "openclaw-" // case-sensitive prefix would silently miss it, meaning a user who // ran --purge-wsl-orphans would be told "no orphans" while a 2.6 GB - // .vhdx orphan was still on disk. Pin the case-insensitive substring - // strategy so future refactors can't reintroduce the bug. + // .vhdx orphan was still on disk. Pin the case-insensitive exact legacy + // name + anchored prefix strategy so future refactors cannot drift back + // to destructive substring matching. var src = LoadOrphanPurgerSource(); - Assert.Contains("OrphanWslDistroPatterns", src); + Assert.Contains("LegacyOpenClawGatewayDistroName = \"OpenClawGateway\"", src); + Assert.Contains("StartsWith(OrphanWslDistroPrefix, StringComparison.OrdinalIgnoreCase)", src); + Assert.Contains("Equals(LegacyOpenClawGatewayDistroName, StringComparison.OrdinalIgnoreCase)", src); Assert.Contains("StringComparison.OrdinalIgnoreCase", src); - Assert.Contains("OpenClawGateway", src); // documented in the remarks + Assert.DoesNotContain("line.Contains(pattern", src); + } + + [Fact] + public void WslUnregister_UsesArgumentListForDistroNames() + { + var src = LoadOrphanPurgerSource(); + Assert.Contains("psi.ArgumentList.Add(\"--unregister\")", src); + Assert.Contains("psi.ArgumentList.Add(distroName)", src); + Assert.DoesNotContain("$\"--unregister {distroName}\"", src); } [Fact] From 357401dd73803ab43b2186810d938343c2efd207 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Thu, 21 May 2026 08:18:31 -0700 Subject: [PATCH 23/56] docs(msix): compare stable update endpoints Add a maintainer-facing comparison of durable AppInstaller update feed endpoint options and recommend a project-owned custom domain backed by static hosting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/MSIX_UPDATE_ENDPOINT_OPTIONS.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 docs/MSIX_UPDATE_ENDPOINT_OPTIONS.md diff --git a/docs/MSIX_UPDATE_ENDPOINT_OPTIONS.md b/docs/MSIX_UPDATE_ENDPOINT_OPTIONS.md new file mode 100644 index 000000000..1a5983aa5 --- /dev/null +++ b/docs/MSIX_UPDATE_ENDPOINT_OPTIONS.md @@ -0,0 +1,28 @@ +# MSIX update endpoint options + +OpenClaw's MSIX/AppInstaller update feed needs a stable HTTPS URL for each architecture-specific `.appinstaller` file. The URL should outlive any one maintainer account, release host, or CI implementation because Windows stores it as the update source for installed packages. + +## Recommendation + +Use a project-owned custom domain such as `https://updates.openclaw.dev/windows/msix/openclaw-x64.appinstaller`, backed by Azure Static Web Apps or Azure Blob Storage plus CDN/Front Door. This keeps the public contract independent from the hosting backend and avoids tying an unofficial community project to Microsoft-owned `aka.ms` infrastructure. + +## Options + +| Option | Example endpoint | Pros | Cons / risks | Best fit | Recommendation | +|---|---|---|---|---|---| +| Project-owned custom domain backed by Azure Static Web Apps | `https://updates.openclaw.dev/windows/msix/openclaw-x64.appinstaller` | Stable public contract; easy static hosting; GitHub Actions deploy works well; can serve required MIME, `Content-Length`, and range headers; backend can change later behind DNS | Requires domain/DNS ownership and a small Azure resource; maintainers must manage deployment credentials | Community-owned project that wants a durable updater URL without taking on much infra | **Preferred** | +| Project-owned custom domain backed by Azure Blob Storage + CDN or Front Door | `https://updates.openclaw.dev/windows/msix/openclaw-x64.appinstaller` | Very durable object storage; strong header control; CDN/range support; easy to keep historical MSIX assets available | More Azure configuration than Static Web Apps; CDN caching needs careful invalidation for stable `.appinstaller` filenames | Higher-scale or more operations-friendly version of the preferred model | **Preferred if maintainers are comfortable with Azure ops** | +| GitHub Pages from the main repo, optionally behind a custom domain | `https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller` or custom-domain equivalent | Simple; close to repo/release workflow; no separate cloud account if Pages is acceptable; good interim path | Requires Pages on the main repo; stable feed publishing is separate from GitHub Release attachment unless automated; direct `github.io` URL couples installed clients to GitHub Pages | Interim endpoint or long-term endpoint only if maintainers want GitHub Pages as project infrastructure | **Acceptable interim; better behind custom domain** | +| GitHub Pages from a dev/fork branch | `https://indierawk2k2.github.io/openclaw-windows-node/openclaw-x64.appinstaller` | Fast for testing; no main-repo Pages decision required | Not durable; tied to an individual fork/account; wrong trust boundary for production installs | Manual pre-merge update testing | **Testing only** | +| Direct GitHub Release asset URL | `https://github.com/openclaw/openclaw-windows-node/releases/download/vX.Y.Z/...` | Releases already contain signed artifacts; immutable tag URLs are good for package payloads | Not a stable feed URL by itself; "latest" redirects and release asset URLs are not ideal as Windows' stored `.appinstaller` source; harder to guarantee AppInstaller-friendly headers on redirects | Payload downloads referenced by a hosted `.appinstaller` | **Use for MSIX payloads, not as the stable feed** | +| `aka.ms` short link | `https://aka.ms/openclaw-msix-x64` | Very stable Microsoft-operated short URL; can redirect to any backend | This is not an official Microsoft project; ownership/approval may be inappropriate or unavailable; redirect adds another operational dependency | Official Microsoft-owned projects or Microsoft-sponsored distribution | **Do not use as canonical for this project** | +| Third-party object storage/static hosting with custom domain | `https://updates.openclaw.dev/windows/msix/openclaw-x64.appinstaller` backed by S3, Cloudflare R2, Netlify, etc. | Can be cheap and durable; custom domain keeps backend replaceable; many providers support correct headers | Provider-specific header/range behavior must be validated; maintainers need provider access and deployment secrets | Maintainers prefer non-Azure hosting | **Viable if header validation passes** | +| Self-hosted server | `https://updates.openclaw.dev/windows/msix/openclaw-x64.appinstaller` | Full control over headers, logs, and rollout behavior | Highest maintenance burden; uptime/TLS/security patching become project responsibilities | Projects with existing reliable infra | **Avoid unless existing infra already exists** | + +## Assumptions + +- OpenClaw remains distributed outside the Microsoft Store for this path. +- AppInstaller update metadata stays architecture-specific for now: `openclaw-x64.appinstaller` and `openclaw-arm64.appinstaller`. +- The stable `.appinstaller` URL is a long-lived contract stored by Windows for installed clients. +- MSIX package payloads may still live on GitHub Releases as long as the hosted `.appinstaller` points to versioned, signed assets and header validation passes. +- The update feed host must serve `.appinstaller` as `application/appinstaller`, MSIX payloads as `application/msix`, provide `Content-Length`, and support range requests for MSIX payloads. From cd11bbf506d3bbf25fbd5d91e7a201d8c36eff95 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Thu, 21 May 2026 09:02:46 -0700 Subject: [PATCH 24/56] fix(msix): address review-blocking update and purge risks Guard destructive orphan cleanup while the companion package is installed or the tray is running, narrow WSL cleanup to known app-owned distro names, make manual update checks metadata-only, treat missing deployment HRESULTs as failures, and make packaged autostart changes awaitable with result handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/MSIX_E2E_TEST_RUNBOOK.md | 3 +- docs/uninstall-msix.md | 11 +- src/OpenClaw.Tray.WinUI/App.xaml.cs | 26 ++-- .../Onboarding/OnboardingWindow.cs | 20 ++- .../Pages/SettingsPage.xaml.cs | 21 ++- .../Services/AppInstallerUpdateService.cs | 69 ++++++++- .../Services/AutoStartManager.cs | 19 ++- src/OpenClaw.WinNode.Cli/OrphanPurger.cs | 133 ++++++++++++++++-- src/OpenClaw.WinNode.Cli/Program.cs | 4 +- .../AppInstallerTemplateAssertionTests.cs | 33 ++++- .../MsixManifestAssertionTests.cs | 19 +++ .../OrphanPurgerContractTests.cs | 39 +++-- 12 files changed, 348 insertions(+), 49 deletions(-) diff --git a/docs/MSIX_E2E_TEST_RUNBOOK.md b/docs/MSIX_E2E_TEST_RUNBOOK.md index 424638c87..580437e1a 100644 --- a/docs/MSIX_E2E_TEST_RUNBOOK.md +++ b/docs/MSIX_E2E_TEST_RUNBOOK.md @@ -116,7 +116,8 @@ multi-launch behaviour, real WSL distros, and the dirty-uninstall recovery. 5. **Assert** the following are absent: `%APPDATA%\OpenClawTray\`, `%LOCALAPPDATA%\OpenClawTray\`, `HKCU:\Software\Classes\openclaw`, `HKCU:\Software\Microsoft\Windows\CurrentVersion\Run\OpenClawTray`, - `openclaw-*` WSL distros. + and known app-owned WSL distros such as `OpenClawGateway` or + `openclaw-local`. ### 7. Dirty uninstall + recovery (proves `--purge-wsl-orphans`) diff --git a/docs/uninstall-msix.md b/docs/uninstall-msix.md index 5e62d8229..783fddb81 100644 --- a/docs/uninstall-msix.md +++ b/docs/uninstall-msix.md @@ -60,19 +60,26 @@ The CLI detects and removes: | Kind | Where | |-----------------------|--------------------------------------------------------------------------------------| -| `wsl-distro` | Any WSL distro whose name starts with `openclaw-` | +| `wsl-distro` | Known app-owned WSL distro names such as `OpenClawGateway` and `openclaw-local` | | `appdata-folder` | `%APPDATA%\OpenClawTray\` | | `localappdata-folder` | `%LOCALAPPDATA%\OpenClawTray\` | | `registry-uri-scheme` | `HKCU\Software\Classes\openclaw` (legacy unpackaged URI scheme) | | `registry-run-key` | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\OpenClawTray` (legacy autostart) | +When `--confirm-destructive` is used, the CLI refuses to delete anything if it +can still see the OpenClaw Companion MSIX registered for the current user or the +tray mutex is present. Use the in-app cleanup first. The +`--force-even-if-installed` override exists only for support cases where you +have independently verified the installed app is gone but Windows' package +registration check is stale or unavailable. + If the CLI is not available (e.g., the package was uninstalled before this fallback was published), the equivalent PowerShell one-liners are: ```powershell # 1. Unregister the WSL distro(s) wsl --list --quiet | - Where-Object { $_ -match '^openclaw-' } | + Where-Object { $_ -in @('OpenClawGateway', 'openclaw-local', 'openclaw-staging') } | ForEach-Object { wsl --unregister $_ } # 2. Remove autostart registry entry (legacy) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index c38d07d43..d81df9a0d 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -2824,8 +2824,6 @@ private void OnSettingsSaved(object? sender, EventArgs e) _globalHotkey?.Unregister(); } - AutoStartManager.SetAutoStart(_settings.AutoStart); - // Notify ad-hoc listeners (e.g. ChatWindow may be alive but not // owned by the hub) that settings have changed. Marshal onto the // UI thread because IAppCommands.NotifySettingsSaved is a public @@ -3264,12 +3262,18 @@ private async void ToggleChannel(string channelName) } } - private void ToggleAutoStart() + private async void ToggleAutoStart() { if (_settings == null) return; _settings.AutoStart = !_settings.AutoStart; _settings.Save(); - AutoStartManager.SetAutoStart(_settings.AutoStart); + var requestedAutoStart = _settings.AutoStart; + var autoStartApplied = await AutoStartManager.SetAutoStartAsync(requestedAutoStart); + if (!autoStartApplied) + { + _settings.AutoStart = !requestedAutoStart; + _settings.Save(); + } } private void OpenLogFile() @@ -3434,9 +3438,8 @@ private async Task CheckForUpdatesUserInitiatedAsync() { Logger.Info("Manual update check requested"); - // Packaged: ask Windows AppInstaller to check the stable architecture URL. - // The default path avoids force-closing the tray; Windows can finish - // package registration when OpenClaw exits or the user explicitly restarts. + // Packaged: read the stable architecture feed and compare versions. + // "Check for updates" must not stage/register packages on every click. if (OpenClawTray.Helpers.PackageHelper.IsPackaged) { _appState!.UpdateInfo = new UpdateCommandCenterInfo @@ -3448,9 +3451,16 @@ private async Task CheckForUpdatesUserInitiatedAsync() }; UpdateStatusDetailWindow(); - var outcome = await AppInstallerUpdateService.TryApplyUpdateAsync(); + var outcome = await AppInstallerUpdateService.CheckForUpdateAsync(); _appState!.UpdateInfo = outcome.Outcome switch { + AppInstallerUpdateService.UpdateOutcome.UpdateAvailable => new UpdateCommandCenterInfo + { + Status = "Available", + CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CheckedAt = DateTime.UtcNow, + Detail = outcome.DetailMessage ?? "update available" + }, AppInstallerUpdateService.UpdateOutcome.UpdateQueued => new UpdateCommandCenterInfo { Status = "Ready", diff --git a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs index 2cde28f61..41cd31b6a 100644 --- a/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs +++ b/src/OpenClaw.Tray.WinUI/Onboarding/OnboardingWindow.cs @@ -43,7 +43,7 @@ public sealed class OnboardingWindow : WindowEx private EventHandler? _v2StateCaptureHandler; // Single-fire guard so the X button (Closed) and the Finish button don't both // dispatch completion. Both paths - // route through TryCompleteOnboarding which no-ops after the first call. + // route through TryCompleteOnboardingAsync which no-ops after the first call. private bool _completionDispatched; private bool _incompleteSetupDialogOpen; @@ -235,9 +235,9 @@ private void CreateAndStartV2Bridge(SettingsManager settings) freshLocalGatewayUninstall: RunFreshLocalGatewayUninstallAsync); _v2Bridge.PrimarySetupRequested += (_, _) => _ = ConfirmAndStartV2SetupAsync(); _v2Bridge.AdvancedSetupRequested += (_, _) => OpenConnectionsFromAdvancedSetup(); - _v2Bridge.Finished += (_, _) => + _v2Bridge.Finished += async (_, _) => { - if (TryCompleteOnboarding()) + if (await TryCompleteOnboardingAsync()) { Close(); } @@ -313,7 +313,7 @@ public async Task CaptureCurrentPageAsync() /// private bool _dismissedWithoutCompletion; - private void OnClosed(object sender, WindowEventArgs args) + private async void OnClosed(object sender, WindowEventArgs args) { // X button path: also runs TryCompleteOnboarding (idempotent via _completionDispatched) // so a user who clicks the title-bar X on the Ready page still gets the chat-window @@ -324,7 +324,7 @@ private void OnClosed(object sender, WindowEventArgs args) // must NOT touch settings/AutoStart so the prior gateway connection is preserved. if (!_dismissedWithoutCompletion) { - _ = TryCompleteOnboarding(); + await TryCompleteOnboardingAsync(); } try { _v2Bridge?.Dispose(); } catch { /* ignore */ } @@ -521,7 +521,7 @@ private void OpenConnectionsFromAdvancedSetup() /// gateway wizard can stop on a later channel step even after credentials/model /// setup succeeded, but Finish on All Set still runs this handler. /// - private bool TryCompleteOnboarding() + private async Task TryCompleteOnboardingAsync() { if (_completionDispatched) return true; var finishedFromTerminalPage = _v2State?.CurrentRoute == OpenClawTray.Onboarding.V2.V2Route.AllSet; @@ -547,7 +547,13 @@ private bool TryCompleteOnboarding() // it should still get the default (true) registered. Idempotent. try { - AutoStartManager.SetAutoStart(_settings.AutoStart); + var requestedAutoStart = _settings.AutoStart; + var autoStartApplied = await AutoStartManager.SetAutoStartAsync(requestedAutoStart); + if (!autoStartApplied) + { + _settings.AutoStart = !requestedAutoStart; + _settings.Save(); + } } catch (Exception ex) { diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs index 5031aff09..652fc785a 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs @@ -70,7 +70,7 @@ private void OnUnloaded(object sender, RoutedEventArgs e) private void WireAutoSaveHandlers() { - AutoStartToggle.Toggled += (_, _) => PersistAutoStart(); + AutoStartToggle.Toggled += async (_, _) => await PersistAutoStartAsync(); GlobalHotkeyToggle.Toggled += (_, _) => Persist(s => s.GlobalHotkeyEnabled = GlobalHotkeyToggle.IsOn); UseLegacyWebChatToggle.Toggled += (_, _) => Persist(s => s.UseLegacyWebChat = UseLegacyWebChatToggle.IsOn); NotificationsToggle.Toggled += (_, _) => Persist(s => s.ShowNotifications = NotificationsToggle.IsOn); @@ -117,7 +117,7 @@ private void Persist(Action mutate) } } - private void PersistAutoStart() + private async System.Threading.Tasks.Task PersistAutoStartAsync() { if (_loading || CurrentApp.Settings == null) return; _saving = true; @@ -125,7 +125,22 @@ private void PersistAutoStart() { CurrentApp.Settings.AutoStart = AutoStartToggle.IsOn; CurrentApp.Settings.Save(); - AutoStartManager.SetAutoStart(CurrentApp.Settings.AutoStart); + var requestedAutoStart = CurrentApp.Settings.AutoStart; + var autoStartApplied = await AutoStartManager.SetAutoStartAsync(requestedAutoStart); + if (!autoStartApplied) + { + CurrentApp.Settings.AutoStart = !requestedAutoStart; + _loading = true; + try + { + AutoStartToggle.IsOn = CurrentApp.Settings.AutoStart; + } + finally + { + _loading = false; + } + CurrentApp.Settings.Save(); + } ((IAppCommands)CurrentApp).NotifySettingsSaved(); ShowSavedIndicator(); } diff --git a/src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs b/src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs index 0b6b7c82c..a614ba53c 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs @@ -1,7 +1,9 @@ using System; using System.IO; +using System.Net.Http; using System.Runtime.InteropServices; using System.Threading.Tasks; +using System.Xml.Linq; using OpenClawTray.Helpers; namespace OpenClawTray.Services; @@ -21,6 +23,8 @@ namespace OpenClawTray.Services; /// internal static class AppInstallerUpdateService { + private static readonly HttpClient SharedHttpClient = new(); + /// /// Stable x64 URL of the AppInstaller XML on GitHub Pages. /// @@ -44,6 +48,8 @@ internal static class AppInstallerUpdateService /// public enum UpdateOutcome { + /// A newer version is advertised by the AppInstaller feed. + UpdateAvailable, /// Windows accepted the update request; registration may complete after restart. UpdateQueued, /// An update is available but Windows needs OpenClaw to exit before registration can finish. @@ -61,6 +67,37 @@ public record UpdateResult(UpdateOutcome Outcome, string? DetailMessage); internal const int HResultPackagesInUse = unchecked((int)0x80073D02); internal const int HResultPackageAlreadyExists = unchecked((int)0x80073CFB); + /// + /// Reads the hosted AppInstaller XML and compares its version with the + /// installed package version without staging or registering any package. + /// + public static async Task CheckForUpdateAsync( + string? appInstallerUri = null, + HttpClient? httpClient = null) + { + if (!PackageHelper.IsPackaged) + { + return new UpdateResult(UpdateOutcome.NotPackaged, + "AppInstallerUpdateService called from an unpackaged process. " + + "branch on PackageHelper.IsPackaged before invoking this service."); + } + + var uri = new Uri(appInstallerUri ?? LatestAppInstallerUri, UriKind.Absolute); + + try + { + var client = httpClient ?? SharedHttpClient; + var xml = await client.GetStringAsync(uri); + var publishedVersion = ParseAppInstallerVersion(xml); + var currentVersion = GetCurrentPackageVersion(); + return ClassifyPublishedVersion(currentVersion, publishedVersion); + } + catch (Exception ex) + { + return new UpdateResult(UpdateOutcome.Failed, ex.Message); + } + } + /// /// Asks Windows to fetch the MSIX advertised at the AppInstaller URL. /// By default this does not force-close the tray; callers that expose an @@ -125,13 +162,43 @@ internal static UpdateResult ClassifyDeploymentResult( { HResultPackagesInUse => new UpdateResult(UpdateOutcome.UpdatePendingRestart, "An update is available, but OpenClaw is running. Close and reopen OpenClaw to finish installing it."), - 0 or HResultPackageAlreadyExists => new UpdateResult(UpdateOutcome.NoUpdateAvailable, + HResultPackageAlreadyExists => new UpdateResult(UpdateOutcome.NoUpdateAvailable, "Already on the latest version published at the AppInstaller URL."), + 0 => new UpdateResult(UpdateOutcome.Failed, + $"PackageManager did not register the package and did not report an HRESULT: {errorText ?? "no error text"}"), _ => new UpdateResult(UpdateOutcome.Failed, $"PackageManager reported HRESULT 0x{unchecked((uint)hResult):X8}: {errorText}") }; } + internal static UpdateResult ClassifyPublishedVersion(Version currentVersion, Version publishedVersion) + { + if (publishedVersion.CompareTo(currentVersion) > 0) + { + return new UpdateResult(UpdateOutcome.UpdateAvailable, + $"Version {publishedVersion} is available. Windows AppInstaller will install it in the background when possible."); + } + + return new UpdateResult(UpdateOutcome.NoUpdateAvailable, + $"Already on version {currentVersion}; latest published version is {publishedVersion}."); + } + + internal static Version ParseAppInstallerVersion(string appInstallerXml) + { + var doc = XDocument.Parse(appInstallerXml); + var versionText = (string?)doc.Root?.Attribute("Version"); + if (!Version.TryParse(versionText, out var version) || version.Revision < 0) + throw new FormatException("AppInstaller Version must be a four-part version."); + + return version; + } + + private static Version GetCurrentPackageVersion() + { + var version = global::Windows.ApplicationModel.Package.Current.Id.Version; + return new Version(version.Major, version.Minor, version.Build, version.Revision); + } + private static global::Windows.Management.Deployment.PackageVolume ResolveCurrentPackageVolume( global::Windows.Management.Deployment.PackageManager manager) { diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs index 32e9eb517..2a68c04d3 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs @@ -60,12 +60,11 @@ public static bool IsAutoStartEnabled() } } - public static void SetAutoStart(bool enable) + public static async Task SetAutoStartAsync(bool enable) { if (PackageHelper.IsPackaged) { - _ = SetAutoStartPackagedAsync(enable); - return; + return await SetAutoStartPackagedAsync(enable); } try @@ -74,7 +73,7 @@ public static void SetAutoStart(bool enable) if (key == null) { Logger.Warn($"Auto-start registry key unavailable: HKCU\\{RegistryKey}"); - return; + return false; } if (enable) @@ -82,20 +81,23 @@ public static void SetAutoStart(bool enable) var exePath = Environment.ProcessPath ?? System.Reflection.Assembly.GetExecutingAssembly().Location; key.SetValue(AppName, $"\"{exePath}\""); Logger.Info("Auto-start enabled (unpackaged, HKCU\\...\\Run)"); + return true; } else { key.DeleteValue(AppName, false); Logger.Info("Auto-start disabled (unpackaged, HKCU\\...\\Run)"); + return true; } } catch (Exception ex) { Logger.Error($"Failed to set auto-start (unpackaged): {ex.Message}"); + return false; } } - private static async Task SetAutoStartPackagedAsync(bool enable) + private static async Task SetAutoStartPackagedAsync(bool enable) { try { @@ -108,16 +110,23 @@ private static async Task SetAutoStartPackagedAsync(bool enable) // read-only until they re-enable it there. var state = await task.RequestEnableAsync(); Logger.Info($"StartupTask enable requested → state={state}"); + var enabled = state == global::Windows.ApplicationModel.StartupTaskState.Enabled + || state == global::Windows.ApplicationModel.StartupTaskState.EnabledByPolicy; + if (!enabled) + Logger.Warn($"StartupTask enable did not take effect; state={state}"); + return enabled; } else { task.Disable(); Logger.Info("StartupTask disabled"); + return true; } } catch (Exception ex) { Logger.Error($"Failed to set auto-start (packaged): {ex.Message}"); + return false; } } } diff --git a/src/OpenClaw.WinNode.Cli/OrphanPurger.cs b/src/OpenClaw.WinNode.Cli/OrphanPurger.cs index 3339bdbe8..48e25f60a 100644 --- a/src/OpenClaw.WinNode.Cli/OrphanPurger.cs +++ b/src/OpenClaw.WinNode.Cli/OrphanPurger.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.IO; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.Win32; @@ -41,16 +42,23 @@ internal static class OrphanPurger /// name used by the WSL gateway installer (still in production as of /// 2026-05; observed on Mike's dev box during the MSIX-E2E manual test /// prep). - /// openclaw-* — the newer kebab-case + /// Known kebab-case names — the newer /// convention adopted for variants like openclaw-local, /// openclaw-staging. /// /// Match is case-insensitive because wsl --list --quiet echoes /// the user-specified case verbatim and we cannot rely on either form. - /// Matching is intentionally anchored/exact so a user-created distro named - /// my-openclaw-experiments is never destroyed by the cleanup tool. + /// Matching is intentionally exact so user-created distros named + /// my-openclaw-experiments or openclaw-personal are never + /// destroyed by the cleanup tool. /// internal const string LegacyOpenClawGatewayDistroName = "OpenClawGateway"; + internal static readonly string[] OpenClawOwnedWslDistroNames = new[] + { + LegacyOpenClawGatewayDistroName, + "openclaw-local", + "openclaw-staging", + }; /// /// Retained for backward compatibility with OrphanPurgerContractTests @@ -59,6 +67,13 @@ internal static class OrphanPurger /// . /// internal const string OrphanWslDistroPrefix = "openclaw-"; + internal const string ForceEvenIfInstalledFlag = "--force-even-if-installed"; + internal const string TrayMutexName = "OpenClawTray"; + internal static readonly string[] CompanionPackageNames = new[] + { + "OpenClaw.Companion", + "OpenClaw.Companion.Alpha", + }; /// /// Registry subkeys under HKCU\Software\Classes we treat as @@ -79,14 +94,16 @@ public record PurgeReport( IReadOnlyList Orphans, IReadOnlyList Removed, IReadOnlyList Failed, - bool ConfirmDestructive); + bool ConfirmDestructive, + string? BlockedReason = null); public static async Task RunAsync( bool confirmDestructive, bool jsonOutput, TextWriter stdout, TextWriter stderr, - Func? envLookup = null) + Func? envLookup = null, + bool forceEvenIfInstalled = false) { envLookup ??= Environment.GetEnvironmentVariable; @@ -97,8 +114,15 @@ public static async Task RunAsync( var removed = new List(); var failed = new List(); + string? blockedReason = null; - if (confirmDestructive) + if (confirmDestructive && orphans.Count > 0 && !forceEvenIfInstalled && + TryGetLiveInstallBlockReason(stderr, out blockedReason)) + { + stderr.WriteLine($"[purge] Refusing destructive cleanup: {blockedReason}"); + stderr.WriteLine($"[purge] Run Reset & remove from the installed app first, or pass {ForceEvenIfInstalledFlag} if you already verified the install is gone."); + } + else if (confirmDestructive) { foreach (var orphan in orphans) { @@ -114,7 +138,7 @@ public static async Task RunAsync( } } - var report = new PurgeReport(orphans, removed, failed, confirmDestructive); + var report = new PurgeReport(orphans, removed, failed, confirmDestructive, blockedReason); if (jsonOutput) { stdout.WriteLine(JsonSerializer.Serialize(report, @@ -125,6 +149,7 @@ public static async Task RunAsync( WriteHumanReport(report, stdout); } + if (blockedReason is not null) return 2; if (failed.Count > 0) return 2; if (!confirmDestructive && orphans.Count > 0) return 1; return 0; @@ -188,8 +213,98 @@ private static IEnumerable DetectWslDistros(TextWriter stderr) } internal static bool IsOpenClawOwnedWslDistroName(string distroName) => - distroName.Equals(LegacyOpenClawGatewayDistroName, StringComparison.OrdinalIgnoreCase) || - distroName.StartsWith(OrphanWslDistroPrefix, StringComparison.OrdinalIgnoreCase); + OpenClawOwnedWslDistroNames.Any(owned => + distroName.Equals(owned, StringComparison.OrdinalIgnoreCase)); + + private static bool TryGetLiveInstallBlockReason(TextWriter stderr, out string reason) + { + if (IsTrayMutexPresent()) + { + reason = "OpenClaw tray is still running"; + return true; + } + + var packageState = TryGetCompanionPackageInstalledForCurrentUser(stderr); + if (packageState == true) + { + reason = "OpenClaw Companion MSIX is still installed for the current user"; + return true; + } + + if (packageState is null) + { + reason = "could not verify that the OpenClaw Companion MSIX is removed"; + return true; + } + + reason = string.Empty; + return false; + } + + private static bool IsTrayMutexPresent() + { + try + { + if (!Mutex.TryOpenExisting(TrayMutexName, out var mutex)) + return false; + + mutex.Dispose(); + return true; + } + catch + { + return false; + } + } + + private static bool? TryGetCompanionPackageInstalledForCurrentUser(TextWriter stderr) + { + try + { + var packageNameList = string.Join(",", + CompanionPackageNames.Select(name => $"'{name.Replace("'", "''")}'")); + var script = $"$names=@({packageNameList}); " + + "$pkg=@(); foreach ($name in $names) { " + + "$pkg += @(Get-AppxPackage -Name $name -ErrorAction SilentlyContinue) }; " + + "if ($pkg.Count -gt 0) { exit 0 }; exit 1"; + var psi = new ProcessStartInfo("powershell.exe") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add("-NoProfile"); + psi.ArgumentList.Add("-NonInteractive"); + psi.ArgumentList.Add("-ExecutionPolicy"); + psi.ArgumentList.Add("Bypass"); + psi.ArgumentList.Add("-Command"); + psi.ArgumentList.Add(script); + + using var proc = Process.Start(psi); + if (proc is null) + return null; + + if (!proc.WaitForExit(10_000)) + { + try { proc.Kill(entireProcessTree: true); } catch { /* best effort */ } + stderr.WriteLine("[purge] timed out verifying MSIX package registration"); + return null; + } + + return proc.ExitCode switch + { + 0 => true, + 1 => false, + _ => null + }; + } + catch (Exception ex) + { + stderr.WriteLine($"[purge] failed to verify MSIX package registration ({ex.Message})"); + return null; + } + } private static IEnumerable DetectFileOrphans(Func envLookup) { diff --git a/src/OpenClaw.WinNode.Cli/Program.cs b/src/OpenClaw.WinNode.Cli/Program.cs index fd088a8e8..adc0da2e0 100644 --- a/src/OpenClaw.WinNode.Cli/Program.cs +++ b/src/OpenClaw.WinNode.Cli/Program.cs @@ -65,7 +65,8 @@ public static async Task RunAsync( { var confirm = args.Contains("--confirm-destructive"); var json = args.Contains("--json-output"); - return await OrphanPurger.RunAsync(confirm, json, stdout, stderr, envLookup); + var forceEvenIfInstalled = args.Contains(OrphanPurger.ForceEvenIfInstalledFlag); + return await OrphanPurger.RunAsync(confirm, json, stdout, stderr, envLookup, forceEvenIfInstalled); } WinNodeOptions options; @@ -818,6 +819,7 @@ internal static void PrintUsage(TextWriter stdout) stdout.WriteLine(" skipped the in-app Reset & remove. Dry-run by default;"); stdout.WriteLine(" pass --confirm-destructive to actually delete."); stdout.WriteLine(" --confirm-destructive Apply the deletions (otherwise dry-run; exit 1 if dirty)"); + stdout.WriteLine(" --force-even-if-installed Override the installed/running safety guard"); stdout.WriteLine(" --json-output Emit the orphan/removed/failed report as JSON"); stdout.WriteLine(); stdout.WriteLine("Examples:"); diff --git a/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs b/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs index af3388c9e..6d7f55942 100644 --- a/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs @@ -127,7 +127,8 @@ public void InAppService_DoesNotForceShutdownByDefault() Assert.Contains("bool forceRestart = false", service); Assert.Contains("AddPackageByAppInstallerOptions.None", service); - Assert.Contains("TryApplyUpdateAsync()", app); + Assert.Contains("CheckForUpdateAsync()", app); + Assert.DoesNotContain("TryApplyUpdateAsync()", app); Assert.DoesNotContain("TryApplyUpdateAsync(forceRestart: true", app); } @@ -147,6 +148,36 @@ public void InAppService_DoesNotReportPackagesInUseAsNoUpdateAvailable() Assert.DoesNotContain("0x80073D02 => new UpdateResult(UpdateOutcome.NoUpdateAvailable", service); } + [Fact] + public void InAppService_DoesNotReportMissingDeploymentHResultAsCurrent() + { + var service = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Services", "AppInstallerUpdateService.cs")); + + Assert.Contains("HResultPackageAlreadyExists => new UpdateResult(UpdateOutcome.NoUpdateAvailable", service); + Assert.Contains("0 => new UpdateResult(UpdateOutcome.Failed", service); + Assert.DoesNotContain("0 or HResultPackageAlreadyExists", service); + } + + [Fact] + public void ManualUpdateCheck_IsMetadataOnly() + { + var service = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Services", "AppInstallerUpdateService.cs")); + var app = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + + Assert.Contains("CheckForUpdateAsync", service); + Assert.Contains("ParseAppInstallerVersion", service); + Assert.Contains("ClassifyPublishedVersion", service); + Assert.Contains("UpdateAvailable", service); + Assert.Contains("AppInstallerUpdateService.CheckForUpdateAsync()", app); + Assert.DoesNotContain("var outcome = await AppInstallerUpdateService.TryApplyUpdateAsync()", app); + } + [Fact] public void InAppService_UsesCurrentPackageVolumeBeforeDefaultFallback() { diff --git a/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs b/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs index 8b10c73f5..63f70d7c1 100644 --- a/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs @@ -116,6 +116,25 @@ public void Tray_DeclaresStartupTaskExtensionMatchingAutoStartManager() Assert.NotNull(startupTask); Assert.Equal("OpenClawCompanionStartup", (string?)startupTask!.Attribute("TaskId")); Assert.Equal("false", (string?)startupTask.Attribute("Enabled")); + + var autoStartManager = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Services", "AutoStartManager.cs")); + var app = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + var settingsPage = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Pages", "SettingsPage.xaml.cs")); + var onboarding = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Onboarding", "OnboardingWindow.cs")); + + Assert.Contains("Task SetAutoStartAsync", autoStartManager); + Assert.DoesNotContain("_ = SetAutoStartPackagedAsync", autoStartManager); + Assert.DoesNotContain("public static void SetAutoStart", autoStartManager); + Assert.Contains("await AutoStartManager.SetAutoStartAsync", app); + Assert.Contains("await AutoStartManager.SetAutoStartAsync", settingsPage); + Assert.Contains("await AutoStartManager.SetAutoStartAsync", onboarding); + Assert.Contains("var autoStartApplied = await AutoStartManager.SetAutoStartAsync", app); + Assert.Contains("var autoStartApplied = await AutoStartManager.SetAutoStartAsync", settingsPage); + Assert.Contains("var autoStartApplied = await AutoStartManager.SetAutoStartAsync", onboarding); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs b/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs index ac1d5cc94..168dd0264 100644 --- a/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs @@ -45,31 +45,48 @@ private static string LoadOrphanPurgerSource() => [Fact] public void OrphanWslDistroPrefix_IsTheOpenclawPrefix() { - // The local-gateway flow names every distro it installs with an - // openclaw- prefix. Drift here means a real orphan goes undetected - // (we silently miss what we promised to clean) — pin it. Retained - // for backward compat after the 2026-05 case-insensitive fix added - // exact legacy-name matching for OpenClawGateway. + // Retained for backward compat with support/docs that mention the + // historical openclaw- prefix, even though destructive matching now + // uses an exact allow-list. Assert.Contains("OrphanWslDistroPrefix = \"openclaw-\"", LoadOrphanPurgerSource()); } [Fact] - public void WslDistroDetection_IsCaseInsensitive_Anchored_AndCatchesLegacyOpenClawGateway() + public void WslDistroDetection_IsCaseInsensitive_Exact_AndCatchesKnownOwnedDistros() { // Regression: during MSIX-E2E manual test prep we found Mike's box // had an OpenClawGateway (PascalCase, no dash) distro installed by // the historical local-gateway flow. The original "openclaw-" // case-sensitive prefix would silently miss it, meaning a user who // ran --purge-wsl-orphans would be told "no orphans" while a 2.6 GB - // .vhdx orphan was still on disk. Pin the case-insensitive exact legacy - // name + anchored prefix strategy so future refactors cannot drift back - // to destructive substring matching. + // .vhdx orphan was still on disk. Pin the case-insensitive exact-name + // strategy so future refactors cannot drift back to destructive + // substring or prefix matching. var src = LoadOrphanPurgerSource(); + Assert.Contains("OpenClawOwnedWslDistroNames", src); Assert.Contains("LegacyOpenClawGatewayDistroName = \"OpenClawGateway\"", src); - Assert.Contains("StartsWith(OrphanWslDistroPrefix, StringComparison.OrdinalIgnoreCase)", src); - Assert.Contains("Equals(LegacyOpenClawGatewayDistroName, StringComparison.OrdinalIgnoreCase)", src); + Assert.Contains("\"openclaw-local\"", src); + Assert.Contains("\"openclaw-staging\"", src); + Assert.Contains("distroName.Equals(owned, StringComparison.OrdinalIgnoreCase)", src); Assert.Contains("StringComparison.OrdinalIgnoreCase", src); Assert.DoesNotContain("line.Contains(pattern", src); + Assert.DoesNotContain("StartsWith(OrphanWslDistroPrefix", src); + } + + [Fact] + public void DestructivePurge_IsBlockedWhenCompanionIsInstalledOrRunning() + { + var src = LoadOrphanPurgerSource(); + var program = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), "src", "OpenClaw.WinNode.Cli", "Program.cs")); + + Assert.Contains("TryGetLiveInstallBlockReason", src); + Assert.Contains("IsTrayMutexPresent", src); + Assert.Contains("TryGetCompanionPackageInstalledForCurrentUser", src); + Assert.Contains("CompanionPackageNames", src); + Assert.Contains("OpenClaw.Companion.Alpha", src); + Assert.Contains("ForceEvenIfInstalledFlag = \"--force-even-if-installed\"", src); + Assert.Contains("forceEvenIfInstalled", program); } [Fact] From 2f3ca95b03a44d2e343e25a5c025eace5e7a73e5 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Fri, 22 May 2026 11:34:09 -0700 Subject: [PATCH 25/56] feat(msix): automate stable appinstaller feed PRs Switch the candidate stable AppInstaller feed to raw GitHub files under installer/appinstaller, add the release follow-up workflow that opens a maintainer-reviewed feed update PR, and teach the renderer and validation scripts about package identity and GitHub-hosted candidate assets. Also updates docs and source-contract tests for the raw GitHub feed path, pre-release feed blocking, and metadata-only MainPackage version parsing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/appinstaller-feed-pr.yml | 161 ++++++++++++++++++ .github/workflows/ci.yml | 38 ++++- DEVELOPMENT.md | 12 +- docs/MSIX_E2E_TEST_RUNBOOK.md | 15 +- docs/MSIX_UPDATE_ENDPOINT_OPTIONS.md | 8 +- docs/RELEASING.md | 31 ++-- docs/SETUP.md | 6 +- installer/appinstaller/README.md | 23 +++ .../openclaw-companion.appinstaller.template | 5 +- scripts/render-appinstaller.ps1 | 23 ++- scripts/test-appinstaller-update.ps1 | 2 +- scripts/validate-appinstaller-hosting.ps1 | 119 ++++++++++--- .../Services/AppInstallerUpdateService.cs | 15 +- .../AppInstallerTemplateAssertionTests.cs | 42 ++++- 14 files changed, 413 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/appinstaller-feed-pr.yml create mode 100644 installer/appinstaller/README.md diff --git a/.github/workflows/appinstaller-feed-pr.yml b/.github/workflows/appinstaller-feed-pr.yml new file mode 100644 index 000000000..e169b457e --- /dev/null +++ b/.github/workflows/appinstaller-feed-pr.yml @@ -0,0 +1,161 @@ +name: AppInstaller Feed PR + +on: + workflow_dispatch: + inputs: + tag: + description: Release tag whose signed MSIX assets should advance the stable AppInstaller feed + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + update-feed: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v6 + with: + ref: master + fetch-depth: 0 + + - name: Render stable AppInstaller feed files + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + + $repo = '${{ github.repository }}' + $tag = '${{ inputs.tag }}'.Trim() + if ([string]::IsNullOrWhiteSpace($tag)) { + throw "tag input is required." + } + if (-not $tag.StartsWith('v', [StringComparison]::OrdinalIgnoreCase)) { + throw "Release tag must start with 'v'. Got '$tag'." + } + if ($tag.Contains('-')) { + throw "Pre-release AppInstaller feed updates are blocked until alpha channel policy is decided. Tag: $tag" + } + + $release = gh release view $tag --repo $repo --json tagName,isPrerelease,assets | ConvertFrom-Json + if ($release.isPrerelease) { + throw "Pre-release AppInstaller feed updates are blocked until alpha channel policy is decided. Tag: $tag" + } + + $versionText = $tag.Substring(1) + if ($versionText -notmatch '^\d+\.\d+\.\d+$') { + throw "Stable release tag must be vX.Y.Z so the MSIX/AppInstaller version can be rendered as X.Y.Z.0. Got '$tag'." + } + $version = "$versionText.0" + $publisher = 'CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US' + $identityName = 'OpenClaw.Companion' + $feedDir = 'installer\appinstaller' + New-Item -ItemType Directory -Force -Path $feedDir | Out-Null + + function Get-RequiredAsset { + param([Parameter(Mandatory)] [string] $Pattern) + $matches = @($release.assets | Where-Object { $_.name -like $Pattern }) + if ($matches.Count -ne 1) { + $available = ($release.assets | ForEach-Object { $_.name }) -join ', ' + throw "Expected exactly one release asset matching '$Pattern' for $tag; found $($matches.Count). Assets: $available" + } + return $matches[0] + } + + function Get-ReleaseAssetUri { + param([Parameter(Mandatory)] [string] $AssetName) + $escapedName = [Uri]::EscapeDataString($AssetName) + return "https://github.com/$repo/releases/download/$tag/$escapedName" + } + + $x64Asset = Get-RequiredAsset -Pattern 'OpenClawCompanion-*-win-x64.msix' + $arm64Asset = Get-RequiredAsset -Pattern 'OpenClawCompanion-*-win-arm64.msix' + $x64Uri = Get-ReleaseAssetUri -AssetName $x64Asset.name + $arm64Uri = Get-ReleaseAssetUri -AssetName $arm64Asset.name + + $rawBase = "https://raw.githubusercontent.com/$repo/master/installer/appinstaller" + $x64FeedPath = Join-Path $feedDir 'openclaw-x64.appinstaller' + $arm64FeedPath = Join-Path $feedDir 'openclaw-arm64.appinstaller' + + .\scripts\render-appinstaller.ps1 ` + -Version $version ` + -Publisher $publisher ` + -IdentityName $identityName ` + -ProcessorArchitecture x64 ` + -MsixUri $x64Uri ` + -AppInstallerUri "$rawBase/openclaw-x64.appinstaller" ` + -OutputPath $x64FeedPath + + .\scripts\render-appinstaller.ps1 ` + -Version $version ` + -Publisher $publisher ` + -IdentityName $identityName ` + -ProcessorArchitecture arm64 ` + -MsixUri $arm64Uri ` + -AppInstallerUri "$rawBase/openclaw-arm64.appinstaller" ` + -OutputPath $arm64FeedPath + + .\scripts\validate-appinstaller-hosting.ps1 ` + -AppInstallerPath $x64FeedPath ` + -MsixUri $x64Uri ` + -AllowGitHubContentTypes + .\scripts\validate-appinstaller-hosting.ps1 ` + -AppInstallerPath $arm64FeedPath ` + -MsixUri $arm64Uri ` + -AllowGitHubContentTypes + + $branch = "automation/appinstaller-feed-$($tag -replace '[^A-Za-z0-9._-]', '-')" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git checkout -B $branch + git add $feedDir + + $changes = git status --short -- $feedDir + if ([string]::IsNullOrWhiteSpace($changes)) { + Write-Host "Stable AppInstaller feed already points at $tag; no PR needed." + exit 0 + } + + git commit -m "chore(msix): update AppInstaller feed for $tag" ` + -m "Advance the stable Windows AppInstaller feed to the signed MSIX assets from $tag." ` + -m "Merging this PR advances the auto-update source for installed MSIX clients." + git push --force-with-lease origin $branch + + $bodyPath = Join-Path $env:RUNNER_TEMP 'appinstaller-feed-pr.md' + @" + Updates the stable Windows AppInstaller feed files for `$tag`. + + Merging this PR advances installed MSIX clients that poll the stable feed: + + - x64 feed: `$rawBase/openclaw-x64.appinstaller` + - ARM64 feed: `$rawBase/openclaw-arm64.appinstaller` + - x64 MSIX: `$x64Uri` + - ARM64 MSIX: `$arm64Uri` + + Validation performed: + + - Rendered both feed files from `scripts/render-appinstaller.ps1` + - Parsed local AppInstaller XML before publishing + - Validated GitHub release MSIX headers with candidate GitHub content-type compatibility enabled + - Blocked pre-release/alpha feed updates until channel policy is decided + "@ | Set-Content -Path $bodyPath -Encoding UTF8 + + $existingPr = gh pr list --repo $repo --base master --head $branch --json number --jq '.[0].number' + if ([string]::IsNullOrWhiteSpace($existingPr)) { + gh pr create ` + --repo $repo ` + --base master ` + --head $branch ` + --title "chore(msix): update AppInstaller feed for $tag" ` + --body-file $bodyPath + } + else { + gh pr edit $existingPr ` + --repo $repo ` + --title "chore(msix): update AppInstaller feed for $tag" ` + --body-file $bodyPath + } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56fb8dc33..339d93645 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -326,10 +326,14 @@ jobs: $publisher = "CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US" $base = "https://github.com/${{ github.repository }}/releases/download/$tag" $msixUri = "$base/OpenClawCompanion-$assetVersion-${{ matrix.rid }}.msix" - $appInstallerUri = "https://openclaw.github.io/openclaw-windows-node/openclaw-$arch.appinstaller" + $isAlpha = "${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, '-') }}" -eq "true" + $identityName = if ($isAlpha) { "OpenClaw.Companion.Alpha" } else { "OpenClaw.Companion" } + $feedPrefix = if ($isAlpha) { "openclaw-alpha" } else { "openclaw" } + $appInstallerUri = "https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/$feedPrefix-$arch.appinstaller" ./scripts/render-appinstaller.ps1 ` -Version $version ` -Publisher $publisher ` + -IdentityName $identityName ` -ProcessorArchitecture $arch ` -MsixUri $msixUri ` -AppInstallerUri $appInstallerUri ` @@ -408,6 +412,7 @@ jobs: runs-on: windows-latest permissions: contents: write + actions: write steps: - uses: actions/checkout@v6 @@ -517,25 +522,30 @@ jobs: $version = "${{ needs.test.outputs.majorMinorPatch }}.0" $tag = "${{ github.ref_name }}" $publisher = "CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US" + $isAlpha = "${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, '-') }}" -eq "true" + $identityName = if ($isAlpha) { "OpenClaw.Companion.Alpha" } else { "OpenClaw.Companion" } + $feedPrefix = if ($isAlpha) { "openclaw-alpha" } else { "openclaw" } $base = "https://github.com/${{ github.repository }}/releases/download/$tag" $x64Uri = "$base/OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix" $arm64Uri = "$base/OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix" ./scripts/render-appinstaller.ps1 ` -Version $version ` -Publisher $publisher ` + -IdentityName $identityName ` -ProcessorArchitecture x64 ` -MsixUri $x64Uri ` - -AppInstallerUri "https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller" ` + -AppInstallerUri "https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/$feedPrefix-x64.appinstaller" ` -OutputPath "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.appinstaller" ./scripts/render-appinstaller.ps1 ` -Version $version ` -Publisher $publisher ` + -IdentityName $identityName ` -ProcessorArchitecture arm64 ` -MsixUri $arm64Uri ` - -AppInstallerUri "https://openclaw.github.io/openclaw-windows-node/openclaw-arm64.appinstaller" ` + -AppInstallerUri "https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/$feedPrefix-arm64.appinstaller" ` -OutputPath "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.appinstaller" - Copy-Item "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.appinstaller" "openclaw-x64.appinstaller" - Copy-Item "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.appinstaller" "openclaw-arm64.appinstaller" + Copy-Item "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.appinstaller" "$feedPrefix-x64.appinstaller" + Copy-Item "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.appinstaller" "$feedPrefix-arm64.appinstaller" - name: Create Release uses: softprops/action-gh-release@v3 @@ -546,8 +556,7 @@ jobs: OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.appinstaller OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.appinstaller - openclaw-x64.appinstaller - openclaw-arm64.appinstaller + openclaw*.appinstaller prerelease: ${{ contains(github.ref_name, '-') }} make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }} body: | @@ -556,8 +565,8 @@ jobs: ### Downloads - **MSIX x64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix` — Intel / AMD 64-bit, with embedded AppInstaller metadata - **MSIX ARM64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix` — Windows on ARM, with embedded AppInstaller metadata - - **AppInstaller x64**: `openclaw-x64.appinstaller` — stable hosted update source for Intel / AMD 64-bit - - **AppInstaller ARM64**: `openclaw-arm64.appinstaller` — stable hosted update source for Windows on ARM + - **AppInstaller x64**: `${{ contains(github.ref_name, '-') && 'openclaw-alpha-x64.appinstaller' || 'openclaw-x64.appinstaller' }}` — architecture update source for Intel / AMD 64-bit + - **AppInstaller ARM64**: `${{ contains(github.ref_name, '-') && 'openclaw-alpha-arm64.appinstaller' || 'openclaw-arm64.appinstaller' }}` — architecture update source for Windows on ARM - **Tag-pinned AppInstaller x64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.appinstaller` - **Tag-pinned AppInstaller ARM64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.appinstaller` @@ -578,3 +587,14 @@ jobs: 1. Install the signed MSIX for your architecture. On supported Windows builds, the package includes embedded AppInstaller metadata for automatic background updates. 2. Launch from Start Menu or system tray. 3. Right-click tray icon → Settings to configure. + + - name: Request AppInstaller feed PR + if: success() && (steps.msix-x64.outcome == 'success' || steps.msix-arm64.outcome == 'success') + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh workflow run appinstaller-feed-pr.yml ` + --repo "${{ github.repository }}" ` + --ref master ` + -f tag="${{ github.ref_name }}" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a4e086f45..f3031e7bf 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -616,16 +616,18 @@ When a tag is pushed (e.g., `git tag v1.2.3 && git push origin v1.2.3`): 2. **Render AppInstaller:** - `scripts/render-appinstaller.ps1` produces architecture-specific AppInstaller files: - `OpenClawCompanion-X.Y.Z-win-x64.appinstaller`, - `OpenClawCompanion-X.Y.Z-win-arm64.appinstaller`, - `openclaw-x64.appinstaller`, and `openclaw-arm64.appinstaller` - - See [`docs/RELEASING.md`](./docs/RELEASING.md) for the AppInstaller update flow + `OpenClawCompanion-X.Y.Z-win-x64.appinstaller`, + `OpenClawCompanion-X.Y.Z-win-arm64.appinstaller`, + `openclaw-x64.appinstaller`, and `openclaw-arm64.appinstaller` + - After release creation, a follow-up workflow opens a maintainer-reviewed PR + to update the raw GitHub stable feed files under `installer\appinstaller\`. + See [`docs/RELEASING.md`](./docs/RELEASING.md) for the AppInstaller update flow. 3. **GitHub Release:** - Automatic release created with tag name - Attached assets: - `OpenClawCompanion-X.Y.Z-win-x64.msix` and `-win-arm64.msix` - - `openclaw-x64.appinstaller` and `openclaw-arm64.appinstaller` (stable update sources) + - `openclaw-x64.appinstaller` and `openclaw-arm64.appinstaller` (stable-name feed copies) - `OpenClawCompanion-X.Y.Z-win-x64.appinstaller` and `-win-arm64.appinstaller` (tag-pinned) - Release notes auto-generated from commits diff --git a/docs/MSIX_E2E_TEST_RUNBOOK.md b/docs/MSIX_E2E_TEST_RUNBOOK.md index 580437e1a..75a7f75d6 100644 --- a/docs/MSIX_E2E_TEST_RUNBOOK.md +++ b/docs/MSIX_E2E_TEST_RUNBOOK.md @@ -149,10 +149,11 @@ support recipe works. 1. Install vN via the signed MSIX on Windows 11 24H2 and via the hosted architecture-specific `.appinstaller` on a downlevel Windows target. -2. Publish vN+1 by tagging `vX.Y.Z+1` and re-uploading the rendered - `openclaw-x64.appinstaller` / `openclaw-arm64.appinstaller` files to GitHub - Pages (the release pipeline produces the files; the gh-pages publish is - currently manual — see RELEASING.md). +2. Publish vN+1 by tagging `vX.Y.Z+1`. After the release assets are created, + the feed-update workflow opens a PR that updates + `installer\appinstaller\openclaw-x64.appinstaller` and + `installer\appinstaller\openclaw-arm64.appinstaller`. Merge that PR to advance + the raw GitHub stable feed. 3. **Trigger 1 (AutomaticBackgroundTask):** Leave the tray running and give Windows enough time to poll the stable URL. **Assert** no App Installer UI appears during normal launch. @@ -190,6 +191,6 @@ Record outcomes per scenario in the release tracking issue with: - Pass / Fail / Skip - Notes for any partial passes or unexpected dialogs -Promote `openclaw-x64.appinstaller` and `openclaw-arm64.appinstaller` to -GitHub Pages only after scenarios 1, 2, 5, 6, 7, 8 (triggers 1 and 2), 9, and -10 all pass on at least one VM. +Promote the generated feed PR, or fall back to a static host/CDN if raw GitHub +fails, only after scenarios 1, 2, 5, 6, 7, 8 (triggers 1 and 2), 9, and 10 all +pass on at least one VM. diff --git a/docs/MSIX_UPDATE_ENDPOINT_OPTIONS.md b/docs/MSIX_UPDATE_ENDPOINT_OPTIONS.md index 1a5983aa5..e9735ad09 100644 --- a/docs/MSIX_UPDATE_ENDPOINT_OPTIONS.md +++ b/docs/MSIX_UPDATE_ENDPOINT_OPTIONS.md @@ -4,14 +4,15 @@ OpenClaw's MSIX/AppInstaller update feed needs a stable HTTPS URL for each archi ## Recommendation -Use a project-owned custom domain such as `https://updates.openclaw.dev/windows/msix/openclaw-x64.appinstaller`, backed by Azure Static Web Apps or Azure Blob Storage plus CDN/Front Door. This keeps the public contract independent from the hosting backend and avoids tying an unofficial community project to Microsoft-owned `aka.ms` infrastructure. +Use `raw.githubusercontent.com` as the first candidate because the Mac companion app already uses a raw GitHub Sparkle appcast (`https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`). Gate that choice on Windows App Installer red/blue E2E validation because raw GitHub serves repo files with GitHub-controlled headers. If Windows rejects raw GitHub, keep the same generated feed files in `installer\appinstaller\` and publish them to a project-owned custom domain backed by Azure Static Web Apps, Azure Blob Storage plus CDN/Front Door, or another static host with AppInstaller-compatible headers. ## Options | Option | Example endpoint | Pros | Cons / risks | Best fit | Recommendation | |---|---|---|---|---|---| -| Project-owned custom domain backed by Azure Static Web Apps | `https://updates.openclaw.dev/windows/msix/openclaw-x64.appinstaller` | Stable public contract; easy static hosting; GitHub Actions deploy works well; can serve required MIME, `Content-Length`, and range headers; backend can change later behind DNS | Requires domain/DNS ownership and a small Azure resource; maintainers must manage deployment credentials | Community-owned project that wants a durable updater URL without taking on much infra | **Preferred** | -| Project-owned custom domain backed by Azure Blob Storage + CDN or Front Door | `https://updates.openclaw.dev/windows/msix/openclaw-x64.appinstaller` | Very durable object storage; strong header control; CDN/range support; easy to keep historical MSIX assets available | More Azure configuration than Static Web Apps; CDN caching needs careful invalidation for stable `.appinstaller` filenames | Higher-scale or more operations-friendly version of the preferred model | **Preferred if maintainers are comfortable with Azure ops** | +| Raw GitHub file in this repo | `https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller` | Mirrors the Mac companion Sparkle appcast pattern; no separate hosting account; feed changes can be maintainer-reviewed PRs against `master`; raw URLs are simple and durable as long as the repo/branch/path remain stable | GitHub controls MIME/cache/header behavior; current HEAD checks show `text/plain` for raw files and no normal `Content-Length`; Windows App Installer may reject this even if Sparkle accepts it | First candidate if red/blue AppInstaller E2E proves Windows accepts it | **Preferred candidate, validation-gated** | +| Project-owned custom domain backed by Azure Static Web Apps | `https://updates.openclaw.dev/windows/msix/openclaw-x64.appinstaller` | Stable public contract; easy static hosting; GitHub Actions deploy works well; can serve required MIME, `Content-Length`, and range headers; backend can change later behind DNS | Requires domain/DNS ownership and a small Azure resource; maintainers must manage deployment credentials | Community-owned project that wants a durable updater URL without taking on much infra | **Fallback if raw GitHub fails E2E** | +| Project-owned custom domain backed by Azure Blob Storage + CDN or Front Door | `https://updates.openclaw.dev/windows/msix/openclaw-x64.appinstaller` | Very durable object storage; strong header control; CDN/range support; easy to keep historical MSIX assets available | More Azure configuration than Static Web Apps; CDN caching needs careful invalidation for stable `.appinstaller` filenames | Higher-scale or more operations-friendly version of the preferred model | **Fallback if maintainers prefer Azure ops** | | GitHub Pages from the main repo, optionally behind a custom domain | `https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller` or custom-domain equivalent | Simple; close to repo/release workflow; no separate cloud account if Pages is acceptable; good interim path | Requires Pages on the main repo; stable feed publishing is separate from GitHub Release attachment unless automated; direct `github.io` URL couples installed clients to GitHub Pages | Interim endpoint or long-term endpoint only if maintainers want GitHub Pages as project infrastructure | **Acceptable interim; better behind custom domain** | | GitHub Pages from a dev/fork branch | `https://indierawk2k2.github.io/openclaw-windows-node/openclaw-x64.appinstaller` | Fast for testing; no main-repo Pages decision required | Not durable; tied to an individual fork/account; wrong trust boundary for production installs | Manual pre-merge update testing | **Testing only** | | Direct GitHub Release asset URL | `https://github.com/openclaw/openclaw-windows-node/releases/download/vX.Y.Z/...` | Releases already contain signed artifacts; immutable tag URLs are good for package payloads | Not a stable feed URL by itself; "latest" redirects and release asset URLs are not ideal as Windows' stored `.appinstaller` source; harder to guarantee AppInstaller-friendly headers on redirects | Payload downloads referenced by a hosted `.appinstaller` | **Use for MSIX payloads, not as the stable feed** | @@ -26,3 +27,4 @@ Use a project-owned custom domain such as `https://updates.openclaw.dev/windows/ - The stable `.appinstaller` URL is a long-lived contract stored by Windows for installed clients. - MSIX package payloads may still live on GitHub Releases as long as the hosted `.appinstaller` points to versioned, signed assets and header validation passes. - The update feed host must serve `.appinstaller` as `application/appinstaller`, MSIX payloads as `application/msix`, provide `Content-Length`, and support range requests for MSIX payloads. +- If raw GitHub is used, red/blue E2E validation is the deciding proof because its headers do not match the conventional AppInstaller hosting recommendation. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 84096b994..aaf3207ba 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -57,13 +57,13 @@ quiet updates. The supported pattern is a signed MSIX with embedded `.appinstaller` metadata plus a hosted `.appinstaller` XML file that Windows AppInstaller polls. The CI release job renders one file per architecture from `installer/openclaw-companion.appinstaller.template` via -`scripts/render-appinstaller.ps1` and attaches both tag-pinned and stable -filenames to the GitHub release: +`scripts/render-appinstaller.ps1` and attaches tag-pinned AppInstaller files plus +stable-name copies to the GitHub release: - `OpenClawCompanion-X.Y.Z-win-x64.appinstaller` - `OpenClawCompanion-X.Y.Z-win-arm64.appinstaller` -- `openclaw-x64.appinstaller` -- `openclaw-arm64.appinstaller` +- stable-name copy `openclaw-x64.appinstaller` +- stable-name copy `openclaw-arm64.appinstaller` The `.appinstaller` policy intentionally uses only: @@ -99,16 +99,19 @@ fixed by shipping a higher roll-forward version. matching MSIX must all match exactly. - The rendered `` must match the MSIX URL: x64 files point at x64 MSIX assets and arm64 files point at ARM64 assets. -- The hosted stable URLs must serve correct headers before promotion: - `.appinstaller` as `application/appinstaller`, `.msix` as `application/msix`, - `Content-Length`, and MSIX range requests. -- Validate those headers before promotion: - `.\scripts\validate-appinstaller-hosting.ps1 -AppInstallerUri https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller` - and repeat for `openclaw-arm64.appinstaller`. -- Publishing `openclaw-x64.appinstaller` and `openclaw-arm64.appinstaller` to - GitHub Pages is **a separate step** from attaching them to the release. Until - that's automated, the release operator copies the files from the GitHub - release into the `gh-pages` branch by hand after validation. +- The embedded stable feed URLs currently point at raw GitHub files in this repo: + `installer\appinstaller\openclaw-x64.appinstaller` and + `installer\appinstaller\openclaw-arm64.appinstaller`. +- Raw GitHub mirrors the Mac companion app's Sparkle appcast pattern, but it + serves repo files with GitHub-controlled headers. Windows App Installer must + still be proven with red/blue E2E validation before this endpoint is treated as + durable. +- After the release is created, CI dispatches + `.github\workflows\appinstaller-feed-pr.yml`. That workflow renders the stable + feed files from the signed release assets, validates them, and opens a PR. + Merging that PR advances the stable update source. +- Pre-release/alpha feed updates are intentionally blocked until maintainers + choose a separate channel strategy. ## If you need to retag diff --git a/docs/SETUP.md b/docs/SETUP.md index c8fa04975..bbcad67f6 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -20,14 +20,14 @@ Go to the [Releases page](https://github.com/openclaw/openclaw-windows-node/rele |------|-------------| | `OpenClawCompanion-X.Y.Z-win-x64.msix` | Recommended for Intel / AMD 64-bit; includes embedded AppInstaller metadata on supported Windows builds | | `OpenClawCompanion-X.Y.Z-win-arm64.msix` | Recommended for ARM64 (Surface Pro X, Snapdragon laptops); includes embedded AppInstaller metadata on supported Windows builds | -| `openclaw-x64.appinstaller` | Stable hosted update source / alternate install path for Intel / AMD 64-bit | -| `openclaw-arm64.appinstaller` | Stable hosted update source / alternate install path for ARM64 | +| `openclaw-x64.appinstaller` | Stable update source / alternate install path for Intel / AMD 64-bit | +| `openclaw-arm64.appinstaller` | Stable update source / alternate install path for ARM64 | If you're unsure which architecture you need, most Intel/AMD PCs use x64 and Snapdragon/Surface-on-ARM devices use ARM64. A future MSIX bundle can collapse this to one download, but the current release uses architecture-specific packages. ### 2. Install the MSIX -Double-click the signed MSIX. Windows AppInstaller opens, shows the publisher (Scott Hanselman, code-signed via Azure Trusted Signing), and offers to install. On supported Windows builds, the MSIX also seeds the stable hosted `.appinstaller` URL for background updates. +Double-click the signed MSIX. Windows AppInstaller opens, shows the publisher (Scott Hanselman, code-signed via Azure Trusted Signing), and offers to install. On supported Windows builds, the MSIX also seeds the stable `.appinstaller` URL for background updates. The install runs without requiring administrator privileges. diff --git a/installer/appinstaller/README.md b/installer/appinstaller/README.md new file mode 100644 index 000000000..8fe60d499 --- /dev/null +++ b/installer/appinstaller/README.md @@ -0,0 +1,23 @@ +# Windows AppInstaller stable feed + +This directory is the source-controlled stable update feed for the OpenClaw +Companion MSIX channel. + +Installed MSIX packages poll these architecture-specific raw GitHub URLs: + +- `https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller` +- `https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-arm64.appinstaller` + +Release builds do not push these files directly to `master`. After a successful +stable release, `.github/workflows/appinstaller-feed-pr.yml` renders the feed +files from the signed GitHub Release MSIX assets, validates them, and opens a PR. +Merging that PR advances the stable auto-update source for installed clients. + +Raw GitHub intentionally mirrors the Mac companion app's Sparkle appcast model, +but Windows App Installer has different hosting requirements. If red/blue E2E +testing proves raw GitHub is not accepted by Windows App Installer, keep this +directory as the generated source of truth and publish the same files to a static +host or CDN that serves AppInstaller-compatible headers. + +Alpha/pre-release feed updates are blocked until maintainers choose a channel +strategy. Do not hand-edit stable feed files to point at alpha packages. diff --git a/installer/openclaw-companion.appinstaller.template b/installer/openclaw-companion.appinstaller.template index bf95ed34a..e366fbd77 100644 --- a/installer/openclaw-companion.appinstaller.template +++ b/installer/openclaw-companion.appinstaller.template @@ -9,9 +9,10 @@ {{PUBLISHER}} - quoted publisher subject from the MSIX manifest e.g. "CN=Scott Hanselman, O=Scott Hanselman, …" {{PROCESSOR_ARCHITECTURE}} - x64 or arm64; must match the MSIX package + {{IDENTITY_NAME}} - MSIX package identity, e.g. OpenClaw.Companion {{MSIX_URI}} - absolute URL of the matching architecture .msix {{APPINSTALLER_URI}} - absolute URL of THIS rendered .appinstaller file - on the stable channel (e.g. GitHub Pages) + on the stable channel See docs/RELEASING.md for the AppInstaller update flow and hosting caveats. --> @@ -21,7 +22,7 @@ Uri="{{APPINSTALLER_URI}}"> @@ -51,6 +57,7 @@ param( [Parameter(Mandatory)] [string] $Version, [Parameter(Mandatory)] [string] $Publisher, + [string] $IdentityName = 'OpenClaw.Companion', [Parameter(Mandatory)] [ValidateSet('x64', 'arm64')] [string] $ProcessorArchitecture, [Parameter(Mandatory)] [string] $MsixUri, [Parameter(Mandatory)] [string] $AppInstallerUri, @@ -73,6 +80,10 @@ foreach ($p in $parts) { } } +if ([string]::IsNullOrWhiteSpace($IdentityName)) { + throw "IdentityName must not be empty." +} + # Validate URIs are absolute https:// for production. Local smoke tests may use # http://127.0.0.1 with -AllowHttpForLocalTest. foreach ($pair in @( @@ -104,6 +115,7 @@ $template = Get-Content $templatePath -Raw $rendered = $template $rendered = $rendered.Replace('{{VERSION}}', $Version) $rendered = $rendered.Replace('{{PUBLISHER}}', $Publisher) +$rendered = $rendered.Replace('{{IDENTITY_NAME}}', $IdentityName) $rendered = $rendered.Replace('{{PROCESSOR_ARCHITECTURE}}', $ProcessorArchitecture) $rendered = $rendered.Replace('{{MSIX_URI}}', $MsixUri) $rendered = $rendered.Replace('{{APPINSTALLER_URI}}', $AppInstallerUri) @@ -124,6 +136,12 @@ if ($null -eq $mainPackage) { if ($mainPackage.Publisher -ne $Publisher) { throw "Rendered XML has Publisher '$($mainPackage.Publisher)' but expected '$Publisher'." } +if ($mainPackage.Name -ne $IdentityName) { + throw "Rendered XML has MainPackage Name '$($mainPackage.Name)' but expected '$IdentityName'." +} +if ($mainPackage.Version -ne $Version) { + throw "Rendered XML has MainPackage Version '$($mainPackage.Version)' but expected '$Version'." +} if ($mainPackage.ProcessorArchitecture -ne $ProcessorArchitecture) { throw "Rendered XML has ProcessorArchitecture '$($mainPackage.ProcessorArchitecture)' but expected '$ProcessorArchitecture'." } @@ -140,6 +158,7 @@ Set-Content -Path $OutputPath -Value $rendered -Encoding UTF8 Write-Host "Rendered AppInstaller: $OutputPath" Write-Host " Version: $Version" Write-Host " Publisher: $Publisher" +Write-Host " Identity: $IdentityName" Write-Host " Architecture: $ProcessorArchitecture" Write-Host " MSIX URI: $MsixUri" Write-Host " AppInstaller URI: $AppInstallerUri" diff --git a/scripts/test-appinstaller-update.ps1 b/scripts/test-appinstaller-update.ps1 index 531416d0e..74e1179c0 100644 --- a/scripts/test-appinstaller-update.ps1 +++ b/scripts/test-appinstaller-update.ps1 @@ -7,7 +7,7 @@ .DESCRIPTION The point of this script is to catch regressions in the .appinstaller XML and the PackageManager.AddPackageByAppInstallerFileAsync wiring - *without* needing a real GitHub release / GitHub Pages cycle. Run this + *without* needing a real GitHub release / stable feed PR cycle. Run this before a release tag goes out; if it fails, the same failure will happen to every user that installs from the stable architecture-specific AppInstaller URL. diff --git a/scripts/validate-appinstaller-hosting.ps1 b/scripts/validate-appinstaller-hosting.ps1 index e6b0a6665..7527fe4ae 100644 --- a/scripts/validate-appinstaller-hosting.ps1 +++ b/scripts/validate-appinstaller-hosting.ps1 @@ -6,26 +6,39 @@ Windows AppInstaller is strict about hosted metadata and package assets. This script checks the stable .appinstaller URL, parses its MainPackage URI when -MsixUri is not provided, then validates the MSIX endpoint. It is intended for - release operators before copying openclaw-x64.appinstaller or - openclaw-arm64.appinstaller to the stable hosting branch/location. + release operators before promoting openclaw-x64.appinstaller or + openclaw-arm64.appinstaller to the stable feed location. .PARAMETER AppInstallerUri Stable hosted .appinstaller URL, e.g. - https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller. + https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller. .PARAMETER MsixUri Optional MSIX URL. When omitted, the script fetches AppInstallerUri and reads the MainPackage Uri attribute. +.PARAMETER AppInstallerPath + Optional local .appinstaller file to parse instead of fetching AppInstallerUri. + This is used by the feed-update PR workflow before the rendered file has been + merged into the stable raw GitHub location. + +.PARAMETER AllowGitHubContentTypes + Candidate-mode compatibility switch for GitHub-hosted release assets. GitHub + release downloads currently serve MSIX files as application/octet-stream. This + switch keeps strict validation as the default while allowing red/blue E2E + testing to prove whether Windows AppInstaller accepts GitHub's headers. + .EXAMPLE ./scripts/validate-appinstaller-hosting.ps1 ` - -AppInstallerUri https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller + -AppInstallerUri https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller #> [CmdletBinding()] param( - [Parameter(Mandatory)] [Uri] $AppInstallerUri, - [Uri] $MsixUri + [Uri] $AppInstallerUri, + [string] $AppInstallerPath, + [Uri] $MsixUri, + [switch] $AllowGitHubContentTypes ) $ErrorActionPreference = 'Stop' @@ -67,15 +80,43 @@ function Assert-ContentType { param( [Parameter(Mandatory)] $Response, [Parameter(Mandatory)] [Uri] $Uri, - [Parameter(Mandatory)] [string] $Expected + [Parameter(Mandatory)] [string] $Expected, + [string[]] $AlsoAllowed = @() ) $contentType = Get-HeaderValue -Response $Response -Name 'Content-Type' + $allowed = @($Expected) + $AlsoAllowed + foreach ($candidate in $allowed) { + if (-not [string]::IsNullOrWhiteSpace($contentType) -and + $contentType.StartsWith($candidate, [StringComparison]::OrdinalIgnoreCase)) { + Write-Host " Content-Type OK: $contentType" + return + } + } + if ([string]::IsNullOrWhiteSpace($contentType) -or - -not $contentType.StartsWith($Expected, [StringComparison]::OrdinalIgnoreCase)) { + $allowed.Count -eq 1) { throw "$Uri returned Content-Type '$contentType'; expected '$Expected'." } - Write-Host " Content-Type OK: $contentType" + throw "$Uri returned Content-Type '$contentType'; expected one of: $($allowed -join ', ')." +} + +function Get-MainPackageUri { + param([Parameter(Mandatory)] [xml] $AppInstallerXml) + + $namespaceManager = [System.Xml.XmlNamespaceManager]::new($AppInstallerXml.NameTable) + $namespaceManager.AddNamespace('ai', 'http://schemas.microsoft.com/appx/appinstaller/2018') + $mainPackage = $AppInstallerXml.SelectSingleNode('/ai:AppInstaller/ai:MainPackage', $namespaceManager) + if ($null -eq $mainPackage) { + $mainPackage = $AppInstallerXml.SelectSingleNode('/AppInstaller/MainPackage') + } + + $mainPackageUri = if ($null -eq $mainPackage) { $null } else { $mainPackage.GetAttribute('Uri') } + if ([string]::IsNullOrWhiteSpace($mainPackageUri)) { + throw "AppInstaller XML does not contain a MainPackage Uri." + } + + return [Uri]$mainPackageUri } function Assert-ContentLength { @@ -120,34 +161,56 @@ function Assert-MsixRangeRequest { Write-Host " Range request OK: $contentRange" } -Write-Host "Validating AppInstaller hosting: $AppInstallerUri" -Assert-HttpsUri -Uri $AppInstallerUri -Description 'AppInstallerUri' -$appInstallerHead = Invoke-Head -Uri $AppInstallerUri -Assert-ContentType -Response $appInstallerHead -Uri $AppInstallerUri -Expected 'application/appinstaller' -Assert-ContentLength -Response $appInstallerHead -Uri $AppInstallerUri +if ([string]::IsNullOrWhiteSpace($AppInstallerPath) -and $null -eq $AppInstallerUri) { + throw "Provide either -AppInstallerUri or -AppInstallerPath." +} -if ($null -eq $MsixUri) { - $appInstallerBody = Invoke-WebRequest -Uri $AppInstallerUri -Method Get -MaximumRedirection 5 -UseBasicParsing - [xml]$appInstallerXml = $appInstallerBody.Content - $namespaceManager = [System.Xml.XmlNamespaceManager]::new($appInstallerXml.NameTable) - $namespaceManager.AddNamespace('ai', 'http://schemas.microsoft.com/appx/appinstaller/2018') - $mainPackage = $appInstallerXml.SelectSingleNode('/ai:AppInstaller/ai:MainPackage', $namespaceManager) - if ($null -eq $mainPackage) { - $mainPackage = $appInstallerXml.SelectSingleNode('/AppInstaller/MainPackage') +if (-not [string]::IsNullOrWhiteSpace($AppInstallerPath)) { + if (-not (Test-Path $AppInstallerPath)) { + throw "AppInstallerPath not found: $AppInstallerPath" } - $mainPackageUri = if ($null -eq $mainPackage) { $null } else { $mainPackage.GetAttribute('Uri') } - if ([string]::IsNullOrWhiteSpace($mainPackageUri)) { - throw "$AppInstallerUri does not contain a MainPackage Uri." + Write-Host "Validating local AppInstaller XML: $AppInstallerPath" + [xml]$appInstallerXml = Get-Content -Path $AppInstallerPath -Raw + if ($null -eq $MsixUri) { + $MsixUri = Get-MainPackageUri -AppInstallerXml $appInstallerXml + Write-Host "Discovered MSIX URI from local AppInstaller: $MsixUri" + } +} +else { + Write-Host "Validating AppInstaller hosting: $AppInstallerUri" + Assert-HttpsUri -Uri $AppInstallerUri -Description 'AppInstallerUri' + $appInstallerHead = Invoke-Head -Uri $AppInstallerUri + $allowedAppInstallerTypes = if ($AllowGitHubContentTypes -and + $AppInstallerUri.Host.Equals('raw.githubusercontent.com', [StringComparison]::OrdinalIgnoreCase)) { + @('text/plain') + } else { + @() + } + Assert-ContentType -Response $appInstallerHead -Uri $AppInstallerUri -Expected 'application/appinstaller' -AlsoAllowed $allowedAppInstallerTypes + if (-not ($AllowGitHubContentTypes -and + $AppInstallerUri.Host.Equals('raw.githubusercontent.com', [StringComparison]::OrdinalIgnoreCase))) { + Assert-ContentLength -Response $appInstallerHead -Uri $AppInstallerUri + } + + if ($null -eq $MsixUri) { + $appInstallerBody = Invoke-WebRequest -Uri $AppInstallerUri -Method Get -MaximumRedirection 5 -UseBasicParsing + [xml]$appInstallerXml = $appInstallerBody.Content + $MsixUri = Get-MainPackageUri -AppInstallerXml $appInstallerXml + Write-Host "Discovered MSIX URI from AppInstaller: $MsixUri" } - $MsixUri = [Uri]$mainPackageUri - Write-Host "Discovered MSIX URI from AppInstaller: $MsixUri" } Write-Host "Validating MSIX hosting: $MsixUri" Assert-HttpsUri -Uri $MsixUri -Description 'MsixUri' $msixHead = Invoke-Head -Uri $MsixUri -Assert-ContentType -Response $msixHead -Uri $MsixUri -Expected 'application/msix' +$allowedMsixTypes = if ($AllowGitHubContentTypes -and + $MsixUri.Host.Equals('github.com', [StringComparison]::OrdinalIgnoreCase)) { + @('application/octet-stream') +} else { + @() +} +Assert-ContentType -Response $msixHead -Uri $MsixUri -Expected 'application/msix' -AlsoAllowed $allowedMsixTypes Assert-ContentLength -Response $msixHead -Uri $MsixUri Assert-MsixRangeRequest -Uri $MsixUri diff --git a/src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs b/src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs index a614ba53c..785aad205 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs @@ -26,16 +26,16 @@ internal static class AppInstallerUpdateService private static readonly HttpClient SharedHttpClient = new(); /// - /// Stable x64 URL of the AppInstaller XML on GitHub Pages. + /// Stable x64 URL of the AppInstaller XML in the Windows repo. /// public const string LatestX64AppInstallerUri = - "https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller"; + "https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller"; /// - /// Stable ARM64 URL of the AppInstaller XML on GitHub Pages. + /// Stable ARM64 URL of the AppInstaller XML in the Windows repo. /// public const string LatestArm64AppInstallerUri = - "https://openclaw.github.io/openclaw-windows-node/openclaw-arm64.appinstaller"; + "https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-arm64.appinstaller"; public static string LatestAppInstallerUri => RuntimeInformation.ProcessArchitecture == Architecture.Arm64 @@ -186,9 +186,12 @@ internal static UpdateResult ClassifyPublishedVersion(Version currentVersion, Ve internal static Version ParseAppInstallerVersion(string appInstallerXml) { var doc = XDocument.Parse(appInstallerXml); - var versionText = (string?)doc.Root?.Attribute("Version"); + var mainPackage = doc.Root is null + ? null + : doc.Root.Elements().SingleOrDefault(element => element.Name.LocalName == "MainPackage"); + var versionText = (string?)mainPackage?.Attribute("Version"); if (!Version.TryParse(versionText, out var version) || version.Revision < 0) - throw new FormatException("AppInstaller Version must be a four-part version."); + throw new FormatException("AppInstaller MainPackage Version must be a four-part version."); return version; } diff --git a/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs b/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs index 6d7f55942..6adca2263 100644 --- a/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs @@ -13,7 +13,7 @@ namespace OpenClaw.Tray.Tests; /// notice. The tests here pin: /// /// 1. The template is well-formed XML against the AppInstaller schema URI. -/// 2. The five placeholder tokens are present (so the CI render script's + /// 2. The placeholder tokens are present (so the CI render script's /// substitution table is exhaustive). /// 3. The UpdateSettings block stays quiet: AutomaticBackgroundTask only, /// no OnLaunch UI and no downgrade rollback. @@ -62,12 +62,13 @@ public void Template_IsWellFormedXml() [Theory] [InlineData("{{VERSION}}")] [InlineData("{{PUBLISHER}}")] + [InlineData("{{IDENTITY_NAME}}")] [InlineData("{{PROCESSOR_ARCHITECTURE}}")] [InlineData("{{MSIX_URI}}")] [InlineData("{{APPINSTALLER_URI}}")] public void Template_DeclaresExpectedPlaceholder(string token) { - // scripts/render-appinstaller.ps1 substitutes exactly these five tokens. + // scripts/render-appinstaller.ps1 substitutes exactly these tokens. // If you add a new placeholder here, also add a -replace in the script // AND a CI step parameter. If you remove one, the renderer silently // ships the literal {{TOKEN}} string to AppInstaller which fails to parse. @@ -95,7 +96,7 @@ public void Template_UsesArchitectureSpecificMainPackage() Assert.Empty(doc.Descendants(ns + "MainBundle")); var mainPackage = doc.Descendants(ns + "MainPackage").Single(); - Assert.Equal("OpenClaw.Companion", (string?)mainPackage.Attribute("Name")); + Assert.Equal("{{IDENTITY_NAME}}", (string?)mainPackage.Attribute("Name")); Assert.Equal("{{PROCESSOR_ARCHITECTURE}}", (string?)mainPackage.Attribute("ProcessorArchitecture")); Assert.Equal("{{MSIX_URI}}", (string?)mainPackage.Attribute("Uri")); } @@ -107,12 +108,12 @@ public void InAppService_PointsAtSameStableArchitectureUrlsAsReleaseChannel() GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", "Services", "AppInstallerUpdateService.cs")); - Assert.Contains("https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller", service); - Assert.Contains("https://openclaw.github.io/openclaw-windows-node/openclaw-arm64.appinstaller", service); + Assert.Contains("https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller", service); + Assert.Contains("https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-arm64.appinstaller", service); var ci = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "ci.yml")); - Assert.Contains("https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller", ci); - Assert.Contains("https://openclaw.github.io/openclaw-windows-node/openclaw-arm64.appinstaller", ci); + Assert.Contains("https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/$feedPrefix-x64.appinstaller", ci); + Assert.Contains("https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/$feedPrefix-arm64.appinstaller", ci); } [Fact] @@ -172,6 +173,8 @@ public void ManualUpdateCheck_IsMetadataOnly() Assert.Contains("CheckForUpdateAsync", service); Assert.Contains("ParseAppInstallerVersion", service); + Assert.Contains("element.Name.LocalName == \"MainPackage\"", service); + Assert.Contains("AppInstaller MainPackage Version must be a four-part version", service); Assert.Contains("ClassifyPublishedVersion", service); Assert.Contains("UpdateAvailable", service); Assert.Contains("AppInstallerUpdateService.CheckForUpdateAsync()", app); @@ -229,6 +232,9 @@ public void HostingValidationScript_ChecksMimeLengthAndRange() Assert.Contains("application/appinstaller", script); Assert.Contains("application/msix", script); + Assert.Contains("AppInstallerPath", script); + Assert.Contains("AllowGitHubContentTypes", script); + Assert.Contains("application/octet-stream", script); Assert.Contains("Scheme -ne 'https'", script); Assert.Contains("Content-Length", script); Assert.Contains("Range = 'bytes=0-0'", script); @@ -245,4 +251,26 @@ public void AppInstallerUpdateSmokeScript_BindsSingleHttpListenerAndSelfChecks() Assert.Contains("Invoke-WebRequest \"$baseUri/openclaw.appinstaller\"", script); Assert.Contains("$listenerJob.State -eq 'Failed'", script); } + + [Fact] + public void FeedUpdateWorkflow_OpensMaintainerPrAndBlocksPrereleaseFeeds() + { + var workflow = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), ".github", "workflows", "appinstaller-feed-pr.yml")); + var ci = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "ci.yml")); + + Assert.Contains("appinstaller-feed-pr.yml", ci); + Assert.Contains("actions: write", ci); + Assert.Contains("workflow_dispatch", workflow); + Assert.Contains("pull-requests: write", workflow); + Assert.Contains("contents: write", workflow); + Assert.Contains("Pre-release AppInstaller feed updates are blocked", workflow); + Assert.Contains("installer\\appinstaller", workflow); + Assert.Contains("openclaw-x64.appinstaller", workflow); + Assert.Contains("openclaw-arm64.appinstaller", workflow); + Assert.Contains("gh pr create", workflow); + Assert.Contains("--base master", workflow); + Assert.Contains("validate-appinstaller-hosting.ps1", workflow); + Assert.Contains("-AllowGitHubContentTypes", workflow); + } } From 49f032f740ce72251189122b452e03a772af8a92 Mon Sep 17 00:00:00 2001 From: Mike Harsh Date: Mon, 25 May 2026 06:51:55 -0700 Subject: [PATCH 26/56] fix(msix): surface update checks and package version Render the Check for updates action in the tray menu, show update status on the About page, and display the MSIX package version instead of the assembly fallback for packaged builds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 20 ++++++------- .../Helpers/AppVersionHelper.cs | 30 +++++++++++++++++++ src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml | 9 ++++-- .../Pages/AboutPage.xaml.cs | 22 +++++++++++++- .../Services/TrayMenuStateBuilder.cs | 1 + .../DiagnosticsPageContractTests.cs | 17 +++++++++++ .../FluentIconCatalogTests.cs | 8 +++++ 7 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Helpers/AppVersionHelper.cs diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index d81df9a0d..ebada7c70 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3383,7 +3383,7 @@ private void OnSettingsHotkeyPressed(object? sender, EventArgs e) private static UpdateCommandCenterInfo BuildInitialUpdateInfo() => new() { Status = "Not checked", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown" + CurrentVersion = AppVersionHelper.CurrentVersionText }; private async Task CheckForUpdatesAsync() @@ -3397,7 +3397,7 @@ private async Task CheckForUpdatesAsync() _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Managed", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CurrentVersion = AppVersionHelper.CurrentVersionText, CheckedAt = DateTime.UtcNow, Detail = "managed by Windows AppInstaller" }; @@ -3414,7 +3414,7 @@ private async Task CheckForUpdatesAsync() _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Skipped", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CurrentVersion = AppVersionHelper.CurrentVersionText, CheckedAt = DateTime.UtcNow, Detail = "unpackaged build; install the MSIX for auto-update" }; @@ -3426,7 +3426,7 @@ private async Task CheckForUpdatesAsync() _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Failed", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CurrentVersion = AppVersionHelper.CurrentVersionText, CheckedAt = DateTime.UtcNow, Detail = ex.Message }; @@ -3445,7 +3445,7 @@ private async Task CheckForUpdatesUserInitiatedAsync() _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Checking", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CurrentVersion = AppVersionHelper.CurrentVersionText, CheckedAt = DateTime.UtcNow, Detail = $"querying {AppInstallerUpdateService.LatestAppInstallerUri}" }; @@ -3457,35 +3457,35 @@ private async Task CheckForUpdatesUserInitiatedAsync() AppInstallerUpdateService.UpdateOutcome.UpdateAvailable => new UpdateCommandCenterInfo { Status = "Available", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CurrentVersion = AppVersionHelper.CurrentVersionText, CheckedAt = DateTime.UtcNow, Detail = outcome.DetailMessage ?? "update available" }, AppInstallerUpdateService.UpdateOutcome.UpdateQueued => new UpdateCommandCenterInfo { Status = "Ready", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CurrentVersion = AppVersionHelper.CurrentVersionText, CheckedAt = DateTime.UtcNow, Detail = outcome.DetailMessage ?? "update accepted; restart OpenClaw when convenient" }, AppInstallerUpdateService.UpdateOutcome.UpdatePendingRestart => new UpdateCommandCenterInfo { Status = "Ready", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CurrentVersion = AppVersionHelper.CurrentVersionText, CheckedAt = DateTime.UtcNow, Detail = outcome.DetailMessage ?? "update available; close and reopen OpenClaw to finish" }, AppInstallerUpdateService.UpdateOutcome.NoUpdateAvailable => new UpdateCommandCenterInfo { Status = "Current", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CurrentVersion = AppVersionHelper.CurrentVersionText, CheckedAt = DateTime.UtcNow, Detail = outcome.DetailMessage ?? "no updates available" }, _ => new UpdateCommandCenterInfo { Status = "Failed", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CurrentVersion = AppVersionHelper.CurrentVersionText, CheckedAt = DateTime.UtcNow, Detail = outcome.DetailMessage ?? "update failed" } diff --git a/src/OpenClaw.Tray.WinUI/Helpers/AppVersionHelper.cs b/src/OpenClaw.Tray.WinUI/Helpers/AppVersionHelper.cs new file mode 100644 index 000000000..7e45aac35 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/AppVersionHelper.cs @@ -0,0 +1,30 @@ +using System; + +namespace OpenClawTray.Helpers; + +internal static class AppVersionHelper +{ + public static string CurrentVersionText + { + get + { + if (PackageHelper.IsPackaged) + { + try + { + var version = global::Windows.ApplicationModel.Package.Current.Id.Version; + return $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}"; + } + catch + { + // Fall through to assembly version for unpackaged/test contexts + // where Package.Current may be unavailable despite stale state. + } + } + + return typeof(AppVersionHelper).Assembly.GetName().Version?.ToString() ?? "unknown"; + } + } + + public static string DisplayVersion => $"v{CurrentVersionText}"; +} diff --git a/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml index 044f008df..d0beb8239 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml @@ -17,7 +17,7 @@ -