From fafaec692d3e82286f2aaf35cde2ff918f3db8e4 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Thu, 3 Sep 2026 16:03:47 -0700 Subject: [PATCH 01/19] feat(msix): add development and Microsoft Store MSIX packaging Adds an MSIX packaging channel alongside the existing Inno Setup + Updatum unpackaged installer, which is unchanged. Two flavors are produced from the same source, selected by the new `build.ps1 -Msix Dev|Store` parameter: - Dev: side-by-side identity (OpenClaw.Companion.Dev), signed with a local self-signed certificate, for sideload testing. Its version revision is derived from the installed package revision + 1 because Add-AppxPackage requires a strictly increasing version. - Store: release identity, unsigned (the Store re-signs), built for both win-x64 and win-arm64. Its version revision is pinned to 0 because Partner Center reserves the fourth version field. Those two revision rules are mutually exclusive, so a single artifact cannot serve both channels. Package.appxmanifest is the single source of truth for release identity; Build-Msix.ps1 reads it and verifies identity, publisher, architecture, and version on every packaged output, so swapping in the Partner Center values needs no script change. It also asserts required payload content (wxc-exec.exe, VC++ runtime DLLs) and forbids dev-only artifacts from leaking into a Store package. The manifest also declares the toast COM activator, which resolves the "Failed to register notification activator" warning in packaged builds, and a StartupTask extension used by the following commit. build.ps1 gains [CmdletBinding()]. Without it a PowerShell script routes unknown named parameters into $args and silently ignores them, so a stale switch produced a normal unpackaged build with no error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build.ps1 | 177 +++++++++- scripts/Build-Msix.ps1 | 333 ++++++++++++++++++ scripts/setup-dev-msix-cert.ps1 | 158 +++++++++ src/Directory.Build.props | 3 + .../OpenClaw.Tray.WinUI.csproj | 70 +++- src/OpenClaw.Tray.WinUI/Package.appxmanifest | 38 +- 6 files changed, 766 insertions(+), 13 deletions(-) create mode 100644 scripts/Build-Msix.ps1 create mode 100644 scripts/setup-dev-msix-cert.ps1 diff --git a/build.ps1 b/build.ps1 index f2b843d31..b90ddfe9d 100644 --- a/build.ps1 +++ b/build.ps1 @@ -20,6 +20,22 @@ Build the WinUI app with the side-by-side dev identity. Defaults off so release identity remains the default for every configuration. +.PARAMETER Msix + Produce an MSIX package. The two modes are mutually exclusive because they + build different applications, not two flavors of one: + + Dev - locally signed, self-contained package using the side-by-side dev + identity. Implies -DevBuild and uses the certificate created by + scripts\setup-dev-msix-cert.ps1. The manifest revision is the + installed development package revision plus one, so repeated + Add-AppxPackage sideloads upgrade cleanly. + + Store - unsigned, self-contained packages for x64 and ARM64 using the + release identity. Partner Center signs them, so no local + certificate is used, and the manifest revision is pinned to 0 + because the Store reserves that field. Forces -Configuration + Release and cannot be combined with -DevBuild. + .PARAMETER NoTrustRepository Do not automatically add this checkout to git safe.directory when GitVersion cannot read a repo owned by a different Windows account/group. The script @@ -29,8 +45,14 @@ .\build.ps1 .\build.ps1 -Project WinUI -Configuration Release .\build.ps1 -CheckOnly + .\build.ps1 -Project WinUI -Msix Dev + .\build.ps1 -Project WinUI -Msix Store #> +# CmdletBinding makes unrecognized parameters a hard error. Without it a removed +# or misspelled switch such as -PackageMsix lands in $args and is silently +# ignored, producing an unpackaged build with no indication anything was wrong. +[CmdletBinding()] param( [ValidateSet("All", "Tray", "WinUI", "Shared", "Cli", "WinNodeCli", "SetupEngine")] [string]$Project = "All", @@ -42,6 +64,9 @@ param( [switch]$DevBuild, + [ValidateSet("Dev", "Store")] + [string]$Msix, + [switch]$NoTrustRepository ) @@ -50,6 +75,26 @@ $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path Set-Location $repoRoot +$buildDevMsix = ($Msix -eq "Dev") +$buildStoreMsix = ($Msix -eq "Store") + +if ($buildDevMsix) { + $DevBuild = $true +} + +# Microsoft Store packages ship the release identity and are signed by Partner +# Center, so they are incompatible with the dev identity and the local dev cert. +$storeMsixRuntimeIdentifiers = @("win-x64", "win-arm64") +if ($buildStoreMsix) { + if ($DevBuild) { + throw "-Msix Store cannot be combined with -DevBuild. Store packages must use the release identity." + } + if ($PSBoundParameters.ContainsKey("Configuration") -and $Configuration -ne "Release") { + throw "-Msix Store requires -Configuration Release. Debug binaries are not accepted by Store certification." + } + $Configuration = "Release" +} + # Colors for output function Write-Header($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan } function Write-Success($text) { Write-Host "✅ $text" -ForegroundColor Green } @@ -299,6 +344,44 @@ if ($arch -eq "ARM64") { Write-Info "ARM64 detected - builds will target ARM64 by default" } +if ($buildStoreMsix) { + if ($Project -notin @("All", "Tray", "WinUI")) { + Write-Error "-Msix Store requires -Project All, Tray, or WinUI." + $issues += "Store MSIX packaging requires the WinUI project" + } + + Write-Success "Store MSIX target architectures: $($storeMsixRuntimeIdentifiers -join ', ')" + Write-Info "Packages are left unsigned; Partner Center signs Store submissions." +} + +if ($buildDevMsix) { + if ($Project -notin @("All", "Tray", "WinUI")) { + Write-Error "-Msix Dev requires -Project All, Tray, or WinUI." + $issues += "MSIX packaging requires the WinUI project" + } + + $devMsixCertificateDirectory = Join-Path $env:LOCALAPPDATA "OpenClawDevelopment\MSIX" + $devMsixThumbprintFile = Join-Path $devMsixCertificateDirectory "dev-msix-thumbprint.txt" + $devMsixThumbprint = if (Test-Path $devMsixThumbprintFile) { + (Get-Content -LiteralPath $devMsixThumbprintFile -Raw).Trim() + } else { + "" + } + $devMsixCertificate = if ($devMsixThumbprint) { + Get-Item "Cert:\CurrentUser\My\$devMsixThumbprint" -ErrorAction SilentlyContinue + } else { + $null + } + + if ($devMsixCertificate) { + Write-Success "Development MSIX signing certificate: $($devMsixCertificate.Thumbprint)" + } else { + Write-Error "Development MSIX signing certificate not found." + Write-Info "Run .\scripts\setup-dev-msix-cert.ps1 from an elevated PowerShell, then retry." + $issues += "Missing development MSIX signing certificate" + } +} + # Summary Write-Header "Prerequisite Summary" @@ -352,7 +435,7 @@ function Invoke-DotNetCaptured($arguments) { } } -function Build-Project($name, $path, $useRid = $false) { +function Build-Project($name, $path, $useRid = $false, $packageMsix = $false) { Write-Host "`nBuilding $name..." -ForegroundColor White if (-not (Test-Path $path)) { @@ -360,14 +443,49 @@ function Build-Project($name, $path, $useRid = $false) { return $false } - $dotnetArgs = @("build", $path, "-c", $Configuration) - # WinUI requires runtime identifier for self-contained WebView2 support - if ($useRid) { - $dotnetArgs += @("-r", $rid) + if ($packageMsix) { + $installedDevPackage = Get-AppxPackage -Name "OpenClaw.Companion.Dev" -ErrorAction SilentlyContinue | + Where-Object Publisher -eq "CN=OpenClaw Local Development" | + Sort-Object { [version]$_.Version.ToString() } -Descending | + Select-Object -First 1 + $msixRevision = if ($installedDevPackage) { + ([version]$installedDevPackage.Version.ToString()).Revision + 1 + } else { + 1 + } + if ($msixRevision -gt 65535) { + Write-Error "The installed development MSIX revision is already 65535. Remove the installed OpenClaw.Companion.Dev package before rebuilding." + return $false + } + + $dotnetArgs = @( + "publish", $path, + "-c", $Configuration, + "-r", $rid, + "--self-contained", + "-p:MsixRevision=$msixRevision" + ) + } else { + $dotnetArgs = @("build", $path, "-c", $Configuration) + # WinUI requires runtime identifier for self-contained WebView2 support + if ($useRid) { + $dotnetArgs += @("-r", $rid) + } } if ($DevBuild -and ($name -eq "WinUI" -or $name -eq "Tray")) { $dotnetArgs += "-p:DevBuild=true" } + if ($packageMsix) { + $platform = if ($rid -eq "win-arm64") { "ARM64" } else { "x64" } + $dotnetArgs += @( + "-p:Platform=$platform", + "-p:PackageMsix=true", + "-p:GenerateAppxPackageOnBuild=true", + "-p:AppxBundle=Never", + "-p:UapAppxPackageBuildMode=SideloadOnly", + "-p:AppxPackageDir=AppPackages\" + ) + } $result = Invoke-DotNetCaptured $dotnetArgs $exitCode = $LASTEXITCODE @@ -420,6 +538,40 @@ $projects = @{ "SetupEngine" = @{ Path = "src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj"; UseRid = $false } } +if ($buildStoreMsix) { + # scripts\Build-Msix.ps1 owns packaging, identity verification, and the + # provenance sidecar. build.ps1 only drives it once per architecture. + $storeArchitectures = $storeMsixRuntimeIdentifiers | ForEach-Object { $_ -replace "^win-", "" } + $storePackages = @() + foreach ($storeArchitecture in $storeArchitectures) { + Write-Host "`nBuilding Store MSIX ($storeArchitecture)..." -ForegroundColor White + try { + & (Join-Path $repoRoot "scripts\Build-Msix.ps1") ` + -Architecture $storeArchitecture ` + -Configuration $Configuration + } catch { + Write-Error "Store MSIX ($storeArchitecture) packaging failed: $($_.Exception.Message)" + exit 1 + } + $storePackages += Join-Path $repoRoot "artifacts\msix\$storeArchitecture\OpenClawCompanion-$storeArchitecture.msix" + } + + Write-Header "Store MSIX Packages" + foreach ($package in $storePackages) { + if (-not (Test-Path -LiteralPath $package)) { + Write-Error "Expected package was not produced: $package" + exit 1 + } + Write-Success $package + } + Write-Host "`nUpload both packages to the same Partner Center submission." -ForegroundColor Cyan + Write-Info "Reserve the app name first, then replace Identity/@Name, Identity/@Publisher," + Write-Info "and Properties/PublisherDisplayName in src\OpenClaw.Tray.WinUI\Package.appxmanifest" + Write-Info "with the values Partner Center assigns." + Write-Host "" + exit 0 +} + $toBuild = if ($Project -eq "All") { @("Shared", "Cli", "WinNodeCli", "SetupEngine", "WinUI") } else { @($Project) } # Always build Shared first if building other projects @@ -431,7 +583,8 @@ for ($i = 0; $i -lt $toBuild.Count; $i++) { $proj = $toBuild[$i] if ($projects.ContainsKey($proj)) { $projInfo = $projects[$proj] - $buildResults[$proj] = Build-Project $proj $projInfo.Path $projInfo.UseRid + $shouldPackageMsix = $buildDevMsix -and ($proj -eq "WinUI" -or $proj -eq "Tray") + $buildResults[$proj] = Build-Project $proj $projInfo.Path $projInfo.UseRid $shouldPackageMsix if ($proj -eq "Shared" -and -not $buildResults[$proj] -and $i -lt ($toBuild.Count - 1)) { Write-Warning "Skipping remaining projects because Shared failed." break @@ -462,6 +615,18 @@ if ($failCount -eq 0) { $winUITargetFramework = Get-ProjectTargetFramework $winUIProjectPath $winUIProjectDirectory = (Split-Path -Parent $winUIProjectPath).Replace("/", "\") + if ($buildDevMsix) { + $devMsixPackage = Get-ChildItem (Join-Path $repoRoot "$winUIProjectDirectory\AppPackages") -Recurse -Filter "*.msix" -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if ($devMsixPackage) { + Write-Host " MSIX: $($devMsixPackage.FullName)" -ForegroundColor White + Write-Host " Install: Add-AppxPackage -Path `"$($devMsixPackage.FullName)`" -ForceApplicationShutdown" -ForegroundColor White + } else { + Write-Warning "MSIX packaging succeeded but no .msix was found under $winUIProjectDirectory\AppPackages." + } + } + if ($winUITargetFramework) { $winUIOutputDirectory = ".\$winUIProjectDirectory\bin\$Configuration\$winUITargetFramework\$rid" $winUIManifestPath = ".\$winUIProjectDirectory\Package.appxmanifest" diff --git a/scripts/Build-Msix.ps1 b/scripts/Build-Msix.ps1 new file mode 100644 index 000000000..e434d787f --- /dev/null +++ b/scripts/Build-Msix.ps1 @@ -0,0 +1,333 @@ +<# +.SYNOPSIS + Builds an unsigned, self-contained Microsoft Store MSIX for one architecture. + +.DESCRIPTION + Publishes OpenClaw.Tray.WinUI into a temporary work directory, then copies + the resulting package to a deterministically named artifact and verifies it + before returning. + + The package is left unsigned because Partner Center signs Store + submissions. Local development packages are produced instead by + build.ps1 -Msix Dev, which uses the certificate from + scripts\setup-dev-msix-cert.ps1 and a separate side-by-side identity. + + src\OpenClaw.Tray.WinUI\Package.appxmanifest is the single source of truth + for the release identity. The build fails when the produced package drifts + from it, when the version does not end in .0, when more than one package is + produced, or when required content is missing or forbidden content is + present. + + An msix-metadata.json sidecar records the source commit, whether the tree + was dirty, the package version, publisher, and the package SHA-256. + +.PARAMETER Architecture + Target architecture: x64 or arm64. Defaults to x64. The Store serves a + separate package per architecture; upload both to one submission. + +.PARAMETER Configuration + Build configuration: Debug or Release. Defaults to Release. Store + certification does not accept Debug binaries. + +.PARAMETER OutputDirectory + Where to place the package and its metadata sidecar. Relative paths resolve + against the repository root. Defaults to artifacts\msix\, + which is cleaned on each run. A caller-supplied directory is never deleted; + the build fails if it already exists and is not empty. + +.EXAMPLE + .\scripts\Build-Msix.ps1 -Architecture x64 + .\scripts\Build-Msix.ps1 -Architecture arm64 + .\build.ps1 -Project WinUI -Msix Store +#> +[CmdletBinding()] +param( + [ValidateSet('x64', 'arm64')] + [string]$Architecture = 'x64', + + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', + + [string]$OutputDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# $IsWindows only exists in PowerShell Core, and strict mode turns a bare read +# into a terminating error under Windows PowerShell 5.1. +$isWindowsVariable = Get-Variable -Name IsWindows -ErrorAction SilentlyContinue +$runningOnWindows = if ($isWindowsVariable) { + [bool]$isWindowsVariable.Value +} +else { + [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT +} +if (-not $runningOnWindows) { + throw 'MSIX packaging requires Windows.' +} + +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$projectDirectory = Join-Path $repositoryRoot 'src\OpenClaw.Tray.WinUI' +$projectPath = Join-Path $projectDirectory 'OpenClaw.Tray.WinUI.csproj' +$sourceManifestPath = Join-Path $projectDirectory 'Package.appxmanifest' + +function Invoke-CheckedCommand { + param( + [Parameter(Mandatory)] + [scriptblock]$Command, + + [Parameter(Mandatory)] + [string]$FailureMessage + ) + + & $Command + if ($LASTEXITCODE -ne 0) { + throw "$FailureMessage Exit code: $LASTEXITCODE." + } +} + +function Remove-DirectoryIfPresent { + param( + [Parameter(Mandatory)] + [string]$Path + ) + + if ([IO.Directory]::Exists($Path)) { + [IO.Directory]::Delete($Path, $true) + } +} + +function Test-PackageVersion { + param( + [Parameter(Mandatory)] + [string]$Version + ) + + $segments = @($Version.Split('.')) + if ($segments.Count -ne 4) { + throw "MSIX package version must contain four numeric components: $Version" + } + + foreach ($segment in $segments) { + [uint16]$value = 0 + if (-not [uint16]::TryParse($segment, [ref]$value)) { + throw "Invalid MSIX package version component: $segment" + } + } + + # Partner Center and the Store reserve the revision component. + if ($segments[3] -ne '0') { + throw "MSIX package version must end in .0 for release packages: $Version" + } +} + +# The tracked manifest is the single source of truth for the release identity. +# A packaged build that drifts from it is a packaging bug, not a new identity. +[xml]$sourceManifest = Get-Content -LiteralPath $sourceManifestPath -Raw +$expectedIdentityName = [string]$sourceManifest.Package.Identity.Name +$expectedPublisher = [string]$sourceManifest.Package.Identity.Publisher +if ( + [string]::IsNullOrWhiteSpace($expectedIdentityName) -or + [string]::IsNullOrWhiteSpace($expectedPublisher) +) { + throw "Could not read the release identity from $sourceManifestPath." +} + +if ($OutputDirectory) { + # Never recursively delete a caller-supplied path; it may hold unrelated files. + $OutputDirectory = [IO.Path]::GetFullPath( + [IO.Path]::Combine($repositoryRoot, $OutputDirectory)) + if ([IO.Directory]::Exists($OutputDirectory) -and + @(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0) { + throw ( + "The output directory already exists and is not empty: $OutputDirectory. " + + 'Choose another -OutputDirectory or remove it first.' + ) + } +} +else { + # The default location is script-owned, so a stale package is cleared here + # rather than being mistaken for the current build. + $OutputDirectory = Join-Path $repositoryRoot "artifacts\msix\$Architecture" + Remove-DirectoryIfPresent -Path $OutputDirectory +} +New-Item -Path $OutputDirectory -ItemType Directory -Force | Out-Null +$OutputDirectory = (Resolve-Path -LiteralPath $OutputDirectory).Path + +$temporaryRoot = if ($env:RUNNER_TEMP) { + $env:RUNNER_TEMP +} +else { + [IO.Path]::GetTempPath() +} +$workRoot = Join-Path ` + $temporaryRoot ` + "openclaw-companion-msix-$Architecture-$([guid]::NewGuid().ToString('N'))" +$msixBuildDirectory = Join-Path $workRoot 'appx' +New-Item -Path $msixBuildDirectory -ItemType Directory -Force | Out-Null + +$platform = if ($Architecture -eq 'arm64') { 'ARM64' } else { 'x64' } + +try { + $appxOutput = $msixBuildDirectory.TrimEnd('\') + '\' + Write-Host "Building unsigned win-$Architecture MSIX with MSBuild." + Invoke-CheckedCommand ` + -FailureMessage "MSIX build failed for $Architecture." ` + -Command { + & dotnet publish $projectPath ` + --configuration $Configuration ` + --runtime "win-$Architecture" ` + --self-contained ` + "-p:Platform=$platform" ` + -p:PackageMsix=true ` + -p:GenerateAppxPackageOnBuild=true ` + -p:AppxBundle=Never ` + -p:UapAppxPackageBuildMode=SideloadOnly ` + -p:AppxPackageSigningEnabled=false ` + "-p:AppxPackageDir=$appxOutput" ` + --nologo + } + + $builtPackages = @( + Get-ChildItem ` + -LiteralPath $msixBuildDirectory ` + -Filter '*.msix' ` + -File ` + -Recurse + ) + if ($builtPackages.Count -ne 1) { + throw ( + "Expected one MSIX under '$msixBuildDirectory'; " + + "found $($builtPackages.Count)." + ) + } + + $msixName = "OpenClawCompanion-$Architecture.msix" + $msixPath = Join-Path $OutputDirectory $msixName + Copy-Item -LiteralPath $builtPackages[0].FullName -Destination $msixPath -Force + + $requiredEntries = @( + 'OpenClaw.Tray.WinUI.exe', + 'AppxManifest.xml', + 'coreclr.dll', + 'hostfxr.dll', + 'hostpolicy.dll', + 'System.Private.CoreLib.dll', + 'Microsoft.ui.xaml.dll', + 'OpenClaw.SetupEngine.dll', + 'OpenClaw.SetupEngine.UI.dll', + "tools/mxc/$Architecture/wxc-exec.exe" + ) + # The MSIX resolves the CRT through the VCLibs framework dependency, and + # Partner Center rejects a package that is already signed. + $forbiddenEntries = @( + 'AppxSignature.p7x', + 'vcruntime140.dll', + 'vcruntime140_1.dll', + 'msvcp140.dll', + 'msvcp140_1.dll' + ) + + $packageEntries = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase + ) + Add-Type -AssemblyName System.IO.Compression.FileSystem + $packageArchive = [System.IO.Compression.ZipFile]::OpenRead($msixPath) + try { + foreach ($entry in $packageArchive.Entries) { + if ([string]::IsNullOrEmpty($entry.Name)) { + continue + } + + $null = $packageEntries.Add([Uri]::UnescapeDataString($entry.FullName)) + } + + $manifestEntry = $packageArchive.Entries | + Where-Object { $_.FullName -eq 'AppxManifest.xml' } | + Select-Object -First 1 + if ($null -eq $manifestEntry) { + throw 'The MSIX does not contain AppxManifest.xml.' + } + + $manifestReader = New-Object System.IO.StreamReader($manifestEntry.Open()) + try { + [xml]$packagedManifest = $manifestReader.ReadToEnd() + } + finally { + $manifestReader.Dispose() + } + } + finally { + $packageArchive.Dispose() + } + + foreach ($requiredEntry in $requiredEntries) { + if (-not $packageEntries.Contains($requiredEntry)) { + throw "The MSIX is missing required content: $requiredEntry" + } + } + foreach ($forbiddenEntry in $forbiddenEntries) { + if ($packageEntries.Contains($forbiddenEntry)) { + throw "The MSIX contains forbidden content: $forbiddenEntry" + } + } + + $packagedIdentity = $packagedManifest.Package.Identity + $packageVersion = [string]$packagedIdentity.Version + Test-PackageVersion -Version $packageVersion + + if ([string]$packagedIdentity.Name -ne $expectedIdentityName) { + throw ( + "The MSIX identity is '$($packagedIdentity.Name)' but " + + "$sourceManifestPath declares '$expectedIdentityName'." + ) + } + if ([string]$packagedIdentity.Publisher -ne $expectedPublisher) { + throw ( + "The MSIX publisher is '$($packagedIdentity.Publisher)' but " + + "$sourceManifestPath declares '$expectedPublisher'." + ) + } + if ([string]$packagedIdentity.ProcessorArchitecture -ne $Architecture) { + throw ( + "The MSIX targets '$($packagedIdentity.ProcessorArchitecture)' " + + "but $Architecture was requested." + ) + } + + $sourceCommit = (& git -C $repositoryRoot rev-parse HEAD) -join '' + if ($LASTEXITCODE -ne 0 -or $sourceCommit -notmatch '^[0-9a-fA-F]{40}$') { + throw 'Unable to resolve the current source commit.' + } + $sourceTreeDirty = [bool](& git -C $repositoryRoot status --porcelain) + if ($LASTEXITCODE -ne 0) { + throw 'Unable to inspect the current source tree.' + } + + $msixHash = ( + Get-FileHash -LiteralPath $msixPath -Algorithm SHA256 + ).Hash.ToLowerInvariant() + [ordered]@{ + repository = 'https://github.com/openclaw/openclaw-windows-node' + sourceCommit = $sourceCommit.ToLowerInvariant() + sourceTreeDirty = $sourceTreeDirty + architecture = $Architecture + configuration = $Configuration + archive = $msixName + sha256 = $msixHash + signed = $false + identityName = $expectedIdentityName + packageVersion = $packageVersion + publisher = $expectedPublisher + } | ConvertTo-Json | + Set-Content ` + -LiteralPath (Join-Path $OutputDirectory 'msix-metadata.json') ` + -Encoding utf8 + + Write-Host "Created unsigned MSIX: $msixPath" + Write-Host " Identity: $expectedIdentityName $packageVersion $Architecture" +} +finally { + Remove-DirectoryIfPresent -Path $workRoot +} diff --git a/scripts/setup-dev-msix-cert.ps1 b/scripts/setup-dev-msix-cert.ps1 new file mode 100644 index 000000000..319159146 --- /dev/null +++ b/scripts/setup-dev-msix-cert.ps1 @@ -0,0 +1,158 @@ +<# +.SYNOPSIS + Provisions or removes the local development MSIX signing certificate. + +.DESCRIPTION + Creates a non-exportable current-user code-signing certificate whose + subject matches the generated development manifest, trusts its public + certificate for local package installation, and stores only its thumbprint + under %LOCALAPPDATA%\OpenClawDevelopment\MSIX. + + The certificate is development-only. Microsoft Store submissions use the + Partner Center identity and signing process instead. +#> +[CmdletBinding()] +param( + [switch]$Force, + [switch]$Remove, + [switch]$SkipTrust +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$projectPath = Join-Path $repoRoot "src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj" +$certificateDirectory = Join-Path $env:LOCALAPPDATA "OpenClawDevelopment\MSIX" +$thumbprintPath = Join-Path $certificateDirectory "dev-msix-thumbprint.txt" +$legacyPfxPath = Join-Path $env:LOCALAPPDATA "OpenClawTray\dev-msix.pfx" +$friendlyName = "OpenClaw Development MSIX Signing" +$codeSigningOid = "1.3.6.1.5.5.7.3.3" + +[xml]$project = Get-Content -LiteralPath $projectPath +$publisherNode = $project.SelectSingleNode("/Project/PropertyGroup/OpenClawDevMsixPublisher") +$publisher = if ($null -ne $publisherNode) { $publisherNode.InnerText } else { $null } +if ([string]::IsNullOrWhiteSpace($publisher)) { + throw "OpenClawDevMsixPublisher is missing from $projectPath" +} + +function Assert-CanModifyMachineTrust { + $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "Run this script from an elevated PowerShell to modify LocalMachine\TrustedPeople, or pass -SkipTrust." + } +} + +function Remove-CertificateAndTrust($certificate) { + if (-not $SkipTrust) { + Assert-CanModifyMachineTrust + $trusted = Get-ChildItem Cert:\LocalMachine\TrustedPeople -ErrorAction SilentlyContinue | + Where-Object Thumbprint -eq $certificate.Thumbprint + foreach ($trustedCertificate in $trusted) { + Remove-Item "Cert:\LocalMachine\TrustedPeople\$($trustedCertificate.Thumbprint)" -Force + } + } + + Remove-Item "Cert:\CurrentUser\My\$($certificate.Thumbprint)" -Force +} + +$existing = Get-ChildItem Cert:\CurrentUser\My | + Where-Object { + $_.FriendlyName -eq $friendlyName -and + $_.HasPrivateKey -and + ($_.EnhancedKeyUsageList | ForEach-Object { $_.ObjectId }) -contains $codeSigningOid + } + +$legacyThumbprint = $null +if (Test-Path -LiteralPath $legacyPfxPath) { + try { + $legacyCertificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + $legacyPfxPath, + "openclaw-dev") + $legacyThumbprint = $legacyCertificate.Thumbprint + $legacyCertificate.Dispose() + } catch { + Write-Warning "Could not inspect the legacy development PFX: $($_.Exception.Message)" + } +} + +if ($Remove) { + foreach ($certificate in $existing) { + Remove-CertificateAndTrust $certificate + } + if ($legacyThumbprint) { + Remove-Item "Cert:\CurrentUser\My\$legacyThumbprint" -Force -ErrorAction SilentlyContinue + if (-not $SkipTrust) { + Assert-CanModifyMachineTrust + Remove-Item "Cert:\LocalMachine\TrustedPeople\$legacyThumbprint" -Force -ErrorAction SilentlyContinue + } + } + Remove-Item -LiteralPath $thumbprintPath -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $legacyPfxPath -Force -ErrorAction SilentlyContinue + Write-Host "Development MSIX certificate and local trust removed." + exit 0 +} + +if ($Force) { + foreach ($certificate in $existing) { + Remove-CertificateAndTrust $certificate + } + if ($legacyThumbprint) { + Remove-Item "Cert:\CurrentUser\My\$legacyThumbprint" -Force -ErrorAction SilentlyContinue + if (-not $SkipTrust) { + Assert-CanModifyMachineTrust + Remove-Item "Cert:\LocalMachine\TrustedPeople\$legacyThumbprint" -Force -ErrorAction SilentlyContinue + } + } + $existing = @() +} + +$certificate = $existing | + Where-Object { + $_.Subject -eq $publisher -and + $_.NotAfter -gt (Get-Date) + } | + Sort-Object NotAfter -Descending | + Select-Object -First 1 + +if (-not $certificate) { + $certificate = New-SelfSignedCertificate ` + -Type CodeSigningCert ` + -Subject $publisher ` + -KeyUsage DigitalSignature ` + -KeyAlgorithm RSA ` + -KeyLength 2048 ` + -KeyExportPolicy NonExportable ` + -HashAlgorithm SHA256 ` + -NotAfter (Get-Date).AddYears(3) ` + -FriendlyName $friendlyName ` + -CertStoreLocation "Cert:\CurrentUser\My" ` + -TextExtension @("2.5.29.37={text}$codeSigningOid", "2.5.29.19={text}") +} + +if (-not $SkipTrust) { + Assert-CanModifyMachineTrust + $trusted = Get-ChildItem Cert:\LocalMachine\TrustedPeople -ErrorAction SilentlyContinue | + Where-Object Thumbprint -eq $certificate.Thumbprint + if (-not $trusted) { + $cerPath = Join-Path $env:TEMP "openclaw-dev-msix-$($certificate.Thumbprint).cer" + try { + Export-Certificate -Cert $certificate -FilePath $cerPath | Out-Null + Import-Certificate -FilePath $cerPath -CertStoreLocation "Cert:\LocalMachine\TrustedPeople" | Out-Null + } finally { + Remove-Item -LiteralPath $cerPath -Force -ErrorAction SilentlyContinue + } + } +} + +New-Item -ItemType Directory -Path $certificateDirectory -Force | Out-Null +Set-Content -LiteralPath $thumbprintPath -Value $certificate.Thumbprint -Encoding ASCII +Remove-Item -LiteralPath $legacyPfxPath -Force -ErrorAction SilentlyContinue + +Write-Host "Development MSIX certificate ready." +Write-Host "Subject: $($certificate.Subject)" +Write-Host "Thumbprint: $($certificate.Thumbprint)" +Write-Host "Reference: $thumbprintPath" +Write-Host "" +Write-Host "Build with:" +Write-Host " .\build.ps1 -Project WinUI -Msix Dev" diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 8d810a798..97a205268 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -12,6 +12,9 @@ all + + + true diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index a8d3d39bf..0808142ce 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -59,7 +59,15 @@ MSIX true - false + CN=OpenClaw Local Development + $(LOCALAPPDATA)\OpenClawDevelopment\MSIX + $(OpenClawDevMsixCertificateDirectory)\dev-msix-thumbprint.txt + $([System.IO.File]::ReadAllText('$(OpenClawDevMsixThumbprintFile)').Trim()) + + true + false + $(OpenClawDevMsixCertificateThumbprint) true Never SideloadOnly @@ -133,8 +141,10 @@ + + @@ -164,6 +174,15 @@ } text = nameRegex.Replace(text, "${1}" + IdentityName + "$2", 1); + var publisherRegex = new System.Text.RegularExpressions.Regex( + "(]*?\\bPublisher\\s*=\\s*[\"'])[^\"']+([\"'])", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (!publisherRegex.IsMatch(text)) { + Log.LogError("GenerateOpenClawAppxManifest: Identity/@Publisher missing from " + SourceManifestPath); + return false; + } + text = publisherRegex.Replace(text, "${1}" + Publisher + "$2", 1); + var propsDisplayRegex = new System.Text.RegularExpressions.Regex( "([\\s\\S]*?)[^<]+()", System.Text.RegularExpressions.RegexOptions.IgnoreCase); @@ -190,6 +209,19 @@ return false; } text = protocolRegex.Replace(text, "${1}" + ProtocolName + "$2", 1); + + var toastClsidRegex = new System.Text.RegularExpressions.Regex( + "(]*?\\bToastActivatorCLSID\\s*=\\s*[\"'])[^\"']+([\"'])", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + var comClassRegex = new System.Text.RegularExpressions.Regex( + "(]*?\\bId\\s*=\\s*[\"'])[^\"']+([\"'])", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (!toastClsidRegex.IsMatch(text) || !comClassRegex.IsMatch(text)) { + Log.LogError("GenerateOpenClawAppxManifest: toast activation CLSID declarations missing from " + SourceManifestPath); + return false; + } + text = toastClsidRegex.Replace(text, "${1}" + ToastActivatorClsid + "$2", 1); + text = comClassRegex.Replace(text, "${1}" + ToastActivatorClsid + "$2", 1); } System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(OutputManifestPath)); @@ -203,25 +235,35 @@ <_StrippedVersion>$([System.Text.RegularExpressions.Regex]::Replace('$(Version)', '[-+].*$', '')) <_VersionDotCount>$([System.Text.RegularExpressions.Regex]::Matches('$(_StrippedVersion)', '\.').Count) - <_AppxManifestVersion Condition="'$(_VersionDotCount)' == '3'">$(_StrippedVersion) - <_AppxManifestVersion Condition="'$(_VersionDotCount)' == '2'">$(_StrippedVersion).0 - <_AppxManifestVersion Condition="'$(_VersionDotCount)' == '1'">$(_StrippedVersion).0.0 + <_AppxBaseVersion Condition="'$(_VersionDotCount)' == '3'">$([System.Text.RegularExpressions.Regex]::Replace('$(_StrippedVersion)', '\.\d+$', '')) + <_AppxBaseVersion Condition="'$(_VersionDotCount)' == '2'">$(_StrippedVersion) + <_AppxBaseVersion Condition="'$(_VersionDotCount)' == '1'">$(_StrippedVersion).0 + <_AppxRevision Condition="'$(DevBuild)' == 'true' and '$(MsixRevision)' != ''">$(MsixRevision) + <_AppxRevision Condition="'$(DevBuild)' == 'true' and '$(_AppxRevision)' == '' and '$(GitVersion_CommitsSinceVersionSource)' != ''">$(GitVersion_CommitsSinceVersionSource) + <_AppxRevision Condition="'$(DevBuild)' == 'true' and '$(_AppxRevision)' == ''">1 + <_AppxRevision Condition="'$(DevBuild)' != 'true'">0 + <_AppxManifestVersion>$(_AppxBaseVersion).$(_AppxRevision) <_GeneratedAppxManifestPath>$(IntermediateOutputPath)openclaw.Package.appxmanifest - + + + + ProtocolName="openclaw-dev" + ToastActivatorClsid="C536D4AD-19BE-4F7A-B227-AB97629BF299" /> @@ -294,6 +336,22 @@ + + + + <_WxcExecPackageFiles Include="$(MxcSdkBinDir)wxc-exec.exe" /> + <_WxcExecPackageFiles Include="$(MxcSdkBinDir)*.dll" /> + + tools\mxc\$(MxcArch)\%(Filename)%(Extension) + PreserveNewest + + + + + + + + + + + + + + + + + + + + From a224ef9287c4dd72b2dbec651479691b266aceeb Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Thu, 3 Sep 2026 16:04:51 -0700 Subject: [PATCH 02/19] feat(tray): use packaged StartupTask for autostart when running as MSIX The existing autostart path writes a scheduled task or a Run registry value. Neither is appropriate inside an MSIX package: the package is the owner of its own startup registration, and Windows exposes that to the user in Settings > Apps > Startup, where a user toggle can override the app's request. AutoStartManager now detects package identity and, when packaged, drives the StartupTask declared in Package.appxmanifest instead of the Win32 mechanisms. The Win32 path is untouched for unpackaged installs. Because the user's Settings toggle wins over the app's request, the settings view model reverts its checkbox to the effective state rather than reporting success when Windows declines to enable the task, so the UI cannot claim autostart is on while it is disabled by policy or by the user. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 22 ++++- src/OpenClaw.Tray.WinUI/AppIdentity.cs | 6 ++ .../Presentation/SettingsPageViewModel.cs | 11 +++ .../Services/AutoStartManager.cs | 96 ++++++++++++++++++- .../SettingsPageViewModelTests.cs | 4 +- 5 files changed, 133 insertions(+), 6 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 59a7ee0b5..f3457d36c 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -220,6 +220,7 @@ public IntPtr GetHubWindowHandle() => private string? _lastManagerConnectedSideEffectsKey; private SettingsWriteOrigin? _trayPermissionWriteOrigin; private SettingsWriteOrigin? _appCapabilityPermissionWriteOrigin; + private SettingsWriteOrigin? _trayAutoStartWriteOrigin; // FrozenDictionary for O(1) case-insensitive notification type → setting lookup — no per-call allocation. private static readonly System.Collections.Frozen.FrozenDictionary> s_notifTypeMap = @@ -4001,9 +4002,11 @@ private void ToggleAutoStart() => private async Task ToggleAutoStartAsync() { if (_settings == null) return; - _settings.AutoStart = !_settings.AutoStart; - _settings.Save(); - await AutoStartManager.SetAutoStartAsync(_settings.AutoStart); + + var origin = SettingsStore is { } store + ? GetOrCreateSettingsWriteOrigin(ref _trayAutoStartWriteOrigin, store) + : null; + await ApplyAutoStartCore(origin, !_settings.AutoStart); } /// @@ -4014,6 +4017,9 @@ private async Task ToggleAutoStartAsync() /// triggering view model ignores its own change event. /// public async Task ApplyAutoStart(SettingsWriteOrigin origin, bool autoStart) + => await ApplyAutoStartCore(origin, autoStart); + + private async Task ApplyAutoStartCore(SettingsWriteOrigin? origin, bool autoStart) { if (_settings == null) return false; try @@ -4035,6 +4041,16 @@ public async Task ApplyAutoStart(SettingsWriteOrigin origin, bool autoStar catch (Exception ex) { Logger.Error($"ApplyAutoStart failed: {ex.Message}"); + var effectiveAutoStart = await AutoStartManager.IsAutoStartEnabledAsync(); + if (SettingsStore is { } store) + { + store.Update(origin, edit => edit.AutoStart = effectiveAutoStart); + } + else if (_settings != null) + { + _settings.AutoStart = effectiveAutoStart; + _settings.Save(); + } return false; } } diff --git a/src/OpenClaw.Tray.WinUI/AppIdentity.cs b/src/OpenClaw.Tray.WinUI/AppIdentity.cs index f009f5374..8e53dae5b 100644 --- a/src/OpenClaw.Tray.WinUI/AppIdentity.cs +++ b/src/OpenClaw.Tray.WinUI/AppIdentity.cs @@ -25,6 +25,9 @@ internal static class AppIdentity /// Windows scheduled task name (must differ so both can auto-start). public const string StartupTaskName = "OpenClaw Companion (Dev)"; + /// MSIX manifest startup task identifier. + public const string PackageStartupTaskId = "OpenClawStartup"; + /// Leaf directory for local and roaming app-owned data. public const string DataDirectoryName = "OpenClawTray-Dev"; @@ -64,6 +67,9 @@ internal static class AppIdentity /// Windows scheduled task name. public const string StartupTaskName = "OpenClaw Companion"; + /// MSIX manifest startup task identifier. + public const string PackageStartupTaskId = "OpenClawStartup"; + /// Leaf directory for local and roaming app-owned data. public const string DataDirectoryName = "OpenClawTray"; diff --git a/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs b/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs index 8c4a941b7..262af55e0 100644 --- a/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs +++ b/src/OpenClaw.Tray.WinUI/Presentation/SettingsPageViewModel.cs @@ -101,6 +101,17 @@ private async Task ApplyAutoStartAsync(bool value) if (await _appCommands.ApplyAutoStart(_origin, value)) { RaiseSaved(); + return; + } + + _loading = true; + try + { + SetField(ref _autoStart, _store.Current.AutoStart, nameof(AutoStart)); + } + finally + { + _loading = false; } } diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs index 867b32b2c..7744e6e84 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs @@ -1,7 +1,9 @@ using Microsoft.Win32; using OpenClaw.Shared; +using OpenClawTray.Helpers; using System; using System.Threading.Tasks; +using Windows.ApplicationModel; namespace OpenClawTray.Services; @@ -12,9 +14,17 @@ public static class AutoStartManager { private const string RegistryKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; private static readonly string AppName = AppIdentity.AutoStartRegistryName; + // Deliberately no legacy-autostart cleanup here. The scheduled task named + // AppIdentity.StartupTaskName is created by installer.iss and removed by the Inno + // uninstaller, so deleting it from the packaged app would silently disable a legacy + // install the user has not agreed to replace. Detecting a legacy install, obtaining + // consent, and removing its registrations belong to a migration flow that asks first. public static bool IsAutoStartEnabled() { + if (PackageHelper.IsPackaged) + return IsPackagedAutoStartEnabled(); + try { using var key = Registry.CurrentUser.OpenSubKey(RegistryKey, false); @@ -29,6 +39,27 @@ public static bool IsAutoStartEnabled() } public static void SetAutoStart(bool enable) + { + if (PackageHelper.IsPackaged) + { + SetPackagedAutoStartAsync(enable).GetAwaiter().GetResult(); + return; + } + + SetUnpackagedAutoStart(enable); + } + + public static Task SetAutoStartAsync(bool enable) => + PackageHelper.IsPackaged + ? SetPackagedAutoStartAsync(enable) + : Task.Run(() => SetUnpackagedAutoStart(enable)); + + public static Task IsAutoStartEnabledAsync() => + PackageHelper.IsPackaged + ? IsPackagedAutoStartEnabledAsync() + : Task.Run(IsAutoStartEnabled); + + private static void SetUnpackagedAutoStart(bool enable) { try { @@ -65,8 +96,69 @@ public static void SetAutoStart(bool enable) } } - public static Task SetAutoStartAsync(bool enable) => - Task.Run(() => SetAutoStart(enable)); + private static bool IsPackagedAutoStartEnabled() + { + try + { + var startupTask = StartupTask.GetAsync(AppIdentity.PackageStartupTaskId) + .AsTask() + .GetAwaiter() + .GetResult(); + return startupTask.State is StartupTaskState.Enabled or StartupTaskState.EnabledByPolicy; + } + catch (Exception ex) + { + Logger.Warn($"Failed to query packaged auto-start: {ex.Message}"); + return false; + } + } + + private static async Task IsPackagedAutoStartEnabledAsync() + { + try + { + var startupTask = await StartupTask.GetAsync(AppIdentity.PackageStartupTaskId); + return startupTask.State is StartupTaskState.Enabled or StartupTaskState.EnabledByPolicy; + } + catch (Exception ex) + { + Logger.Warn($"Failed to query packaged auto-start: {ex.Message}"); + return false; + } + } + + private static async Task SetPackagedAutoStartAsync(bool enable) + { + var startupTask = await StartupTask.GetAsync(AppIdentity.PackageStartupTaskId); + if (!enable) + { + startupTask.Disable(); + Logger.Info("Packaged auto-start disabled"); + return; + } + + if (startupTask.State is StartupTaskState.Enabled or StartupTaskState.EnabledByPolicy) + { + Logger.Info("Packaged auto-start already enabled"); + return; + } + + var state = await startupTask.RequestEnableAsync(); + if (state is StartupTaskState.Enabled or StartupTaskState.EnabledByPolicy) + { + Logger.Info("Packaged auto-start enabled"); + return; + } + + throw new InvalidOperationException(state switch + { + StartupTaskState.DisabledByUser => + "Windows startup is disabled by the user. Re-enable OpenClaw Companion in Settings > Apps > Startup.", + StartupTaskState.DisabledByPolicy => + "Windows startup is disabled by policy.", + _ => $"Windows did not enable the packaged startup task (state: {state})." + }); + } private static void DeleteRunKey() { diff --git a/tests/OpenClaw.Tray.Tests/Presentation/SettingsPageViewModelTests.cs b/tests/OpenClaw.Tray.Tests/Presentation/SettingsPageViewModelTests.cs index 3b347a4f2..115427d29 100644 --- a/tests/OpenClaw.Tray.Tests/Presentation/SettingsPageViewModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/Presentation/SettingsPageViewModelTests.cs @@ -136,7 +136,7 @@ public void AutoStart_AppliedThroughAppCommand_FlashesSavedOnlyOnSuccess() [Fact] public void AutoStart_OsWriteFailure_DoesNotFlashSaved() { - var vm = NewVm(out _, out var appCommands, out _, out var temp); + var vm = NewVm(out var settings, out var appCommands, out _, out var temp); using (temp) { appCommands.AutoStartResult = false; // simulate the OS registration failing @@ -144,10 +144,12 @@ public void AutoStart_OsWriteFailure_DoesNotFlashSaved() var savedFlashes = 0; vm.SavedIndicated += (_, _) => savedFlashes++; + var effectiveValue = settings.AutoStart; vm.AutoStart = !vm.AutoStart; Assert.Equal(1, appCommands.AutoStartApplyCount); Assert.Equal(0, savedFlashes); // no confirmation when the apply reports failure + Assert.Equal(effectiveValue, vm.AutoStart); } } From 0b8111c1d5bb33d9b4d6bef17e7514c43fd641b4 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Thu, 3 Sep 2026 16:05:15 -0700 Subject: [PATCH 03/19] test(msix): add packaging contract tests and document MSIX channels MsixDevelopmentSigningTests asserts the packaging contracts as source text, so a future edit to build.ps1, the csproj, the manifest, or either packaging script cannot silently break the channel split without a red test. Notably it pins the -Msix ValidateSet, the presence of [CmdletBinding()], and that no certificate password or PFX export is introduced. DEVELOPMENT.md documents both channels, when to use each, and why the Store revision must be 0 while the dev revision must increase. It also states plainly that the dev revision tracks the installed package rather than incrementing per build, since rebuilding without installing reuses the same revision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DEVELOPMENT.md | 108 ++++++++++ .../MsixDevelopmentSigningTests.cs | 203 ++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100644 tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7af709a26..02c50d8ec 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -208,6 +208,114 @@ Use the local helper to build unsigned installer EXEs without waiting for CI: `-Fast` uses ZIP/no-solid compression for quick local iteration. CI release builds keep the default LZMA solid compression and Azure signing. +#### Local development MSIX + +The development MSIX is opt-in and does not replace the Inno or Updatum +release paths. Create and trust its local signing certificate once from an +elevated PowerShell: + +```powershell +.\scripts\setup-dev-msix-cert.ps1 +``` + +Then build the signed package: + +```powershell +.\build.ps1 -Project WinUI -Msix Dev +``` + +`-Msix Dev` implies `-DevBuild`, uses the side-by-side development package +identity, publishes the .NET runtime self-contained, advances the installed +development package revision, and prints the generated package path. The +development certificate has a distinct local-only publisher and a +non-exportable private key; only its thumbprint is stored under +`%LOCALAPPDATA%\OpenClawDevelopment\MSIX`. Future Microsoft Store submissions +use the Partner Center identity and signing process instead. + +The development machine must also have +`Microsoft.VCLibs.140.00.UWPDesktop` version `14.0.33728.0` or newer installed. +Microsoft Store distribution resolves this framework dependency automatically; +direct `Add-AppxPackage` sideloading requires it to be installed first. + +Uninstall an existing Inno build before switching that identity to MSIX. The +Inno uninstaller removes its host `HKCU\...\Run` value; MSIX virtualizes HKCU +writes and cannot remove that host value without a restricted capability that +is inappropriate for the Store package. The packaged app deliberately leaves the +legacy scheduled task in place: deleting it would silently disable an Inno +install the user has not agreed to replace, so that cleanup belongs to a +migration flow that asks first. +Packaged builds register launch-at-login through the manifest +`windows.startupTask` extension and the Windows `StartupTask` API. Unpackaged +Inno builds retain the existing scheduled-task and registry fallback until that +installer path is retired. + +Remove the development certificate and machine trust when it is no longer +needed: + +```powershell +.\scripts\setup-dev-msix-cert.ps1 -Remove +``` + +#### Microsoft Store packages + +Store submissions use the release identity and are signed by Partner Center, so +they share no state with the development certificate above: + +```powershell +.\build.ps1 -Project WinUI -Msix Store +``` + +The two `-Msix` modes are mutually exclusive because they produce different +applications rather than two flavors of one. They install side by side, which +is what lets a packaged smoke test run without disturbing a working install: + +| | `-Msix Dev` | `-Msix Store` | +| --- | --- | --- | +| Identity | `OpenClaw.Companion.Dev` | `OpenClaw.Companion` | +| Publisher | local development certificate | Partner Center | +| Protocol | `openclaw-dev` | `openclaw` | +| Signing | signed locally | unsigned; the Store signs | +| Version revision | installed revision + 1 | pinned to `0` | +| Architectures | host only | x64 and ARM64 | + +The revision field is the clearest reason the modes cannot merge, because each +needs the opposite value. `Add-AppxPackage` only installs over an existing +package when the version increases, and GitVersion holds major/minor/build +steady across rebuilds of one commit, so a development build derives its +revision from the installed development package and adds one. Rebuilding +without installing in between reuses the same revision, which is why an +uninstalled package must be installed before the next revision advances. +Partner Center rejects any submission whose revision is non-zero. + +`-Msix Store` forces `-Configuration Release`, refuses to combine with +`-DevBuild`, and delegates to `scripts\Build-Msix.ps1` once +per architecture. Each run produces one unsigned self-contained package at +`artifacts\msix\\OpenClawCompanion-.msix` alongside an +`msix-metadata.json` provenance sidecar recording the source commit, whether +the tree was dirty, the package version, publisher, and the package SHA-256. + +`scripts\Build-Msix.ps1` fails the build when the produced package drifts from +`Package.appxmanifest`: the identity name, publisher, and processor +architecture must match, the version must be four `uint16` components ending in +`.0` because Partner Center reserves the revision field, exactly one `.msix` +must be produced, required content must be present (the app host, the .NET +runtime, the in-process SetupEngine UI, and the architecture-matched +`wxc-exec.exe`), and forbidden content must be absent (`AppxSignature.p7x` and +the loose Visual C++ runtime files that the Inno payload ships but the MSIX +resolves through its VCLibs framework dependency). + +Upload both `.msix` files to the same Partner Center submission. Before the +first submission, reserve the app name and replace `Identity/@Name`, +`Identity/@Publisher`, and `Properties/PublisherDisplayName` in +`src\OpenClaw.Tray.WinUI\Package.appxmanifest` with the values Partner Center +assigns. The submission also needs a justification for the `runFullTrust` +restricted capability and a stated reason plus privacy policy for the declared +`webcam`, `microphone`, and `location` device capabilities. + +Generating the optional `.appxsym` symbol package additionally requires +`mspdbcmf.exe` from the Visual Studio **Desktop development with C++** workload; +without it the build logs a warning and skips symbols. + #### Dev identity and side-by-side installs Release identity is the default for every configuration. Use `-DevBuild` on `build.ps1` or `-Dev` on `run-app-local.ps1` when you explicitly want the side-by-side dev identity: diff --git a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs new file mode 100644 index 000000000..49b743146 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs @@ -0,0 +1,203 @@ +namespace OpenClaw.Tray.Tests; + +public sealed class MsixDevelopmentSigningTests +{ + [Fact] + public void DevelopmentMsixSigning_IsLocalOnlyAndStoreBuildsRemainUnsigned() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var project = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.csproj")); + var script = File.ReadAllText(Path.Combine( + root, "scripts", "setup-dev-msix-cert.ps1")); + + Assert.Contains("CN=OpenClaw Local Development", project); + Assert.Contains(@"$(LOCALAPPDATA)\OpenClawDevelopment\MSIX", project); + Assert.Contains("'$(DevBuild)' == 'true'", project); + Assert.Contains("", project); + Assert.Contains(@"tools\mxc\$(MxcArch)\%(Filename)%(Extension)", project); + + // The VC runtime deliberately does NOT use publish items. MSIX resolves the CRT + // through its VCLibs framework dependency, so the loose DLLs are only needed by + // the unpackaged Inno payload, where the post-publish copy already delivers them. + Assert.Contains("CopyOpenClawVCRuntimeToPublish", directoryTargets); + Assert.DoesNotContain("AddOpenClawVCRuntimeToPublishItems", directoryTargets); + } + + [Fact] + public void GeneratedDevelopmentManifest_UsesVersionAndIdentityIsolation() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var project = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.csproj")); + + Assert.Contains("DependsOnTargets=\"GetVersion\"", project); + Assert.Contains("true", + File.ReadAllText(Path.Combine(root, "src", "Directory.Build.props"))); + Assert.Contains("$(GitVersion_CommitsSinceVersionSource)", project); + Assert.Contains("Publisher=\"$(OpenClawDevMsixPublisher)\"", project); + Assert.Contains("ToastActivatorClsid=\"C536D4AD-19BE-4F7A-B227-AB97629BF299\"", project); + Assert.Contains("toastClsidRegex.Replace", project); + Assert.Contains("comClassRegex.Replace", project); + } + + [Fact] + public void PackagedAutoStart_UsesTheManifestStartupTask() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var manifest = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "Package.appxmanifest")); + var manager = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "Services", "AutoStartManager.cs")); + + Assert.Contains("Category=\"windows.startupTask\"", manifest); + Assert.Contains("TaskId=\"OpenClawStartup\"", manifest); + Assert.Contains("Enabled=\"false\"", manifest); + Assert.Contains("EntryPoint=\"Windows.FullTrustApplication\"", manifest); + + Assert.Contains("PackageHelper.IsPackaged", manager); + Assert.Contains("StartupTask.GetAsync(AppIdentity.PackageStartupTaskId)", manager); + Assert.Contains("RequestEnableAsync()", manager); + Assert.Contains("startupTask.Disable()", manager); + Assert.Contains("IsPackagedAutoStartEnabledAsync()", manager); + + // The scheduled task named AppIdentity.StartupTaskName is created by installer.iss + // and removed by the Inno uninstaller. A packaged build must never delete it on its + // own: that would silently disable a legacy install the user has not agreed to + // replace. + Assert.DoesNotContain("MigrateLegacyAutoStartAsync", manager); + Assert.DoesNotContain("PackagedLegacyCleanup", manager); + + var app = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + Assert.DoesNotContain("MigrateLegacyAutoStartAsync", app); + Assert.Contains("await ApplyAutoStartCore(origin, !_settings.AutoStart);", app); + Assert.Contains("await AutoStartManager.IsAutoStartEnabledAsync()", app); + } +} From 999015a85fff17c721aaf94556aeb4a72ae5eb65 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Thu, 3 Sep 2026 16:34:16 -0700 Subject: [PATCH 04/19] feat(msix): use reserved Partner Center identity for Store packages Replaces the placeholder Store identity with the values reserved in Partner Center > Product management > Product identity: Name OpenClawFoundation.OpenClaw Publisher CN=4BA40A7A-B719-4C40-BF91-84AF4F1136FC PublisherDisplayName OpenClaw Foundation All three validate against ST_PackageName, ST_Publisher_2010_v2, and ST_DisplayName in the Windows SDK AppxManifestTypes.xsd. The dev sideload identity follows as OpenClawFoundation.OpenClaw.Dev. Identity/@Publisher is the Store publisher and is deliberately not the Authenticode subject used to sign the unpackaged installer, which is unchanged in docs/RELEASING.md and Test-ReleaseExecutableSignatures.ps1. Decouples AppIdentity.AppUserModelId from the MSIX package identity. The two were previously aliased through a PackageIdentityName constant, but SetCurrentProcessExplicitAppUserModelID is skipped once the process has package identity, so that constant only ever supplied the Win32 AUMID for unpackaged installs. Renaming it alongside the package would have orphaned the AUMID that installer.iss has already written into existing users' Start menu shortcuts and broken their notifications, while doing nothing for packaged builds. AppUserModelId now holds its own literal and a regression test asserts it stays different from Identity/@Name. Because the package family name is derived from Name and Publisher, an already-installed dev package will not upgrade in place; remove it before installing the next dev build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DEVELOPMENT.md | 15 +++++----- build.ps1 | 10 +++---- src/OpenClaw.Tray.WinUI/AppIdentity.cs | 28 ++++++++++++------- .../OpenClaw.Tray.WinUI.csproj | 2 +- src/OpenClaw.Tray.WinUI/Package.appxmanifest | 12 ++++---- .../InstallerIssAssertionTests.cs | 4 +-- .../Services/AppUserModelIdIdentityTests.cs | 22 ++++++++++++++- 7 files changed, 62 insertions(+), 31 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 02c50d8ec..18ba8139a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -271,7 +271,7 @@ is what lets a packaged smoke test run without disturbing a working install: | | `-Msix Dev` | `-Msix Store` | | --- | --- | --- | -| Identity | `OpenClaw.Companion.Dev` | `OpenClaw.Companion` | +| Identity | `OpenClawFoundation.OpenClaw.Dev` | `OpenClawFoundation.OpenClaw` | | Publisher | local development certificate | Partner Center | | Protocol | `openclaw-dev` | `openclaw` | | Signing | signed locally | unsigned; the Store signs | @@ -304,13 +304,14 @@ runtime, the in-process SetupEngine UI, and the architecture-matched the loose Visual C++ runtime files that the Inno payload ships but the MSIX resolves through its VCLibs framework dependency). -Upload both `.msix` files to the same Partner Center submission. Before the -first submission, reserve the app name and replace `Identity/@Name`, +Upload both `.msix` files to the same Partner Center submission. `Identity/@Name`, `Identity/@Publisher`, and `Properties/PublisherDisplayName` in -`src\OpenClaw.Tray.WinUI\Package.appxmanifest` with the values Partner Center -assigns. The submission also needs a justification for the `runFullTrust` -restricted capability and a stated reason plus privacy policy for the declared -`webcam`, `microphone`, and `location` device capabilities. +`src\OpenClaw.Tray.WinUI\Package.appxmanifest` already hold the reserved +Partner Center values and must keep matching **Product management > Product +identity** exactly; a mismatch fails ingestion. The submission also needs a +justification for the `runFullTrust` restricted capability and a stated reason +plus privacy policy for the declared `webcam`, `microphone`, and `location` +device capabilities. Generating the optional `.appxsym` symbol package additionally requires `mspdbcmf.exe` from the Visual Studio **Desktop development with C++** workload; diff --git a/build.ps1 b/build.ps1 index b90ddfe9d..807f85c15 100644 --- a/build.ps1 +++ b/build.ps1 @@ -444,7 +444,7 @@ function Build-Project($name, $path, $useRid = $false, $packageMsix = $false) { } if ($packageMsix) { - $installedDevPackage = Get-AppxPackage -Name "OpenClaw.Companion.Dev" -ErrorAction SilentlyContinue | + $installedDevPackage = Get-AppxPackage -Name "OpenClawFoundation.OpenClaw.Dev" -ErrorAction SilentlyContinue | Where-Object Publisher -eq "CN=OpenClaw Local Development" | Sort-Object { [version]$_.Version.ToString() } -Descending | Select-Object -First 1 @@ -454,7 +454,7 @@ function Build-Project($name, $path, $useRid = $false, $packageMsix = $false) { 1 } if ($msixRevision -gt 65535) { - Write-Error "The installed development MSIX revision is already 65535. Remove the installed OpenClaw.Companion.Dev package before rebuilding." + Write-Error "The installed development MSIX revision is already 65535. Remove the installed OpenClawFoundation.OpenClaw.Dev package before rebuilding." return $false } @@ -565,9 +565,9 @@ if ($buildStoreMsix) { Write-Success $package } Write-Host "`nUpload both packages to the same Partner Center submission." -ForegroundColor Cyan - Write-Info "Reserve the app name first, then replace Identity/@Name, Identity/@Publisher," - Write-Info "and Properties/PublisherDisplayName in src\OpenClaw.Tray.WinUI\Package.appxmanifest" - Write-Info "with the values Partner Center assigns." + Write-Info "Identity is taken from src\OpenClaw.Tray.WinUI\Package.appxmanifest and must keep" + Write-Info "matching Partner Center > Product management > Product identity. The Store re-signs" + Write-Info "these packages, so they are intentionally left unsigned here." Write-Host "" exit 0 } diff --git a/src/OpenClaw.Tray.WinUI/AppIdentity.cs b/src/OpenClaw.Tray.WinUI/AppIdentity.cs index 8e53dae5b..b98c139b4 100644 --- a/src/OpenClaw.Tray.WinUI/AppIdentity.cs +++ b/src/OpenClaw.Tray.WinUI/AppIdentity.cs @@ -13,11 +13,15 @@ internal static class AppIdentity /// Short name used in tray tooltip prefix. public const string TrayName = "OpenClaw Tray (Dev)"; - /// MSIX package identity name (must differ from release for side-by-side). - public const string PackageIdentityName = "OpenClaw.Companion.Dev"; - - /// Win32 AppUserModelID used for notifications and shell grouping. - public const string AppUserModelId = PackageIdentityName; + /// + /// Win32 AppUserModelID used for notifications and shell grouping. This applies to + /// unpackaged (Inno Setup) installs only; packaged builds take their AUMID from the + /// MSIX manifest instead. It must keep matching installer.iss MyAppAumid, and it is + /// deliberately independent of Identity/@Name in Package.appxmanifest -- resyncing it + /// to the MSIX identity would orphan the AUMID already written into existing users' + /// Start menu shortcuts and break their notifications. + /// + public const string AppUserModelId = "OpenClaw.Companion.Dev"; /// Windows Registry auto-start value name (must differ so both can auto-start). public const string AutoStartRegistryName = "OpenClawTray-Dev"; @@ -55,11 +59,15 @@ internal static class AppIdentity /// Short name used in tray tooltip prefix. public const string TrayName = "OpenClaw Tray"; - /// MSIX package identity name. - public const string PackageIdentityName = "OpenClaw.Companion"; - - /// Win32 AppUserModelID used for notifications and shell grouping. - public const string AppUserModelId = PackageIdentityName; + /// + /// Win32 AppUserModelID used for notifications and shell grouping. This applies to + /// unpackaged (Inno Setup) installs only; packaged builds take their AUMID from the + /// MSIX manifest instead. It must keep matching installer.iss MyAppAumid, and it is + /// deliberately independent of Identity/@Name in Package.appxmanifest -- resyncing it + /// to the MSIX identity would orphan the AUMID already written into existing users' + /// Start menu shortcuts and break their notifications. + /// + public const string AppUserModelId = "OpenClaw.Companion"; /// Windows Registry auto-start value name. public const string AutoStartRegistryName = "OpenClawTray"; diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index 0808142ce..a32968c04 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -259,7 +259,7 @@ OutputManifestPath="$(_GeneratedAppxManifestPath)" FourPartVersion="$(_AppxManifestVersion)" DevBuild="$(DevBuild)" - IdentityName="OpenClaw.Companion.Dev" + IdentityName="OpenClawFoundation.OpenClaw.Dev" Publisher="$(OpenClawDevMsixPublisher)" DisplayName="OpenClaw Companion (Dev)" ProtocolName="openclaw-dev" diff --git a/src/OpenClaw.Tray.WinUI/Package.appxmanifest b/src/OpenClaw.Tray.WinUI/Package.appxmanifest index 655cf96aa..b4a9b1e29 100644 --- a/src/OpenClaw.Tray.WinUI/Package.appxmanifest +++ b/src/OpenClaw.Tray.WinUI/Package.appxmanifest @@ -8,15 +8,17 @@ xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" IgnorableNamespaces="uap desktop com rescap"> - + diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs index aaca7b649..d9cad232e 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -361,9 +361,9 @@ public void MsixManifest_IsGeneratedUnderObjWithoutMutatingTrackedSource() Assert.Contains(@"", project); Assert.DoesNotContain("PatchDevAppxManifestIdentity", project); Assert.Contains("Version=\"0.0.0.0\"", manifest); - Assert.Contains("Name=\"OpenClaw.Companion\"", manifest); + Assert.Contains("Name=\"OpenClawFoundation.OpenClaw\"", manifest); Assert.Contains("", manifest); - Assert.DoesNotContain("OpenClaw.Companion.Dev", manifest); + Assert.DoesNotContain("OpenClawFoundation.OpenClaw.Dev", manifest); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/Services/AppUserModelIdIdentityTests.cs b/tests/OpenClaw.Tray.Tests/Services/AppUserModelIdIdentityTests.cs index 44ffce45a..430b69c89 100644 --- a/tests/OpenClaw.Tray.Tests/Services/AppUserModelIdIdentityTests.cs +++ b/tests/OpenClaw.Tray.Tests/Services/AppUserModelIdIdentityTests.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Text.RegularExpressions; using OpenClawTray; using Xunit; @@ -43,10 +44,29 @@ public void Registrar_SkipsExplicitAumidWhenMsixPackageIdentityExists() [Fact] public void AppUserModelId_UsesCompanionIdentity() { - Assert.Equal(AppIdentity.PackageIdentityName, AppIdentity.AppUserModelId); + Assert.StartsWith("OpenClaw.Companion", AppIdentity.AppUserModelId); Assert.DoesNotContain("OpenClaw.Tray.WinUI", AppIdentity.AppUserModelId); } + [Fact] + public void AppUserModelId_IsIndependentOfMsixPackageIdentity() + { + // installer.iss bakes this AUMID into the Start menu shortcut of every unpackaged + // install, and SetCurrentProcessExplicitAppUserModelID is skipped entirely once the + // process has package identity. Resyncing the AUMID to the MSIX Identity/@Name would + // therefore break notifications for existing users without helping packaged builds. + var manifest = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Package.appxmanifest")); + + var identityName = Regex.Match(manifest, @"[^""]+)""").Groups["name"].Value; + + Assert.NotEmpty(identityName); + Assert.NotEqual(AppIdentity.AppUserModelId, identityName); + } + [Fact] public void InstallerAumid_MatchesRuntimeAppUserModelId() { From 821263352f7dfce31fb0fc9b10ae80cef8300212 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Thu, 3 Sep 2026 17:19:06 -0700 Subject: [PATCH 05/19] fix(msix): let the Microsoft Store own updates for packaged builds The packaged app ran the Updatum check on startup with no package-identity guard, so a Store-installed Companion would have self-updated from GitHub releases. That bypasses Store servicing, and it lets the packaged app claim update ownership from a legacy Inno install before any migration has been agreed to (docs/MSIX_LEGACY_INSTALL_MIGRATION.md). UpdateCoordinator now short-circuits when the process has package identity, mirroring the existing development-build skip and placed ahead of it so no packaged path reaches the network check. Manual "Check for updates" still reports a result rather than silently doing nothing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/UpdateCoordinator.cs | 25 +++++++++++-- .../Strings/en-us/Resources.resw | 3 ++ .../Strings/fr-fr/Resources.resw | 3 ++ .../Strings/nl-nl/Resources.resw | 3 ++ .../Strings/pt-br/Resources.resw | 3 ++ .../Strings/zh-cn/Resources.resw | 3 ++ .../Strings/zh-tw/Resources.resw | 3 ++ .../MsixDevelopmentSigningTests.cs | 35 +++++++++++++++++++ 8 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs b/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs index 0f071c8c8..a94447346 100644 --- a/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs +++ b/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs @@ -64,6 +64,23 @@ public async Task CheckForUpdatesAsync(bool userInitiated = false) return true; // Don't block launch } + if (PackageHelper.IsPackaged) + { + // A Store-installed package is updated by Windows, not by Updatum. + // Self-updating from GitHub releases would bypass the Store, and the + // packaged app must not claim update ownership from a legacy install. + Logger.Info("Skipping update check in packaged build; updates are managed by the Microsoft Store"); + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Skipped", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = "managed by the Microsoft Store" + }; + _updateCheckGate.Release(); + return true; + } + if (AppIdentity.IsDev) { Logger.Info("Skipping release-channel update check in development build"); @@ -382,9 +399,11 @@ await ShowUpdateInfoDialogAsync( "Skipped", LocalizationHelper.GetString("Update_Title_Skipped"), LocalizationHelper.GetString( - AppIdentity.IsDev - ? "Update_Message_Skipped_Dev" - : "Update_Message_Skipped_Debug")); + PackageHelper.IsPackaged + ? "Update_Message_Skipped_Store" + : AppIdentity.IsDev + ? "Update_Message_Skipped_Dev" + : "Update_Message_Skipped_Debug")); break; } } diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 031cb1c77..5d8ad2c69 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -395,6 +395,9 @@ Update checks are disabled in debug builds. + + Updates for this app are managed by the Microsoft Store. + Update checks are disabled in development builds. diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 0d67166f6..e7893072a 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -367,6 +367,9 @@ La vérification des mises à jour est désactivée dans les builds de débogage. + + Les mises à jour de cette application sont gérées par le Microsoft Store. + La vérification des mises à jour est désactivée dans les versions de développement. diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 3b0b3a2d5..9e653dcc7 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -368,6 +368,9 @@ Updatecontroles zijn uitgeschakeld in debug-builds. + + Updates voor deze app worden beheerd door de Microsoft Store. + Updatecontroles zijn uitgeschakeld in ontwikkelbuilds. diff --git a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw index d87431e5c..56f26fb2b 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -395,6 +395,9 @@ A verificação de atualizações está desativada em builds de depuração. + + As atualizações deste aplicativo são gerenciadas pela Microsoft Store. + A verificação de atualizações está desativada em builds de desenvolvimento. diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 75caddc7c..790e84456 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -367,6 +367,9 @@ 调试版本已禁用更新检查。 + + 此应用的更新由 Microsoft Store 管理。 + 开发版本已禁用更新检查。 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 729834109..1f0c5a74f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -367,6 +367,9 @@ 偵錯版本已停用更新檢查。 + + 此應用程式的更新由 Microsoft Store 管理。 + 開發版本已停用更新檢查。 diff --git a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs index 49b743146..941cfb81d 100644 --- a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs +++ b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs @@ -200,4 +200,39 @@ public void PackagedAutoStart_UsesTheManifestStartupTask() Assert.Contains("await ApplyAutoStartCore(origin, !_settings.AutoStart);", app); Assert.Contains("await AutoStartManager.IsAutoStartEnabledAsync()", app); } + + [Fact] + public void PackagedBuildsDeferUpdatesToTheStore() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var coordinator = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "Services", "UpdateCoordinator.cs")); + + // A Store package is serviced by Windows. Self-updating from GitHub releases would + // bypass the Store and let a packaged build claim update ownership. + Assert.Contains("if (PackageHelper.IsPackaged)", coordinator); + Assert.Contains("managed by the Microsoft Store", coordinator); + Assert.Contains("Update_Message_Skipped_Store", coordinator); + + // The skip must precede the network check so no packaged path reaches Updatum. + var packagedSkip = coordinator.IndexOf("if (PackageHelper.IsPackaged)", StringComparison.Ordinal); + var devSkip = coordinator.IndexOf("if (AppIdentity.IsDev)", StringComparison.Ordinal); + Assert.True(packagedSkip > 0 && packagedSkip < devSkip, + "The packaged update skip must run before the development-build skip."); + } + + [Fact] + public void StoreUpdateMessageExistsInEveryLocale() + { + var resources = Directory.GetFiles( + Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), "src"), + "Resources.resw", + SearchOption.AllDirectories); + + Assert.NotEmpty(resources); + foreach (var resource in resources) + { + Assert.Contains("Update_Message_Skipped_Store", File.ReadAllText(resource)); + } + } } From a33a09f1853d200d3e2d678d41f6598b2180e863 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Thu, 3 Sep 2026 17:43:11 -0700 Subject: [PATCH 06/19] fix(installer): default the gateway removal prompt to No Uninstalling the Inno package asks whether to also unregister the OpenClaw WSL distro. The prompt used MB_YESNO, which makes Yes the default button, so pressing Enter destroys the local gateway. Users who uninstall in order to reinstall - for example when moving to the Microsoft Store package - should keep their gateway unless they deliberately choose otherwise, so make No the default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- installer.iss | 5 ++++- tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/installer.iss b/installer.iss index 74cab78fe..506720732 100644 --- a/installer.iss +++ b/installer.iss @@ -189,13 +189,16 @@ begin end else begin + // MB_DEFBUTTON2 makes "No" the default: removing the WSL gateway is destructive and + // unrecoverable, and a user uninstalling in order to reinstall (for example when + // moving to the Store package) must not lose their gateway by pressing Enter. LocalGatewayCleanupRequested := MsgBox( 'Do you also want to remove the OpenClaw local WSL gateway?' + #13#10#13#10 + 'Choose Yes to unregister the {#MyDistroName} WSL distro and remove generated local gateway state.' + #13#10 + 'Choose No to leave the local gateway and generated local state on this computer.', mbConfirmation, - MB_YESNO) = IDYES; + MB_YESNO or MB_DEFBUTTON2) = IDYES; if LocalGatewayCleanupRequested then Log('User chose to remove the local WSL gateway.') diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs index d9cad232e..e70d9747b 100644 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs @@ -79,7 +79,7 @@ public void Installer_RemovesGeneratedAppStateOnlyAfterGatewayCleanup() Assert.Contains("UninstallSilent()", iss); Assert.Contains("LocalGatewayCleanupRequested := True", iss); Assert.Contains("{#MyDistroName} WSL distro", iss); - Assert.Contains("MB_YESNO", iss); + Assert.Contains("MB_YESNO or MB_DEFBUTTON2", iss); Assert.Contains("ExpandConstant('{sys}\\WindowsPowerShell\\v1.0\\powershell.exe')", iss); Assert.Contains("ewWaitUntilTerminated", iss); Assert.Contains("MB_RETRYCANCEL", iss); From 0d826c39dee8cf0b81ef0adf6d2e59f532441ebf Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Thu, 3 Sep 2026 20:00:51 -0700 Subject: [PATCH 07/19] fix(msix): embed the DPI-aware application manifest in packaged builds app.manifest declares PerMonitorV2 DPI awareness, but ApplicationManifest sat in the PackageMsix != true property group, so it was applied only to the unpackaged Inno build. MSIX builds shipped a DPI-unaware executable that renders blurry on mixed-DPI displays. Packaging does not supply DPI awareness, so move the declaration to the shared property group and cover it with a regression test. Verified with the Windows App Certification Kit against the release-identity x64 package: DPIAwarenessValidation went from WARNING to PASS, taking the overall verdict from WARNING to PASS. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OpenClaw.Tray.WinUI.csproj | 6 ++++- .../MsixDevelopmentSigningTests.cs | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index a32968c04..b05a99852 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -15,6 +15,11 @@ en-US x64;ARM64 win-x64;win-arm64 + + app.manifest $(WarningsNotAsErrors);CS0618 @@ -52,7 +57,6 @@ None true - app.manifest diff --git a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs index 941cfb81d..c362c6559 100644 --- a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs +++ b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs @@ -150,6 +150,33 @@ public void MsixPayload_UsesStandardPublishItemsForNativeDependencies() Assert.DoesNotContain("AddOpenClawVCRuntimeToPublishItems", directoryTargets); } + [Fact] + public void PackagedBuilds_EmbedTheDpiAwareApplicationManifest() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var project = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.csproj")); + var appManifest = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "app.manifest")); + + Assert.Contains("PerMonitorV2", appManifest); + + // app.manifest carries PerMonitorV2 DPI awareness, which packaging does not + // supply. It once sat in the unpackaged-only property group, so MSIX builds + // shipped a DPI-unaware executable that the Windows App Certification Kit + // flagged. Keep the declaration unconditional. + var unpackagedOnlyGroup = project.IndexOf( + "", + StringComparison.Ordinal); + Assert.True(unpackagedOnlyGroup >= 0); + var unpackagedOnlyGroupEnd = project.IndexOf( + "", unpackagedOnlyGroup, StringComparison.Ordinal); + var unpackagedOnlyBody = project[unpackagedOnlyGroup..unpackagedOnlyGroupEnd]; + + Assert.Contains("app.manifest", project); + Assert.DoesNotContain("", unpackagedOnlyBody); + } + [Fact] public void GeneratedDevelopmentManifest_UsesVersionAndIdentityIsolation() { From c021acb4b8086ed63bc32e17153b2c361aa6e7f7 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Fri, 4 Sep 2026 10:12:07 -0700 Subject: [PATCH 08/19] docs: state that the Store package replaces an Inno install The Store package and the Inno installer produce the same application and are not supported side by side: both register the openclaw protocol, both can claim autostart, and production builds share the OpenClawTray single-instance mutex, which MSIX does not namespace-isolate. With both installed, opening the Store app usually surfaces the Inno one. Document uninstall-first as the supported path and record why it is safe: per-user state under %APPDATA%\OpenClawTray survives, the interactive gateway prompt now defaults to No, and silent uninstall always removes the gateway so it must not be used to migrate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DEVELOPMENT.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 18ba8139a..d3f93d0ab 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -317,6 +317,33 @@ Generating the optional `.appxsym` symbol package additionally requires `mspdbcmf.exe` from the Visual Studio **Desktop development with C++** workload; without it the build logs a warning and skips symbols. +#### The Store package replaces an Inno install + +The Store package and the Inno installer produce the same application, so they +are not supported side by side. Both register the `openclaw` protocol and both +can claim autostart, and production builds of either share the `OpenClawTray` +single-instance mutex. MSIX full-trust apps are not namespace-isolated for named +objects, so the second launch hands its activation to whichever instance is +already running. With both installed, opening the Store app usually surfaces the +Inno one instead. + +Uninstall the Inno build before installing the Store package. That transfer is +safe today: + +- Settings, gateway records, and device identities under + `%APPDATA%\OpenClawTray` are preserved. Inno uninstall removes only the + install directory, and a packaged process reads the existing per-user data + through the merged MSIX view, so pairing carries over without re-pairing. +- Uninstall asks whether to also remove the local WSL gateway. **No** is the + default and keeps the gateway. Answer **No** when reinstalling; **Yes** is a + deliberate destructive choice that unregisters the distro. +- A silent uninstall (`/SILENT`, `/VERYSILENT`) always removes the local + gateway, so do not use it to move to the Store package. + +Detecting a legacy install from the packaged app, obtaining consent, and +removing it automatically belong to a migration service that does not exist +yet. Until it ships, uninstalling first is the supported path. + #### Dev identity and side-by-side installs Release identity is the default for every configuration. Use `-DevBuild` on `build.ps1` or `-Dev` on `run-app-local.ps1` when you explicitly want the side-by-side dev identity: From 7498736c12ad1276e2dc5c45c4befcfb47d28344 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Fri, 4 Sep 2026 11:28:28 -0700 Subject: [PATCH 09/19] fix(msix): reconcile packaged auto-start with Windows at startup The packaged manifest installs the StartupTask disabled and Windows owns its state from then on. Nothing reconciled that against the persisted AutoStart preference: SettingsChangeCoordinator.Apply only runs on a settings change, so an AutoStart=true value preserved from an unpackaged install was reported as enabled in Settings while nothing actually launched at logon. The same stale reading occurred after a user toggled the entry in Settings > Apps > Startup. Treat Windows as the source of truth. The stored intent is applied when it can be, and whatever Windows reports afterwards is persisted, so a DisabledByUser or DisabledByPolicy refusal surfaces as off instead of being retried silently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b95cf49-7892-48fe-8eda-b0b1541b52ed --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 35 ++++++++++++++ .../Services/AutoStartManager.cs | 46 +++++++++++++++++++ .../MsixDevelopmentSigningTests.cs | 26 +++++++++++ 3 files changed, 107 insertions(+) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index f3457d36c..6bec2dd4f 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -854,6 +854,15 @@ _dispatcherQueue is null Logger.Error($"Onboarding failed during launch (tray remains available): {ex}"); } + // Packaged builds must reconcile auto-start with Windows after settings load. + // Nothing else does: SettingsChangeCoordinator.Apply only runs on a settings + // *change*, so a preserved AutoStart=true would be shown as enabled while the + // manifest's StartupTask sat disabled. Backgrounded so a slow StartupTask query + // cannot delay tray availability. + ObserveBackgroundFault( + ReconcileAutoStartOnStartupAsync(), + "[App] Failed to reconcile auto-start with Windows"); + // Ensure NodeService is constructed BEFORE InitializeGatewayClient triggers a // NodeConnector connect. The NodeConnector.ClientCreated event subscription // above relies on _nodeService being non-null to register capabilities on the @@ -4055,6 +4064,32 @@ private async Task ApplyAutoStartCore(SettingsWriteOrigin? origin, bool au } } + /// + /// Aligns the stored auto-start preference with the state Windows actually reports, + /// so the Settings toggle never claims auto-start is on while nothing launches at logon. + /// + private async Task ReconcileAutoStartOnStartupAsync() + { + if (_settings == null) return; + + var configured = _settings.AutoStart; + var effective = await AutoStartManager.ReconcileAutoStartAsync(configured); + if (effective == configured) return; + + Logger.Info($"Auto-start setting corrected from {configured} to {effective} to match Windows."); + if (SettingsStore is { } store) + { + store.Update(null, edit => edit.AutoStart = effective); + } + else + { + _settings.AutoStart = effective; + _settings.Save(); + } + + OnSettingsSaved(this, EventArgs.Empty); + } + private void OpenLogFile() { try diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs index 7744e6e84..a5c29b3f8 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs @@ -59,6 +59,52 @@ public static Task IsAutoStartEnabledAsync() => ? IsPackagedAutoStartEnabledAsync() : Task.Run(IsAutoStartEnabled); + /// + /// Reconciles the persisted auto-start preference against the real Windows startup + /// state and returns the value the app should now report and store. + /// + /// + /// Packaged builds need this at startup. The manifest installs the StartupTask + /// disabled, and Windows (not the app) owns the state afterwards, so a preserved + /// AutoStart=true setting carried over from an unpackaged install would + /// otherwise be displayed as enabled while nothing actually launches at logon. The + /// user can also flip the task in Settings > Apps > Startup at any time. + /// + /// Windows is treated as the source of truth: the stored intent is applied when it + /// can be, and whatever Windows reports afterwards is what gets persisted. Enabling + /// is a request that Windows may refuse (DisabledByUser / DisabledByPolicy), and a + /// refusal must not be retried silently forever, so it is surfaced as false. + /// + public static async Task ReconcileAutoStartAsync(bool configured) + { + if (!PackageHelper.IsPackaged) + return configured; + + try + { + var actual = await IsPackagedAutoStartEnabledAsync(); + if (actual == configured) + return configured; + + if (!configured) + { + // Windows says enabled while the app setting says off, which happens when + // the user enables the entry in Startup Apps. Report the truth instead of + // fighting Windows; the in-app toggle still pushes changes the other way. + Logger.Info("Auto-start is enabled in Windows; adopting that state."); + return true; + } + + await SetPackagedAutoStartAsync(true); + return true; + } + catch (Exception ex) + { + Logger.Warn($"Auto-start could not be reconciled, reporting disabled: {ex.Message}"); + return false; + } + } + private static void SetUnpackagedAutoStart(bool enable) { try diff --git a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs index c362c6559..ef17adf8a 100644 --- a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs +++ b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs @@ -228,6 +228,32 @@ public void PackagedAutoStart_UsesTheManifestStartupTask() Assert.Contains("await AutoStartManager.IsAutoStartEnabledAsync()", app); } + [Fact] + public void PackagedAutoStart_IsReconciledWithWindowsAtStartup() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var manager = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "Services", "AutoStartManager.cs")); + var app = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + + // The manifest installs the StartupTask disabled and Windows owns the state + // afterwards, so an AutoStart=true setting preserved from an unpackaged install + // would otherwise be reported as enabled while nothing launches at logon. + Assert.Contains("ReconcileAutoStartAsync", manager); + Assert.Contains("ReconcileAutoStartOnStartupAsync", app); + + // SettingsChangeCoordinator.Apply only runs on a settings *change*, so the + // reconcile must be invoked from the startup path itself. + var coordinator = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "Services", "SettingsChangeCoordinator.cs")); + Assert.DoesNotContain("ReconcileAutoStartAsync", coordinator); + + // A refusal from Windows (DisabledByUser / DisabledByPolicy) must be persisted as + // false rather than retried silently, so the toggle tells the truth. + Assert.Contains("edit.AutoStart = effective", app); + } + [Fact] public void PackagedBuildsDeferUpdatesToTheStore() { From 6676c83a852ab2034fa356c9349c1af3a46a0cc5 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Fri, 4 Sep 2026 11:29:19 -0700 Subject: [PATCH 10/19] refactor(msix): make the Store packaging script release-only and rename it Build-Msix.ps1 produced only Microsoft Store packages: its sole caller was the Store branch of build.ps1, it hardcoded AppxPackageSigningEnabled=false, and it verified the produced package against the release identity. The Dev flavor never used it. The generic name invited the opposite reading, so rename it to Build-StoreMsix.ps1. It also accepted -Configuration Debug and then published and verified that package. build.ps1 -Msix Store forces Release, but the script is a documented entry point on its own, so a caller could produce a locally verified, provenance-stamped artifact that Partner Center rejects. Accept Release only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b95cf49-7892-48fe-8eda-b0b1541b52ed --- DEVELOPMENT.md | 4 ++-- build.ps1 | 4 ++-- scripts/{Build-Msix.ps1 => Build-StoreMsix.ps1} | 15 ++++++++++----- .../MsixDevelopmentSigningTests.cs | 17 +++++++++++++++-- 4 files changed, 29 insertions(+), 11 deletions(-) rename scripts/{Build-Msix.ps1 => Build-StoreMsix.ps1} (94%) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d3f93d0ab..8911fb3cf 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -288,13 +288,13 @@ uninstalled package must be installed before the next revision advances. Partner Center rejects any submission whose revision is non-zero. `-Msix Store` forces `-Configuration Release`, refuses to combine with -`-DevBuild`, and delegates to `scripts\Build-Msix.ps1` once +`-DevBuild`, and delegates to `scripts\Build-StoreMsix.ps1` once per architecture. Each run produces one unsigned self-contained package at `artifacts\msix\\OpenClawCompanion-.msix` alongside an `msix-metadata.json` provenance sidecar recording the source commit, whether the tree was dirty, the package version, publisher, and the package SHA-256. -`scripts\Build-Msix.ps1` fails the build when the produced package drifts from +`scripts\Build-StoreMsix.ps1` fails the build when the produced package drifts from `Package.appxmanifest`: the identity name, publisher, and processor architecture must match, the version must be four `uint16` components ending in `.0` because Partner Center reserves the revision field, exactly one `.msix` diff --git a/build.ps1 b/build.ps1 index 807f85c15..edb54769b 100644 --- a/build.ps1 +++ b/build.ps1 @@ -539,14 +539,14 @@ $projects = @{ } if ($buildStoreMsix) { - # scripts\Build-Msix.ps1 owns packaging, identity verification, and the + # scripts\Build-StoreMsix.ps1 owns packaging, identity verification, and the # provenance sidecar. build.ps1 only drives it once per architecture. $storeArchitectures = $storeMsixRuntimeIdentifiers | ForEach-Object { $_ -replace "^win-", "" } $storePackages = @() foreach ($storeArchitecture in $storeArchitectures) { Write-Host "`nBuilding Store MSIX ($storeArchitecture)..." -ForegroundColor White try { - & (Join-Path $repoRoot "scripts\Build-Msix.ps1") ` + & (Join-Path $repoRoot "scripts\Build-StoreMsix.ps1") ` -Architecture $storeArchitecture ` -Configuration $Configuration } catch { diff --git a/scripts/Build-Msix.ps1 b/scripts/Build-StoreMsix.ps1 similarity index 94% rename from scripts/Build-Msix.ps1 rename to scripts/Build-StoreMsix.ps1 index e434d787f..adbd8d06d 100644 --- a/scripts/Build-Msix.ps1 +++ b/scripts/Build-StoreMsix.ps1 @@ -26,8 +26,9 @@ separate package per architecture; upload both to one submission. .PARAMETER Configuration - Build configuration: Debug or Release. Defaults to Release. Store - certification does not accept Debug binaries. + Build configuration. Release is the only accepted value: Store + certification rejects Debug binaries, so this script refuses to stamp a + Debug build with the release identity and provenance sidecar. .PARAMETER OutputDirectory Where to place the package and its metadata sidecar. Relative paths resolve @@ -36,8 +37,8 @@ the build fails if it already exists and is not empty. .EXAMPLE - .\scripts\Build-Msix.ps1 -Architecture x64 - .\scripts\Build-Msix.ps1 -Architecture arm64 + .\scripts\Build-StoreMsix.ps1 -Architecture x64 + .\scripts\Build-StoreMsix.ps1 -Architecture arm64 .\build.ps1 -Project WinUI -Msix Store #> [CmdletBinding()] @@ -45,7 +46,11 @@ param( [ValidateSet('x64', 'arm64')] [string]$Architecture = 'x64', - [ValidateSet('Debug', 'Release')] + # Release-only by design. build.ps1 -Msix Store already forces Release, but this + # script is a documented entry point on its own: accepting Debug here would let a + # caller produce a locally verified, provenance-stamped package that Partner + # Center rejects. + [ValidateSet('Release')] [string]$Configuration = 'Release', [string]$OutputDirectory diff --git a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs index ef17adf8a..1dfb1b58a 100644 --- a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs +++ b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs @@ -60,11 +60,11 @@ public void BuildScript_StoreMsixPathIsUnsignedReleaseIdentityForBothArchitectur { var root = TestRepositoryPaths.GetRepositoryRoot(); var buildScript = File.ReadAllText(Path.Combine(root, "build.ps1")); - var packagingScript = File.ReadAllText(Path.Combine(root, "scripts", "Build-Msix.ps1")); + var packagingScript = File.ReadAllText(Path.Combine(root, "scripts", "Build-StoreMsix.ps1")); Assert.Contains("$buildStoreMsix = ($Msix -eq \"Store\")", buildScript); Assert.Contains("$storeMsixRuntimeIdentifiers = @(\"win-x64\", \"win-arm64\")", buildScript); - Assert.Contains(@"scripts\Build-Msix.ps1", buildScript); + Assert.Contains(@"scripts\Build-StoreMsix.ps1", buildScript); // The Store identity and the dev identity must never be produced by the same build. Assert.Contains("-Msix Store cannot be combined with -DevBuild", buildScript); @@ -254,6 +254,19 @@ public void PackagedAutoStart_IsReconciledWithWindowsAtStartup() Assert.Contains("edit.AutoStart = effective", app); } + [Fact] + public void StoreMsixPackaging_RefusesDebugConfigurations() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var packagingScript = File.ReadAllText(Path.Combine(root, "scripts", "Build-StoreMsix.ps1")); + + // build.ps1 -Msix Store forces Release, but this script is a documented entry + // point on its own. Accepting Debug would let a caller produce a locally verified, + // provenance-stamped package that Partner Center rejects. + Assert.Contains("[ValidateSet('Release')]", packagingScript); + Assert.DoesNotContain("[ValidateSet('Debug', 'Release')]", packagingScript); + } + [Fact] public void PackagedBuildsDeferUpdatesToTheStore() { From 7352de4908c849af68b22047e1c39a9625cbb887 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Fri, 4 Sep 2026 11:29:53 -0700 Subject: [PATCH 11/19] fix(msix): label the Dev startup task distinctly The Dev manifest rewrite changed the package and application display names but left desktop:StartupTask/@DisplayName as OpenClaw Companion. Because the Dev and production packages install side by side, Windows Startup Apps and Task Manager showed two identical startup entries and the user could disable the wrong one. Rewrite that attribute alongside the other Dev fields, and fail the build when it is missing, matching how every other required element in the rewrite behaves. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b95cf49-7892-48fe-8eda-b0b1541b52ed --- .../OpenClaw.Tray.WinUI.csproj | 12 ++++++++++++ .../MsixDevelopmentSigningTests.cs | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index b05a99852..1a653f978 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -214,6 +214,18 @@ } text = protocolRegex.Replace(text, "${1}" + ProtocolName + "$2", 1); + // Windows Startup Apps and Task Manager surface this string. Without the + // rewrite a side-by-side Dev install is indistinguishable from production, + // so the user can disable the wrong startup entry. + var startupTaskDisplayRegex = new System.Text.RegularExpressions.Regex( + "(]*?\\bDisplayName\\s*=\\s*[\"'])[^\"']+([\"'])", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (!startupTaskDisplayRegex.IsMatch(text)) { + Log.LogError("GenerateOpenClawAppxManifest: StartupTask/@DisplayName missing from " + SourceManifestPath); + return false; + } + text = startupTaskDisplayRegex.Replace(text, "${1}" + DisplayName + "$2", 1); + var toastClsidRegex = new System.Text.RegularExpressions.Regex( "(]*?\\bToastActivatorCLSID\\s*=\\s*[\"'])[^\"']+([\"'])", System.Text.RegularExpressions.RegexOptions.IgnoreCase); diff --git a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs index 1dfb1b58a..551eb66ea 100644 --- a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs +++ b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs @@ -267,6 +267,21 @@ public void StoreMsixPackaging_RefusesDebugConfigurations() Assert.DoesNotContain("[ValidateSet('Debug', 'Release')]", packagingScript); } + [Fact] + public void DevManifest_RewritesTheStartupTaskDisplayName() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var project = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.csproj")); + + // Windows Startup Apps and Task Manager surface this string. Without the rewrite a + // side-by-side Dev install is indistinguishable from production there, so the user + // can disable the wrong startup entry. + Assert.Contains("startupTaskDisplayRegex", project); + Assert.Contains("desktop:StartupTask", project); + Assert.Contains("StartupTask/@DisplayName missing from", project); + } + [Fact] public void PackagedBuildsDeferUpdatesToTheStore() { From 51db2a858fa6805a80d1462a4fa7942d30002c68 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Tue, 8 Sep 2026 15:12:49 -0700 Subject: [PATCH 12/19] docs: describe Store and Inno installs coexisting The previous section told users to uninstall the Inno build before installing the Store package. That is not the supported path: both installs can coexist and no uninstall is required. Replace it with what actually happens. The two installs share the openclaw protocol registration, the OpenClawTray mutex, per-user data, the gateway port, and the WSL distro, so the first one launched wins and the second forwards its activation and exits. Both can register autostart, so the logon race persists. A running Store app also blocks the Inno uninstaller, because installer.iss sets AppMutex to the shared mutex name. Migration behavior remains unimplemented and is tracked separately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b95cf49-7892-48fe-8eda-b0b1541b52ed --- DEVELOPMENT.md | 63 +++++++++++++++++++++++++++++--------------------- 1 file changed, 37 insertions(+), 26 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8911fb3cf..a33fdb224 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -317,32 +317,43 @@ Generating the optional `.appxsym` symbol package additionally requires `mspdbcmf.exe` from the Visual Studio **Desktop development with C++** workload; without it the build logs a warning and skips symbols. -#### The Store package replaces an Inno install - -The Store package and the Inno installer produce the same application, so they -are not supported side by side. Both register the `openclaw` protocol and both -can claim autostart, and production builds of either share the `OpenClawTray` -single-instance mutex. MSIX full-trust apps are not namespace-isolated for named -objects, so the second launch hands its activation to whichever instance is -already running. With both installed, opening the Store app usually surfaces the -Inno one instead. - -Uninstall the Inno build before installing the Store package. That transfer is -safe today: - -- Settings, gateway records, and device identities under - `%APPDATA%\OpenClawTray` are preserved. Inno uninstall removes only the - install directory, and a packaged process reads the existing per-user data - through the merged MSIX view, so pairing carries over without re-pairing. -- Uninstall asks whether to also remove the local WSL gateway. **No** is the - default and keeps the gateway. Answer **No** when reinstalling; **Yes** is a - deliberate destructive choice that unregisters the distro. -- A silent uninstall (`/SILENT`, `/VERYSILENT`) always removes the local - gateway, so do not use it to move to the Store package. - -Detecting a legacy install from the packaged app, obtaining consent, and -removing it automatically belong to a migration service that does not exist -yet. Until it ships, uninstalling first is the supported path. +#### The Store package alongside an existing Inno install + +The Store package and the Inno installer produce the same application. Both can +be installed at once, and uninstalling the Inno build first is **not** required. +They cannot both run at once, though, and nothing in either build arbitrates +between them yet. + +What the two installs share: the `openclaw` protocol registration, the +`OpenClawTray` single-instance mutex, per-user data under `%APPDATA%\OpenClawTray`, +the local gateway port, and the WSL gateway distro. MSIX full-trust apps are not +namespace-isolated for named objects, so the mutex really is shared. Package +identity, install directory, and AppUserModelID are the only axes that differ. + +Consequences to expect while both are installed: + +- The first one launched holds the mutex. The second forwards its activation to + the running instance and exits, so opening the Store entry while the Inno build + is running surfaces the Inno window with no error shown. +- Both can register autostart, so which build starts at logon is a race. The + Inno build uses a logon scheduled task; the packaged build uses the manifest's + `windows.startupTask`. Neither build suppresses the other, so the race persists + across reboots. +- Settings, gateway records, and device identities carry over either way. A + packaged process reads the existing per-user data through the merged MSIX view, + so there is no re-pairing. +- A running Store app blocks the Inno uninstaller, because `installer.iss` sets + `AppMutex` to the shared mutex name. Quit the Store app before uninstalling. +- Inno uninstall asks whether to also remove the local WSL gateway. **No** is the + default and keeps it. A silent uninstall (`/SILENT`, `/VERYSILENT`) always + removes the gateway. + +To make the Store build the one that runs, quit the Inno build and launch the +Store entry, or uninstall the Inno build. + +Detecting a legacy install from the packaged app, suppressing its autostart, +telling the user which install is active, and removing it with consent are +tracked as separate migration work and are not implemented here. #### Dev identity and side-by-side installs From 20880a0aa587da1b1009dad8a8dfd4da54b1644c Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Tue, 8 Sep 2026 15:36:06 -0700 Subject: [PATCH 13/19] docs: link MSIX coexistence gaps to the migration issue Reconciles the stale uninstall-first instruction in the Dev MSIX section with the coexistence contract documented below it, and points both at the tracking issue. Refs #1374 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b95cf49-7892-48fe-8eda-b0b1541b52ed --- DEVELOPMENT.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a33fdb224..1a5abcc25 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -237,13 +237,14 @@ The development machine must also have Microsoft Store distribution resolves this framework dependency automatically; direct `Add-AppxPackage` sideloading requires it to be installed first. -Uninstall an existing Inno build before switching that identity to MSIX. The -Inno uninstaller removes its host `HKCU\...\Run` value; MSIX virtualizes HKCU -writes and cannot remove that host value without a restricted capability that -is inappropriate for the Store package. The packaged app deliberately leaves the -legacy scheduled task in place: deleting it would silently disable an Inno -install the user has not agreed to replace, so that cleanup belongs to a -migration flow that asks first. +A production-identity MSIX can be installed while an Inno build is still present; +see "The Store package alongside an existing Inno install" below for what the two +share and how they interfere. The packaged app deliberately leaves the legacy +scheduled task and `HKCU\...\Run` value in place: MSIX virtualizes HKCU writes and +cannot remove the host value without a restricted capability that is inappropriate +for the Store package, and deleting the task would silently disable an Inno install +the user has not agreed to replace. That cleanup belongs to a migration flow that +asks first, tracked in #1374. Packaged builds register launch-at-login through the manifest `windows.startupTask` extension and the Windows `StartupTask` API. Unpackaged Inno builds retain the existing scheduled-task and registry fallback until that @@ -353,7 +354,7 @@ Store entry, or uninstall the Inno build. Detecting a legacy install from the packaged app, suppressing its autostart, telling the user which install is active, and removing it with consent are -tracked as separate migration work and are not implemented here. +tracked in #1374 and are not implemented here. #### Dev identity and side-by-side installs From 50bc2abf8f11a87cd88b46ad24d81e6600db879b Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Tue, 8 Sep 2026 16:14:44 -0700 Subject: [PATCH 14/19] docs: correct Inno autostart and mutex coexistence details Proved on a machine with both builds installed: the shared AppMutex blocks the Inno installer as well as the uninstaller, and the installer's autostart option writes a Startup folder shortcut rather than the scheduled task. Refs #1374 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b95cf49-7892-48fe-8eda-b0b1541b52ed --- DEVELOPMENT.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1a5abcc25..27d819ef9 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -337,14 +337,20 @@ Consequences to expect while both are installed: the running instance and exits, so opening the Store entry while the Inno build is running surfaces the Inno window with no error shown. - Both can register autostart, so which build starts at logon is a race. The - Inno build uses a logon scheduled task; the packaged build uses the manifest's - `windows.startupTask`. Neither build suppresses the other, so the race persists - across reboots. + Inno installer's "Start when Windows starts" task creates a Startup folder + shortcut (`installer.iss:124`, `{userstartup}`); the Inno app's own Settings + toggle creates a logon scheduled task and an `HKCU\...\Run` value; the packaged + build uses the manifest's `windows.startupTask`. Neither build suppresses the + other, so the race persists across reboots. Windows Settings lists all of them + under the same name, and a Startup folder shortcut is labelled by its target + executable, so they cannot be told apart there. - Settings, gateway records, and device identities carry over either way. A packaged process reads the existing per-user data through the merged MSIX view, so there is no re-pairing. -- A running Store app blocks the Inno uninstaller, because `installer.iss` sets - `AppMutex` to the shared mutex name. Quit the Store app before uninstalling. +- A running Store app blocks the Inno installer **and** uninstaller, because + `installer.iss` sets `AppMutex` to the shared mutex name. Both abort with + "Setup has detected that OpenClaw Companion is currently running". Quit the + Store app before installing or uninstalling the Inno build. - Inno uninstall asks whether to also remove the local WSL gateway. **No** is the default and keeps it. A silent uninstall (`/SILENT`, `/VERYSILENT`) always removes the gateway. From fd17b614e2e1293c467ec218165be6cb9faa6849 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Wed, 9 Sep 2026 18:07:46 -0700 Subject: [PATCH 15/19] Preserve the auto-start preference when Windows cannot be queried A failed StartupTask query was indistinguishable from "Windows reports disabled": IsPackagedAutoStartEnabledAsync converted any exception into false. Both callers persist what they are given, so a transient WinRT failure wrote AutoStart=false over an enabled preference. That is unrecoverable on its own, because the next launch reads the overwritten false, reconciliation agrees with it, and nothing launches at logon again. Add AutoStartState with an explicit Unknown, and AutoStartRefusedException to mark a durable refusal (DisabledByUser / DisabledByPolicy). One rule now governs both call sites: only a definite answer from Windows may overwrite the user's intent. An explicit refusal still reports disabled, so the toggle never claims an auto-start that will never happen. This fixes both instances of the collapse: - ReconcileAutoStartAsync at startup, reported in review. - ApplyAutoStartCore's rollback after a failed change, which reached the same raw query through IsAutoStartEnabledAsync and also persists the result. Not reported, found while tracing the first. Extract the decision into AutoStartReconciliation, kept free of WinRT so the policy is unit tested directly instead of through source-text assertions. OpenClaw.Tray.Tests is pure net10.0 and cannot compile AutoStartManager, which is why the existing coverage was source-text only. Validation: ./build.ps1 (all projects); Shared.Tests 3913 passed, 34 skipped; Tray.Tests 2887 passed. Every behavioral claim was checked by reverting the fix and confirming the corresponding tests fail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b95cf49-7892-48fe-8eda-b0b1541b52ed --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 2 +- .../Services/AutoStartManager.cs | 87 ++++--- .../Services/AutoStartReconciliation.cs | 165 +++++++++++++ .../AutoStartReconciliationTests.cs | 216 ++++++++++++++++++ .../MsixDevelopmentSigningTests.cs | 5 +- .../OpenClaw.Tray.Tests.csproj | 1 + 6 files changed, 442 insertions(+), 34 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs create mode 100644 tests/OpenClaw.Tray.Tests/AutoStartReconciliationTests.cs diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 6bec2dd4f..452e51c9d 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -4050,7 +4050,7 @@ private async Task ApplyAutoStartCore(SettingsWriteOrigin? origin, bool au catch (Exception ex) { Logger.Error($"ApplyAutoStart failed: {ex.Message}"); - var effectiveAutoStart = await AutoStartManager.IsAutoStartEnabledAsync(); + var effectiveAutoStart = await AutoStartManager.ResolveAutoStartAfterFailedChangeAsync(autoStart, ex); if (SettingsStore is { } store) { store.Update(origin, edit => edit.AutoStart = effectiveAutoStart); diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs index a5c29b3f8..d3b2ec606 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs @@ -20,6 +20,14 @@ public static class AutoStartManager // install the user has not agreed to replace. Detecting a legacy install, obtaining // consent, and removing its registrations belong to a migration flow that asks first. + /// + /// Reports whether auto-start is currently enabled. + /// + /// + /// Display only, same caveat as : a packaged + /// query that fails reads as false, so this value must never be persisted to + /// SettingsData.AutoStart. + /// public static bool IsAutoStartEnabled() { if (PackageHelper.IsPackaged) @@ -54,11 +62,41 @@ public static Task SetAutoStartAsync(bool enable) => ? SetPackagedAutoStartAsync(enable) : Task.Run(() => SetUnpackagedAutoStart(enable)); + /// + /// Reports whether auto-start is currently enabled. + /// + /// + /// Suitable for display only. A packaged query that fails is reported as false, so + /// this value must never be persisted to SettingsData.AutoStart: doing so + /// turns a transient Windows failure into a permanent loss of the user's preference. + /// Use or + /// when the result will be stored. + /// public static Task IsAutoStartEnabledAsync() => PackageHelper.IsPackaged ? IsPackagedAutoStartEnabledAsync() : Task.Run(IsAutoStartEnabled); + /// + /// Returns the auto-start value to persist after a change attempt threw, rolling the + /// caller's optimistic write back only when Windows gives a definite answer. + /// + /// + /// See for the + /// policy. Unpackaged builds read the registry and scheduled task directly, which is + /// a local read with no transient-failure mode worth modelling. + /// + public static Task ResolveAutoStartAfterFailedChangeAsync(bool requested, Exception failure) + { + if (!PackageHelper.IsPackaged) + return Task.Run(IsAutoStartEnabled); + + return AutoStartReconciliation.ResolveAfterFailedChangeAsync( + requested, + failure, + QueryPackagedAutoStartAsync); + } + /// /// Reconciles the persisted auto-start preference against the real Windows startup /// state and returns the value the app should now report and store. @@ -71,38 +109,18 @@ public static Task IsAutoStartEnabledAsync() => /// user can also flip the task in Settings > Apps > Startup at any time. /// /// Windows is treated as the source of truth: the stored intent is applied when it - /// can be, and whatever Windows reports afterwards is what gets persisted. Enabling - /// is a request that Windows may refuse (DisabledByUser / DisabledByPolicy), and a - /// refusal must not be retried silently forever, so it is surfaced as false. + /// can be, and whatever Windows reports afterwards is what gets persisted. + /// owns the decision itself. /// - public static async Task ReconcileAutoStartAsync(bool configured) + public static Task ReconcileAutoStartAsync(bool configured) { if (!PackageHelper.IsPackaged) - return configured; - - try - { - var actual = await IsPackagedAutoStartEnabledAsync(); - if (actual == configured) - return configured; - - if (!configured) - { - // Windows says enabled while the app setting says off, which happens when - // the user enables the entry in Startup Apps. Report the truth instead of - // fighting Windows; the in-app toggle still pushes changes the other way. - Logger.Info("Auto-start is enabled in Windows; adopting that state."); - return true; - } + return Task.FromResult(configured); - await SetPackagedAutoStartAsync(true); - return true; - } - catch (Exception ex) - { - Logger.Warn($"Auto-start could not be reconciled, reporting disabled: {ex.Message}"); - return false; - } + return AutoStartReconciliation.ReconcileAsync( + configured, + QueryPackagedAutoStartAsync, + SetPackagedAutoStartAsync); } private static void SetUnpackagedAutoStart(bool enable) @@ -159,17 +177,22 @@ private static bool IsPackagedAutoStartEnabled() } } - private static async Task IsPackagedAutoStartEnabledAsync() + private static async Task IsPackagedAutoStartEnabledAsync() => + await QueryPackagedAutoStartAsync() == AutoStartState.Enabled; + + private static async Task QueryPackagedAutoStartAsync() { try { var startupTask = await StartupTask.GetAsync(AppIdentity.PackageStartupTaskId); - return startupTask.State is StartupTaskState.Enabled or StartupTaskState.EnabledByPolicy; + return startupTask.State is StartupTaskState.Enabled or StartupTaskState.EnabledByPolicy + ? AutoStartState.Enabled + : AutoStartState.Disabled; } catch (Exception ex) { Logger.Warn($"Failed to query packaged auto-start: {ex.Message}"); - return false; + return AutoStartState.Unknown; } } @@ -196,7 +219,7 @@ private static async Task SetPackagedAutoStartAsync(bool enable) return; } - throw new InvalidOperationException(state switch + throw new AutoStartRefusedException(state switch { StartupTaskState.DisabledByUser => "Windows startup is disabled by the user. Re-enable OpenClaw Companion in Settings > Apps > Startup.", diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs new file mode 100644 index 000000000..b22fd841c --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs @@ -0,0 +1,165 @@ +using OpenClaw.Shared; +using System; +using System.Threading.Tasks; + +namespace OpenClawTray.Services; + +/// +/// The auto-start state Windows reports, including the case where it could not be read. +/// +/// +/// exists so a transient query failure is never mistaken for +/// "Windows says disabled". Collapsing the two lets the app persist AutoStart=false +/// over an enabled preference, which the user cannot recover from without noticing and +/// re-toggling by hand. +/// +internal enum AutoStartState +{ + Enabled, + Disabled, + Unknown +} + +/// +/// Thrown when Windows explicitly refuses to enable the packaged startup task. +/// +/// +/// Distinct from a transient failure: a refusal (DisabledByUser / DisabledByPolicy) is a +/// durable answer that must be surfaced as disabled rather than retried at every launch. +/// Derives from so existing callers that catch +/// that type keep working. +/// +internal sealed class AutoStartRefusedException : InvalidOperationException +{ + public AutoStartRefusedException(string message) : base(message) + { + } +} + +/// +/// Decides which auto-start value the app should report and persist, given what Windows +/// says and what the user asked for. +/// +/// +/// Both entry points obey one rule: only a definite answer from Windows may overwrite +/// the user's intent. "Disabled" and an explicit refusal are definite. A failed or +/// unreadable query is not, and must leave the stored preference alone, because every +/// caller persists what it is given and the next launch treats that as intent. +/// +/// Kept free of WinRT so the policy can be unit tested directly. +/// owns the real StartupTask query and setter and supplies them here. +/// +internal static class AutoStartReconciliation +{ + /// + /// Reconciles the persisted preference against the real Windows startup state and + /// returns the value the app should now report and store. + /// + /// The preference currently stored in settings. + /// Reads what Windows reports now. + /// Asks Windows to enable auto-start. + /// + /// Windows is the source of truth: the stored intent is applied when it can be, and + /// whatever Windows reports afterwards is what gets persisted. Enabling is a request + /// Windows may refuse, and a refusal must not be retried silently at every launch, so + /// it is surfaced as false. + /// + /// A failure to read or apply the state is not a refusal, so it returns + /// unchanged and the caller persists nothing. + /// + internal static async Task ReconcileAsync( + bool configured, + Func> queryAsync, + Func setEnabledAsync) + { + var actual = await QueryOrUnknownAsync(queryAsync); + if (actual == AutoStartState.Unknown) + { + Logger.Warn($"Auto-start state is unknown, keeping the configured value ({configured})."); + return configured; + } + + var enabled = actual == AutoStartState.Enabled; + if (enabled == configured) + return configured; + + if (enabled) + { + // Windows says enabled while the app setting says off, which happens when the + // user enables the entry in Startup Apps. Report the truth instead of fighting + // Windows; the in-app toggle still pushes changes the other way. + Logger.Info("Auto-start is enabled in Windows; adopting that state."); + return true; + } + + // Configured on, Windows off: apply the stored intent. + try + { + await setEnabledAsync(true); + return true; + } + catch (AutoStartRefusedException ex) + { + Logger.Warn($"Windows refused to enable auto-start, reporting disabled: {ex.Message}"); + return false; + } + catch (Exception ex) + { + Logger.Warn($"Auto-start could not be enabled, keeping the configured value ({configured}): {ex.Message}"); + return configured; + } + } + + /// + /// Decides which auto-start value to persist after an attempt to change it threw. + /// + /// The value the user asked for, already written to settings. + /// The exception the change attempt threw. + /// Reads what Windows reports now. + /// + /// The caller writes optimistically and calls this to + /// decide whether to roll that write back. + /// + /// A refusal is definite, so the toggle is corrected to false rather than left + /// claiming an auto-start that will never happen. Any other failure means the change + /// may or may not have landed, so Windows is asked; if that cannot be determined + /// either, stands. + /// + internal static async Task ResolveAfterFailedChangeAsync( + bool requested, + Exception failure, + Func> queryAsync) + { + if (failure is AutoStartRefusedException) + { + Logger.Warn($"Windows refused the auto-start change, reporting disabled: {failure.Message}"); + return false; + } + + var actual = await QueryOrUnknownAsync(queryAsync); + if (actual == AutoStartState.Unknown) + { + Logger.Warn($"Auto-start state is unknown after a failed change, keeping the requested value ({requested})."); + return requested; + } + + return actual == AutoStartState.Enabled; + } + + /// + /// Runs the query, turning a thrown exception into + /// so callers handle "could not be read" in exactly one place. + /// + private static async Task QueryOrUnknownAsync(Func> queryAsync) + { + try + { + return await queryAsync(); + } + catch (Exception ex) + { + Logger.Warn($"Failed to read the auto-start state: {ex.Message}"); + return AutoStartState.Unknown; + } + } +} diff --git a/tests/OpenClaw.Tray.Tests/AutoStartReconciliationTests.cs b/tests/OpenClaw.Tray.Tests/AutoStartReconciliationTests.cs new file mode 100644 index 000000000..0e53ef109 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/AutoStartReconciliationTests.cs @@ -0,0 +1,216 @@ +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +/// +/// Pins , which decides which auto-start value the +/// app persists: ReconcileAsync at startup, and ResolveAfterFailedChangeAsync +/// after a change attempt throws. +/// +/// The regression these guard: a transient failure to read or apply the Windows startup +/// state used to be reported as "disabled", and both call sites persist what they are +/// given. That silently erased an enabled preference, and unrecoverably so, because the +/// next launch reads the overwritten false and reconciliation agrees with it. An explicit +/// refusal from Windows is a different answer and must still report disabled, otherwise +/// the toggle claims an auto-start that will never happen. +/// +public sealed class AutoStartReconciliationTests +{ + private static Func> Query(AutoStartState state) => () => Task.FromResult(state); + + private static Func> QueryThrows(Exception ex) => () => Task.FromException(ex); + + private static Func SetSucceeds() => _ => Task.CompletedTask; + + private static Func SetThrows(Exception ex) => _ => Task.FromException(ex); + + [Fact] + public async Task QueryThrows_KeepsEnabledPreference() + { + var result = await AutoStartReconciliation.ReconcileAsync( + configured: true, + QueryThrows(new InvalidOperationException("transient WinRT failure")), + SetSucceeds()); + + Assert.True(result); + } + + [Fact] + public async Task QueryUnknown_KeepsEnabledPreference() + { + var result = await AutoStartReconciliation.ReconcileAsync( + configured: true, + Query(AutoStartState.Unknown), + SetSucceeds()); + + Assert.True(result); + } + + [Fact] + public async Task QueryUnknown_KeepsDisabledPreference() + { + var result = await AutoStartReconciliation.ReconcileAsync( + configured: false, + Query(AutoStartState.Unknown), + SetSucceeds()); + + Assert.False(result); + } + + [Fact] + public async Task QueryFails_DoesNotAttemptToWriteState() + { + var setCalled = false; + + await AutoStartReconciliation.ReconcileAsync( + configured: true, + Query(AutoStartState.Unknown), + _ => + { + setCalled = true; + return Task.CompletedTask; + }); + + Assert.False(setCalled); + } + + [Fact] + public async Task ExplicitRefusal_ReportsDisabled() + { + var result = await AutoStartReconciliation.ReconcileAsync( + configured: true, + Query(AutoStartState.Disabled), + SetThrows(new AutoStartRefusedException("Windows startup is disabled by the user."))); + + Assert.False(result); + } + + [Fact] + public async Task EnableFailsWithoutRefusal_KeepsEnabledPreference() + { + var result = await AutoStartReconciliation.ReconcileAsync( + configured: true, + Query(AutoStartState.Disabled), + SetThrows(new IOException("transient failure while enabling"))); + + Assert.True(result); + } + + [Fact] + public async Task ConfiguredEnabledButWindowsDisabled_EnablesAndReportsEnabled() + { + var requested = (bool?)null; + + var result = await AutoStartReconciliation.ReconcileAsync( + configured: true, + Query(AutoStartState.Disabled), + enable => + { + requested = enable; + return Task.CompletedTask; + }); + + Assert.True(result); + Assert.True(requested); + } + + [Fact] + public async Task ConfiguredDisabledButWindowsEnabled_AdoptsWindowsState() + { + var setCalled = false; + + var result = await AutoStartReconciliation.ReconcileAsync( + configured: false, + Query(AutoStartState.Enabled), + _ => + { + setCalled = true; + return Task.CompletedTask; + }); + + Assert.True(result); + Assert.False(setCalled); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task StatesAlreadyAgree_LeavesPreferenceUnchanged(bool configured) + { + var setCalled = false; + var actual = configured ? AutoStartState.Enabled : AutoStartState.Disabled; + + var result = await AutoStartReconciliation.ReconcileAsync( + configured, + Query(actual), + _ => + { + setCalled = true; + return Task.CompletedTask; + }); + + Assert.Equal(configured, result); + Assert.False(setCalled); + } + + // ResolveAfterFailedChangeAsync: the rollback decision after a change attempt threw. + // The caller has already written the requested value, so these pin when that write is + // allowed to be overwritten. + + [Fact] + public async Task FailedChange_Refusal_RollsBackToDisabledWithoutQuerying() + { + var queried = false; + + var result = await AutoStartReconciliation.ResolveAfterFailedChangeAsync( + requested: true, + new AutoStartRefusedException("Windows startup is disabled by the user."), + () => + { + queried = true; + return Task.FromResult(AutoStartState.Enabled); + }); + + Assert.False(result); + Assert.False(queried); + } + + [Fact] + public async Task FailedChange_QueryThrows_KeepsRequestedValue() + { + var result = await AutoStartReconciliation.ResolveAfterFailedChangeAsync( + requested: true, + new IOException("transient failure while enabling"), + QueryThrows(new InvalidOperationException("transient WinRT failure"))); + + Assert.True(result); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task FailedChange_QueryUnknown_KeepsRequestedValue(bool requested) + { + var result = await AutoStartReconciliation.ResolveAfterFailedChangeAsync( + requested, + new IOException("transient failure"), + Query(AutoStartState.Unknown)); + + Assert.Equal(requested, result); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task FailedChange_DefiniteQuery_OverridesRequestedValue(bool windowsEnabled) + { + var state = windowsEnabled ? AutoStartState.Enabled : AutoStartState.Disabled; + + var result = await AutoStartReconciliation.ResolveAfterFailedChangeAsync( + requested: !windowsEnabled, + new IOException("transient failure"), + Query(state)); + + Assert.Equal(windowsEnabled, result); + } +} diff --git a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs index 551eb66ea..55dbe6312 100644 --- a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs +++ b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs @@ -225,7 +225,10 @@ public void PackagedAutoStart_UsesTheManifestStartupTask() root, "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); Assert.DoesNotContain("MigrateLegacyAutoStartAsync", app); Assert.Contains("await ApplyAutoStartCore(origin, !_settings.AutoStart);", app); - Assert.Contains("await AutoStartManager.IsAutoStartEnabledAsync()", app); + // The rollback after a failed change must not persist a raw query result: a failed + // query reads as "disabled" and would erase the preference the user just set. + Assert.Contains("await AutoStartManager.ResolveAutoStartAfterFailedChangeAsync(autoStart, ex)", app); + Assert.DoesNotContain("AutoStartManager.IsAutoStartEnabledAsync()", app); } [Fact] diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index dbac98134..d0b5aa8d7 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -106,6 +106,7 @@ + From 0b2860b83a102aebbd55b780dc7e31c1df49687c Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Thu, 10 Sep 2026 16:31:42 -0700 Subject: [PATCH 16/19] ci: drop the stale MSIX manifest patch step The paused build-msix job rewrote Identity/@Name to OpenClaw.Companion before packing. This branch moved packaging to OpenClawFoundation.OpenClaw, so the step no longer matched the tracked manifest and would have produced a package whose identity disagreed with the source it was built from. The step is also redundant. GitVersion.MsBuild populates $(Version) for every project in the repo, and PrepareOpenClawAppxManifest generates identity and version into obj/ without touching the tracked manifest. Verified locally by invoking that target with the job's exact arguments and no /p:Version, which produced Name="OpenClawFoundation.OpenClaw" and Version=2026.7.2.0. Alpha package identity is deliberately not replaced here. Restoring a prerelease variant belongs with re-enabling the job, tracked in #1375. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b95cf49-7892-48fe-8eda-b0b1541b52ed --- .github/workflows/ci.yml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38f631ffb..396b4c3df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -854,22 +854,6 @@ jobs: - name: Restore run: dotnet restore src/OpenClaw.Tray.WinUI -r ${{ matrix.rid }} - - name: Patch MSIX manifest metadata - shell: pwsh - run: | - $version = "${{ needs.metadata.outputs.majorMinorPatch }}.0" - $isAlpha = "${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, '-') }}" -eq "true" - $identityName = if ($isAlpha) { "OpenClaw.Companion.Alpha" } else { "OpenClaw.Companion" } - $displayName = if ($isAlpha) { "OpenClaw Companion Alpha" } else { "OpenClaw Companion" } - $manifest = "src/OpenClaw.Tray.WinUI/Package.appxmanifest" - [xml]$xml = Get-Content $manifest - $xml.Package.Identity.Name = $identityName - $xml.Package.Identity.Version = $version - $xml.Package.Properties.DisplayName = $displayName - $xml.Package.Applications.Application.VisualElements.DisplayName = $displayName - $xml.Save((Resolve-Path $manifest)) - Write-Host "Patched MSIX manifest to identity $identityName, display name '$displayName', version $version" - - name: Build MSIX Package run: > msbuild src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj From e2ca4e519cb8711677d9884f950edfdd1247f441 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Thu, 10 Sep 2026 16:32:07 -0700 Subject: [PATCH 17/19] fix(tray): serialize auto-start mutations during startup reconciliation Startup reconciliation read the stored preference, awaited a StartupTask query, then persisted the result. The Settings toggle is fire and forget, so a user flipping it while that query was in flight could have their choice overwritten by a decision derived from the value they had just replaced. ReconcileAsync also writes to Windows on the configured-on branch, so the stale decision could be pushed to Windows as well, not just to settings. Both mutation paths now take a shared gate, so their read-decide-write sequences cannot interleave and a toggle raised during reconciliation is applied afterwards and wins. The reconciled value is additionally re-checked against the current preference through the new AutoStartReconciliation.ShouldPersistReconciledValue policy, which covers writes that reach settings without passing through the gate. The saved event is raised after the gate is released. Subscribers apply auto-start themselves, and SemaphoreSlim is not reentrant, so raising it while holding the gate would deadlock the moment a subscriber routed through ApplyAutoStartCore. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b95cf49-7892-48fe-8eda-b0b1541b52ed --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 57 +++++++++++++---- .../Services/AutoStartReconciliation.cs | 17 ++++++ .../AutoStartReconciliationTests.cs | 61 +++++++++++++++++++ .../MsixDevelopmentSigningTests.cs | 8 +++ 4 files changed, 132 insertions(+), 11 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 7564aad75..66f658213 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -222,6 +222,12 @@ public IntPtr GetHubWindowHandle() => private SettingsWriteOrigin? _appCapabilityPermissionWriteOrigin; private SettingsWriteOrigin? _trayAutoStartWriteOrigin; + /// + /// Serializes auto-start mutations so startup reconciliation and a user toggle cannot + /// interleave their read-decide-write sequences against Windows and settings. + /// + private readonly SemaphoreSlim _autoStartMutationGate = new(1, 1); + // FrozenDictionary for O(1) case-insensitive notification type → setting lookup — no per-call allocation. private static readonly System.Collections.Frozen.FrozenDictionary> s_notifTypeMap = new Dictionary>(StringComparer.OrdinalIgnoreCase) @@ -4031,6 +4037,7 @@ public async Task ApplyAutoStart(SettingsWriteOrigin origin, bool autoStar private async Task ApplyAutoStartCore(SettingsWriteOrigin? origin, bool autoStart) { if (_settings == null) return false; + await _autoStartMutationGate.WaitAsync(); try { if (SettingsStore is { } store) @@ -4062,32 +4069,60 @@ private async Task ApplyAutoStartCore(SettingsWriteOrigin? origin, bool au } return false; } + finally + { + _autoStartMutationGate.Release(); + } } /// /// Aligns the stored auto-start preference with the state Windows actually reports, /// so the Settings toggle never claims auto-start is on while nothing launches at logon. /// + /// + /// Runs under so the query-then-set sequence cannot + /// interleave with a user toggle: a toggle raised while this is in flight is applied after + /// it, and therefore wins. The preference is re-read before persisting as well, to cover + /// writes that reach settings without passing through the gate. The saved event is raised + /// after the gate is released, because subscribers apply auto-start themselves and must + /// not re-enter a non-reentrant gate. + /// private async Task ReconcileAutoStartOnStartupAsync() { if (_settings == null) return; - var configured = _settings.AutoStart; - var effective = await AutoStartManager.ReconcileAutoStartAsync(configured); - if (effective == configured) return; - - Logger.Info($"Auto-start setting corrected from {configured} to {effective} to match Windows."); - if (SettingsStore is { } store) + var persisted = false; + await _autoStartMutationGate.WaitAsync(); + try { - store.Update(null, edit => edit.AutoStart = effective); + var configured = _settings.AutoStart; + var effective = await AutoStartManager.ReconcileAutoStartAsync(configured); + + if (!AutoStartReconciliation.ShouldPersistReconciledValue(configured, _settings.AutoStart, effective)) + return; + + Logger.Info($"Auto-start setting corrected from {configured} to {effective} to match Windows."); + if (SettingsStore is { } store) + { + store.Update(null, edit => edit.AutoStart = effective); + } + else + { + _settings.AutoStart = effective; + _settings.Save(); + } + + persisted = true; } - else + finally { - _settings.AutoStart = effective; - _settings.Save(); + _autoStartMutationGate.Release(); } - OnSettingsSaved(this, EventArgs.Empty); + if (persisted) + { + OnSettingsSaved(this, EventArgs.Empty); + } } private void OpenLogFile() diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs index b22fd841c..a9d9d97fd 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs @@ -51,6 +51,23 @@ public AutoStartRefusedException(string message) : base(message) /// internal static class AutoStartReconciliation { + /// + /// Decides whether a startup reconciliation result may still be persisted. + /// + /// The preference read when reconciliation began. + /// The preference as it stands now, after the Windows query. + /// The value reconciliation arrived at. + /// + /// Reconciliation reads the stored preference, then awaits a StartupTask query. The + /// mutation gate keeps the Settings toggle from interleaving with that sequence, but + /// settings can still be written by origins that never take the gate. Persisting the + /// result unconditionally would apply a decision derived from a value that has since been + /// replaced, so a preference that moved while the query was in flight is left alone and + /// the reconciliation result is discarded. + /// + internal static bool ShouldPersistReconciledValue(bool captured, bool current, bool reconciled) + => current == captured && reconciled != captured; + /// /// Reconciles the persisted preference against the real Windows startup state and /// returns the value the app should now report and store. diff --git a/tests/OpenClaw.Tray.Tests/AutoStartReconciliationTests.cs b/tests/OpenClaw.Tray.Tests/AutoStartReconciliationTests.cs index 0e53ef109..b25e08b1c 100644 --- a/tests/OpenClaw.Tray.Tests/AutoStartReconciliationTests.cs +++ b/tests/OpenClaw.Tray.Tests/AutoStartReconciliationTests.cs @@ -213,4 +213,65 @@ public async Task FailedChange_DefiniteQuery_OverridesRequestedValue(bool window Assert.Equal(windowsEnabled, result); } + + /// + /// Startup reconciliation reads the stored preference, then awaits a StartupTask query. + /// If the preference is rewritten while that query is in flight, persisting the result of + /// the stale read would undo the newer value, so it is re-checked first. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void PreferenceChangedDuringQuery_DiscardsReconciledValue(bool captured) + { + var shouldPersist = AutoStartReconciliation.ShouldPersistReconciledValue( + captured: captured, + current: !captured, + reconciled: !captured); + + Assert.False(shouldPersist); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void PreferenceUnchanged_PersistsDifferingReconciledValue(bool captured) + { + var shouldPersist = AutoStartReconciliation.ShouldPersistReconciledValue( + captured: captured, + current: captured, + reconciled: !captured); + + Assert.True(shouldPersist); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReconciledValueMatchesPreference_PersistsNothing(bool captured) + { + var shouldPersist = AutoStartReconciliation.ShouldPersistReconciledValue( + captured: captured, + current: captured, + reconciled: captured); + + Assert.False(shouldPersist); + } + + /// + /// The reconciled value agreeing with where the preference landed is not a reason to + /// write it: the newer write already persisted that value through its own path. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void PreferenceChangedToReconciledValue_StillDiscards(bool captured) + { + var shouldPersist = AutoStartReconciliation.ShouldPersistReconciledValue( + captured: captured, + current: !captured, + reconciled: captured); + + Assert.False(shouldPersist); + } } diff --git a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs index 55dbe6312..aa8cbf290 100644 --- a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs +++ b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs @@ -255,6 +255,14 @@ public void PackagedAutoStart_IsReconciledWithWindowsAtStartup() // A refusal from Windows (DisabledByUser / DisabledByPolicy) must be persisted as // false rather than retried silently, so the toggle tells the truth. Assert.Contains("edit.AutoStart = effective", app); + + // Reconciliation reads the preference, then awaits a StartupTask query. Both mutation + // paths take the gate so their read-decide-write sequences cannot interleave, and the + // result is re-checked against the current preference before it is persisted, to cover + // writes that reach settings without taking the gate. Without both, a stale decision + // silently overwrites the newer choice, or is pushed to Windows on the user's behalf. + Assert.Contains("_autoStartMutationGate", app); + Assert.Contains("AutoStartReconciliation.ShouldPersistReconciledValue(configured, _settings.AutoStart, effective)", app); } [Fact] From ed3b5656ded8985fe6017730ebde55acc19950ad Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Thu, 10 Sep 2026 16:32:27 -0700 Subject: [PATCH 18/19] build(msix): use the reserved OpenClaw name for package display names OpenClaw is the name reserved in Partner Center, so the Store listing, the Start menu tile, and Startup Apps should all read OpenClaw rather than OpenClaw Companion. Only display names change. Package identity is Identity/@Name plus @Publisher, neither of which is touched, so upgrade behavior, pairing, and the Store submission path are unaffected. The Inno product name, the "OpenClaw Companion" scheduled task name, and the assembly metadata are deliberately left alone: the task name is identity bearing and renaming it would orphan scheduled tasks on existing installs, which a packaged build is forbidden from migrating. Docs continue to say OpenClaw Companion in prose. That still describes the Inno product accurately, and this rename is scoped to MSIX packaging. Adds a test pinning all three strings. Display names are labels only, so nothing else in the suite would catch a silent revert. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b95cf49-7892-48fe-8eda-b0b1541b52ed --- .../OpenClaw.Tray.WinUI.csproj | 2 +- src/OpenClaw.Tray.WinUI/Package.appxmanifest | 6 +++--- .../MsixDevelopmentSigningTests.cs | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index 3fd19bbd9..c555911a9 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -278,7 +278,7 @@ DevBuild="$(DevBuild)" IdentityName="OpenClawFoundation.OpenClaw.Dev" Publisher="$(OpenClawDevMsixPublisher)" - DisplayName="OpenClaw Companion (Dev)" + DisplayName="OpenClaw (Dev)" ProtocolName="openclaw-dev" ToastActivatorClsid="C536D4AD-19BE-4F7A-B227-AB97629BF299" /> diff --git a/src/OpenClaw.Tray.WinUI/Package.appxmanifest b/src/OpenClaw.Tray.WinUI/Package.appxmanifest index b4a9b1e29..d86df3345 100644 --- a/src/OpenClaw.Tray.WinUI/Package.appxmanifest +++ b/src/OpenClaw.Tray.WinUI/Package.appxmanifest @@ -22,7 +22,7 @@ Version="0.0.0.0" /> - OpenClaw Companion + OpenClaw OpenClaw Foundation Assets\StoreLogo.png @@ -44,7 +44,7 @@ Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$"> + DisplayName="OpenClaw" /> diff --git a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs index aa8cbf290..642010cfa 100644 --- a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs +++ b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs @@ -293,6 +293,25 @@ public void DevManifest_RewritesTheStartupTaskDisplayName() Assert.Contains("StartupTask/@DisplayName missing from", project); } + [Fact] + public void PackageDisplayNames_MatchThePartnerCenterReservation() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var manifest = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "Package.appxmanifest")); + var project = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.csproj")); + + // "OpenClaw" is the reserved Partner Center name, and these strings are what the + // Store listing, the Start menu tile, and Startup Apps display. Package identity is + // Identity/@Name plus @Publisher, so display names are labels only and changing them + // breaks nothing, which is precisely why a silent revert would otherwise go unnoticed. + Assert.Contains("OpenClaw", manifest); + Assert.Contains(@"DisplayName=""OpenClaw""", manifest); + Assert.DoesNotContain("OpenClaw Companion", manifest); + Assert.Contains(@"DisplayName=""OpenClaw (Dev)""", project); + } + [Fact] public void PackagedBuildsDeferUpdatesToTheStore() { From baa62869099a3da32cddba1a6b7b904fb310981b Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Thu, 10 Sep 2026 17:57:32 -0700 Subject: [PATCH 19/19] fix(tray): gate settings-save auto-start writes Route post-save auto-start effects through the shared toggle and startup reconciliation gate. Read the live preference after acquiring the gate and hold it until the Windows write completes, preserving fault observation. Extract the narrow AutoStartSettingsApplier seam, cover queued reads, asynchronous write ordering, and failure cleanup, and record the ownership boundary in the architecture ledger. Validation: full build passed; Shared 3989 passed, 34 skipped; Tray 2942 passed; focused auto-start/settings coverage 153 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1b95cf49-7892-48fe-8eda-b0b1541b52ed --- docs/ARCHITECTURE.md | 2 + .../App.SettingsChangeCoordinator.cs | 6 +- .../Services/AutoStartSettingsApplier.cs | 25 ++++ .../AutoStartSettingsApplierTests.cs | 128 ++++++++++++++++++ .../MsixDevelopmentSigningTests.cs | 30 ++++ .../OpenClaw.Tray.Tests.csproj | 1 + 6 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 src/OpenClaw.Tray.WinUI/Services/AutoStartSettingsApplier.cs create mode 100644 tests/OpenClaw.Tray.Tests/AutoStartSettingsApplierTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 86f47dbb6..a84b9ab93 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -196,6 +196,8 @@ leading and trailing pipe. Columns, in order: | app-activation-router-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | concrete deep-link IPC, toast argument routing, and single-instance forwarding production logic | ActivationRouter | App.ActivationRouter.cs implements IActivationPlanSink only, dispatching one typed plan per activation | App does not regain a parallel activation production path outside ActivationRouter | AppRefactorContractTests.ToastActivation_RoutesOnUiThread | source-shape | when App is replaced as the WinUI composition root | | app-settings-change-coordinator | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | detached snapshot comparison, SettingsChangeClassifier use, concurrent save serialization, and the full post-save effect order | SettingsChangeCoordinator | App supplies the existing effects as delegates and triggers synchronous Apply from one explicit post-save call | browser proxy sync, reconnect, MCP, hotkey, autostart, telemetry, and surface notification order is preserved; MCP-only behavior and credential precedence are unaffected | SettingsChangeCoordinatorTests.Apply_GatewayUrlChange_PreparesBeforeReconnect | behavioral | - | | app-settings-change-coordinator-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | OnSettingsSaved impact classification, reconnect switch, and inline effect ordering | SettingsChangeCoordinator | App.SettingsChangeCoordinator.cs wires effect delegates only; OnSettingsSaved forwards to Apply | App does not regain a parallel settings-change orchestration path outside SettingsChangeCoordinator | PresentationSeamContractTests.App_AppliesToolCallVisibilityFromPersistedSettings | source-shape | when App is replaced as the WinUI composition root | +| autostart-settings-applier | authoritative | src/OpenClaw.Tray.WinUI/App.SettingsChangeCoordinator.cs | post-save auto-start preference read and Windows write | AutoStartSettingsApplier | App supplies its shared mutation gate, live preference reader, OS setter, and background fault observer | settings-save effects read the current preference only after acquiring the toggle and reconciliation gate and hold it until the OS write completes | AutoStartSettingsApplierTests.ApplyLatestAsync_QueuedSave_ReadsPreferenceAfterGateAcquisition | behavioral | - | +| autostart-settings-direct-write-closed | closed | src/OpenClaw.Tray.WinUI/App.SettingsChangeCoordinator.cs | direct ungated auto-start write from a saved SettingsData snapshot | AutoStartSettingsApplier | effect delegate wiring only; startup reconciliation and explicit toggles retain their existing shared gate | post-save effects cannot replay stale snapshots over newer toggle or reconciliation results | MsixDevelopmentSigningTests.SettingsSaveAutoStart_UsesSharedGateAndLivePreference | source-shape | when the WinUI adapter is exercised directly by behavioral tests | | app-shutdown-coordinator | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | first-wins shared shutdown task, ordered step execution, and per-step log/catch/continue | AppShutdownCoordinator | App builds the immutable step plan from services it owns, including activation null-before-await and failure-safe captured-resource nulling, and constructs the BeginShutdown/ExitApplication actions | shutdown steps run in the same order exactly once even under concurrent callers; each step logs and continues past failure; Exit is called exactly once after all steps | AppShutdownCoordinatorTests.ShutdownAsync_RunsBeginStepsThenExit_InOrder | behavioral | - | | app-shutdown-coordinator-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | the _isExiting bool guard, SafeShutdownStep/SafeShutdownStepAsync helpers, and inline ExitApplicationAsync body | AppShutdownCoordinator | App.AppShutdownCoordinator.cs builds the step plan only; ExitApplicationAsync forwards to ShutdownAsync | App does not regain a parallel exactly-once shutdown guard or step-execution loop outside AppShutdownCoordinator | AppRefactorContractTests.Shutdown_Order_PreservesAwaitedTeardownBeforeExit | source-shape | when App is replaced as the WinUI composition root | | gateway-pending-requests | authoritative | src/OpenClaw.Shared/OpenClawGatewayClient.cs | request-id to method/completion tracking | PendingRequestRegistry | callers create request ids, choose timeout policy, parse and route responses, and use the transport | request ids do not leak after disconnect; the registry remains thread-safe with exactly one terminal completion | PendingRequestRegistryTests.ResponseVersusDrain_ExactlyOneTerminalOutcomeWins | behavioral | - | diff --git a/src/OpenClaw.Tray.WinUI/App.SettingsChangeCoordinator.cs b/src/OpenClaw.Tray.WinUI/App.SettingsChangeCoordinator.cs index 1d339ac69..2b55ee5fc 100644 --- a/src/OpenClaw.Tray.WinUI/App.SettingsChangeCoordinator.cs +++ b/src/OpenClaw.Tray.WinUI/App.SettingsChangeCoordinator.cs @@ -132,7 +132,11 @@ private void ApplyGlobalHotkey(SettingsData settings) private void ApplyAutoStartAndTelemetry(SettingsData settings) { ObserveBackgroundFault( - AutoStartManager.SetAutoStartAsync(settings.AutoStart), + AutoStartSettingsApplier.ApplyLatestAsync( + _autoStartMutationGate, + () => (_settings ?? throw new InvalidOperationException( + "Settings are unavailable while applying auto-start.")).AutoStart, + AutoStartManager.SetAutoStartAsync), "[App] Failed to apply auto-start setting"); ApplyOpenTelemetryEndpointSettings(); } diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartSettingsApplier.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartSettingsApplier.cs new file mode 100644 index 000000000..2a09d94ce --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartSettingsApplier.cs @@ -0,0 +1,25 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenClawTray.Services; + +internal static class AutoStartSettingsApplier +{ + internal static async Task ApplyLatestAsync( + SemaphoreSlim mutationGate, + Func readPreference, + Func setEnabledAsync) + { + await mutationGate.WaitAsync(); + try + { + // A queued settings-save effect must read after any toggle or reconciliation finishes. + await setEnabledAsync(readPreference()); + } + finally + { + mutationGate.Release(); + } + } +} diff --git a/tests/OpenClaw.Tray.Tests/AutoStartSettingsApplierTests.cs b/tests/OpenClaw.Tray.Tests/AutoStartSettingsApplierTests.cs new file mode 100644 index 000000000..442552df0 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/AutoStartSettingsApplierTests.cs @@ -0,0 +1,128 @@ +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +public sealed class AutoStartSettingsApplierTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ApplyLatestAsync_QueuedSave_ReadsPreferenceAfterGateAcquisition(bool initial) + { + using var gate = new SemaphoreSlim(1, 1); + var preference = initial; + var reads = 0; + var writes = new List(); + + await gate.WaitAsync(); + var pending = AutoStartSettingsApplier.ApplyLatestAsync( + gate, + () => + { + reads++; + return preference; + }, + enabled => + { + writes.Add(enabled); + return Task.CompletedTask; + }); + try + { + Assert.False(pending.IsCompleted); + Assert.Equal(0, reads); + Assert.Empty(writes); + preference = !initial; + } + finally + { + gate.Release(); + } + + await pending.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(1, reads); + Assert.Equal(new[] { !initial }, writes); + Assert.Equal(1, gate.CurrentCount); + } + + [Fact] + public async Task ApplyLatestAsync_HoldsGateUntilWindowsWriteCompletes() + { + using var gate = new SemaphoreSlim(1, 1); + var releaseFirstWrite = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var writes = new List(); + var preference = true; + var secondRead = false; + + var first = AutoStartSettingsApplier.ApplyLatestAsync( + gate, + () => preference, + async enabled => + { + await releaseFirstWrite.Task; + writes.Add(enabled); + }); + + preference = false; + var second = AutoStartSettingsApplier.ApplyLatestAsync( + gate, + () => + { + secondRead = true; + return preference; + }, + enabled => + { + writes.Add(enabled); + return Task.CompletedTask; + }); + try + { + Assert.False(first.IsCompleted); + Assert.False(second.IsCompleted); + Assert.False(secondRead); + Assert.Empty(writes); + } + finally + { + releaseFirstWrite.SetResult(); + } + + await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(new[] { true, false }, writes); + Assert.Equal(1, gate.CurrentCount); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ApplyLatestAsync_FailureIsPropagatedAndReleasesGate(bool readFails) + { + using var gate = new SemaphoreSlim(1, 1); + var failure = new InvalidOperationException("injected auto-start failure"); + var failing = AutoStartSettingsApplier.ApplyLatestAsync( + gate, + () => readFails ? throw failure : true, + _ => Task.FromException(failure)); + + var actual = await Assert.ThrowsAsync( + () => failing.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.Same(failure, actual); + Assert.Equal(1, gate.CurrentCount); + + bool? applied = null; + await AutoStartSettingsApplier.ApplyLatestAsync( + gate, + () => false, + enabled => + { + applied = enabled; + return Task.CompletedTask; + }).WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(false, applied); + Assert.Equal(1, gate.CurrentCount); + } +} diff --git a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs index 642010cfa..4262634b2 100644 --- a/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs +++ b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs @@ -265,6 +265,36 @@ public void PackagedAutoStart_IsReconciledWithWindowsAtStartup() Assert.Contains("AutoStartReconciliation.ShouldPersistReconciledValue(configured, _settings.AutoStart, effective)", app); } + [Fact] + public void SettingsSaveAutoStart_UsesSharedGateAndLivePreference() + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + var app = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + var effects = File.ReadAllText(Path.Combine( + root, "src", "OpenClaw.Tray.WinUI", "App.SettingsChangeCoordinator.cs")); + + // Keep the WinUI adapter on the behaviorally tested path, not the saved snapshot. + Assert.Contains("AutoStartSettingsApplier.ApplyLatestAsync(", effects); + Assert.Contains("_autoStartMutationGate,", effects); + Assert.Contains("() => (_settings ?? throw new InvalidOperationException(", effects); + Assert.Contains(")).AutoStart,", effects); + Assert.Contains("AutoStartManager.SetAutoStartAsync)", effects); + Assert.DoesNotContain("settings.AutoStart", effects); + Assert.DoesNotContain("AutoStartManager.SetAutoStartAsync(", effects); + Assert.Contains("ObserveBackgroundFault(", effects); + + var toggle = app[app.IndexOf("private async Task ApplyAutoStartCore(", StringComparison.Ordinal).. + app.IndexOf("private async Task ReconcileAutoStartOnStartupAsync()", StringComparison.Ordinal)]; + var reconcile = app[app.IndexOf("private async Task ReconcileAutoStartOnStartupAsync()", StringComparison.Ordinal).. + app.IndexOf("private void OpenLogFile()", StringComparison.Ordinal)]; + foreach (var mutation in new[] { toggle, reconcile }) + { + Assert.Contains("await _autoStartMutationGate.WaitAsync();", mutation); + Assert.Contains("_autoStartMutationGate.Release();", mutation); + } + } + [Fact] public void StoreMsixPackaging_RefusesDebugConfigurations() { diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 461403ceb..63a59eb10 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -107,6 +107,7 @@ +