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 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c9b89eb95..e0549ed58 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -208,6 +208,160 @@ 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. + +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 +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 | `OpenClawFoundation.OpenClaw.Dev` | `OpenClawFoundation.OpenClaw` | +| 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-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-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` +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. `Identity/@Name`, +`Identity/@Publisher`, and `Properties/PublisherDisplayName` in +`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; +without it the build logs a warning and skips symbols. + +#### 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 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 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. + +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 in #1374 and are not implemented here. + #### 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/build.ps1 b/build.ps1 index 5256cbf3a..60eaeda38 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 } @@ -304,6 +349,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" @@ -357,7 +440,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)) { @@ -365,14 +448,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 "OpenClawFoundation.OpenClaw.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 OpenClawFoundation.OpenClaw.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 @@ -425,6 +543,40 @@ $projects = @{ "SetupEngine" = @{ Path = "src/OpenClaw.SetupEngine/OpenClaw.SetupEngine.csproj"; UseRid = $false } } +if ($buildStoreMsix) { + # 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-StoreMsix.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 "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 +} + $toBuild = if ($Project -eq "All") { @("Shared", "Cli", "WinNodeCli", "SetupEngine", "WinUI") } else { @($Project) } # Always build Shared first if building other projects @@ -436,7 +588,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 @@ -467,6 +620,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/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/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/scripts/Build-StoreMsix.ps1 b/scripts/Build-StoreMsix.ps1 new file mode 100644 index 000000000..adbd8d06d --- /dev/null +++ b/scripts/Build-StoreMsix.ps1 @@ -0,0 +1,338 @@ +<# +.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. 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 + 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-StoreMsix.ps1 -Architecture x64 + .\scripts\Build-StoreMsix.ps1 -Architecture arm64 + .\build.ps1 -Project WinUI -Msix Store +#> +[CmdletBinding()] +param( + [ValidateSet('x64', 'arm64')] + [string]$Architecture = 'x64', + + # 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 +) + +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/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/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index fffbfaf13..66f658213 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -220,6 +220,13 @@ public IntPtr GetHubWindowHandle() => private string? _lastManagerConnectedSideEffectsKey; private SettingsWriteOrigin? _trayPermissionWriteOrigin; 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 = @@ -853,6 +860,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 @@ -4001,9 +4017,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,8 +4032,12 @@ 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; + await _autoStartMutationGate.WaitAsync(); try { if (SettingsStore is { } store) @@ -4035,8 +4057,72 @@ public async Task ApplyAutoStart(SettingsWriteOrigin origin, bool autoStar catch (Exception ex) { Logger.Error($"ApplyAutoStart failed: {ex.Message}"); + var effectiveAutoStart = await AutoStartManager.ResolveAutoStartAfterFailedChangeAsync(autoStart, ex); + if (SettingsStore is { } store) + { + store.Update(origin, edit => edit.AutoStart = effectiveAutoStart); + } + else if (_settings != null) + { + _settings.AutoStart = effectiveAutoStart; + _settings.Save(); + } 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 persisted = false; + await _autoStartMutationGate.WaitAsync(); + try + { + 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; + } + finally + { + _autoStartMutationGate.Release(); + } + + if (persisted) + { + OnSettingsSaved(this, EventArgs.Empty); + } } private void OpenLogFile() diff --git a/src/OpenClaw.Tray.WinUI/AppIdentity.cs b/src/OpenClaw.Tray.WinUI/AppIdentity.cs index f009f5374..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"; @@ -25,6 +29,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"; @@ -52,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"; @@ -64,6 +75,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/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index 0bff7a1ab..c555911a9 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,14 +57,21 @@ None true - app.manifest 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 @@ -134,8 +146,10 @@ + + @@ -165,6 +179,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); @@ -191,6 +214,31 @@ return false; } 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); + 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)); @@ -204,25 +252,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 - + + + + IdentityName="OpenClawFoundation.OpenClaw.Dev" + Publisher="$(OpenClawDevMsixPublisher)" + DisplayName="OpenClaw (Dev)" + ProtocolName="openclaw-dev" + ToastActivatorClsid="C536D4AD-19BE-4F7A-B227-AB97629BF299" /> @@ -295,6 +353,22 @@ + + + + <_WxcExecPackageFiles Include="$(MxcSdkBinDir)wxc-exec.exe" /> + <_WxcExecPackageFiles Include="$(MxcSdkBinDir)*.dll" /> + + tools\mxc\$(MxcArch)\%(Filename)%(Extension) + PreserveNewest + + + + + - OpenClaw Companion + OpenClaw OpenClaw Foundation Assets\StoreLogo.png + @@ -36,7 +44,7 @@ Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$"> OpenClaw Protocol + + + + + + + + + + + + + + + + + + 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..d3b2ec606 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,25 @@ 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. + /// + /// 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) + return IsPackagedAutoStartEnabled(); + try { using var key = Registry.CurrentUser.OpenSubKey(RegistryKey, false); @@ -29,6 +47,83 @@ 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)); + + /// + /// 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. + /// + /// + /// 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. + /// owns the decision itself. + /// + public static Task ReconcileAutoStartAsync(bool configured) + { + if (!PackageHelper.IsPackaged) + return Task.FromResult(configured); + + return AutoStartReconciliation.ReconcileAsync( + configured, + QueryPackagedAutoStartAsync, + SetPackagedAutoStartAsync); + } + + private static void SetUnpackagedAutoStart(bool enable) { try { @@ -65,8 +160,74 @@ 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() => + 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 + ? AutoStartState.Enabled + : AutoStartState.Disabled; + } + catch (Exception ex) + { + Logger.Warn($"Failed to query packaged auto-start: {ex.Message}"); + return AutoStartState.Unknown; + } + } + + 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 AutoStartRefusedException(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/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs new file mode 100644 index 000000000..a9d9d97fd --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartReconciliation.cs @@ -0,0 +1,182 @@ +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 +{ + /// + /// 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. + /// + /// 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/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/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/AutoStartReconciliationTests.cs b/tests/OpenClaw.Tray.Tests/AutoStartReconciliationTests.cs new file mode 100644 index 000000000..b25e08b1c --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/AutoStartReconciliationTests.cs @@ -0,0 +1,277 @@ +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); + } + + /// + /// 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/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/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs index aaca7b649..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); @@ -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/MsixDevelopmentSigningTests.cs b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs new file mode 100644 index 000000000..4262634b2 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/MsixDevelopmentSigningTests.cs @@ -0,0 +1,379 @@ +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 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() + { + 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); + // 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] + 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); + + // 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] + 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() + { + 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 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 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() + { + 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)); + } + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index e8765319a..63a59eb10 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -106,6 +106,8 @@ + + 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); } } 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() {