diff --git a/.github/workflows/appinstaller-feed-pr.yml b/.github/workflows/appinstaller-feed-pr.yml new file mode 100644 index 000000000..05bf55706 --- /dev/null +++ b/.github/workflows/appinstaller-feed-pr.yml @@ -0,0 +1,169 @@ +name: AppInstaller Feed PR + +on: + workflow_dispatch: + inputs: + tag: + description: Release tag whose signed MSIX assets should advance the stable AppInstaller feed + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + update-feed: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v6 + with: + ref: master + fetch-depth: 0 + + - name: Render stable AppInstaller feed files + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + + $repo = '${{ github.repository }}' + $tag = '${{ inputs.tag }}'.Trim() + if ([string]::IsNullOrWhiteSpace($tag)) { + throw "tag input is required." + } + if (-not $tag.StartsWith('v', [StringComparison]::OrdinalIgnoreCase)) { + throw "Release tag must start with 'v'. Got '$tag'." + } + if ($tag.Contains('-')) { + throw "Pre-release AppInstaller feed updates are blocked until alpha channel policy is decided. Tag: $tag" + } + + $release = gh release view $tag --repo $repo --json tagName,isPrerelease,assets | ConvertFrom-Json + if ($release.isPrerelease) { + throw "Pre-release AppInstaller feed updates are blocked until alpha channel policy is decided. Tag: $tag" + } + + $versionText = $tag.Substring(1) + if ($versionText -notmatch '^\d+\.\d+\.\d+$') { + throw "Stable release tag must be vX.Y.Z so the MSIX/AppInstaller version can be rendered as X.Y.Z.0. Got '$tag'." + } + $version = "$versionText.0" + $publisher = 'CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US' + $identityName = 'OpenClaw.Companion' + $feedDir = 'installer\appinstaller' + New-Item -ItemType Directory -Force -Path $feedDir | Out-Null + + function Get-RequiredAsset { + param([Parameter(Mandatory)] [string] $Pattern) + $matches = @($release.assets | Where-Object { $_.name -like $Pattern }) + if ($matches.Count -ne 1) { + $available = ($release.assets | ForEach-Object { $_.name }) -join ', ' + throw "Expected exactly one release asset matching '$Pattern' for $tag; found $($matches.Count). Assets: $available" + } + return $matches[0] + } + + function Get-ReleaseAssetUri { + param([Parameter(Mandatory)] [string] $AssetName) + $escapedName = [Uri]::EscapeDataString($AssetName) + return "https://github.com/$repo/releases/download/$tag/$escapedName" + } + + $x64Asset = Get-RequiredAsset -Pattern "OpenClawCompanion-$versionText-win-x64.msix" + $arm64Asset = Get-RequiredAsset -Pattern "OpenClawCompanion-$versionText-win-arm64.msix" + $x64RuntimeAsset = Get-RequiredAsset -Pattern "Microsoft.WindowsAppRuntime.2-2.0.1.0-win-x64.msix" + $arm64RuntimeAsset = Get-RequiredAsset -Pattern "Microsoft.WindowsAppRuntime.2-2.0.1.0-win-arm64.msix" + $x64Uri = Get-ReleaseAssetUri -AssetName $x64Asset.name + $arm64Uri = Get-ReleaseAssetUri -AssetName $arm64Asset.name + $x64RuntimeUri = Get-ReleaseAssetUri -AssetName $x64RuntimeAsset.name + $arm64RuntimeUri = Get-ReleaseAssetUri -AssetName $arm64RuntimeAsset.name + + $rawBase = "https://raw.githubusercontent.com/$repo/master/installer/appinstaller" + $x64FeedPath = Join-Path $feedDir 'openclaw-x64.appinstaller' + $arm64FeedPath = Join-Path $feedDir 'openclaw-arm64.appinstaller' + + .\scripts\render-appinstaller.ps1 ` + -Version $version ` + -Publisher $publisher ` + -IdentityName $identityName ` + -ProcessorArchitecture x64 ` + -MsixUri $x64Uri ` + -WindowsAppRuntimeUri $x64RuntimeUri ` + -AppInstallerUri "$rawBase/openclaw-x64.appinstaller" ` + -OutputPath $x64FeedPath + + .\scripts\render-appinstaller.ps1 ` + -Version $version ` + -Publisher $publisher ` + -IdentityName $identityName ` + -ProcessorArchitecture arm64 ` + -MsixUri $arm64Uri ` + -WindowsAppRuntimeUri $arm64RuntimeUri ` + -AppInstallerUri "$rawBase/openclaw-arm64.appinstaller" ` + -OutputPath $arm64FeedPath + + .\scripts\validate-appinstaller-hosting.ps1 ` + -AppInstallerPath $x64FeedPath ` + -MsixUri $x64Uri ` + -AllowGitHubContentTypes + .\scripts\validate-appinstaller-hosting.ps1 ` + -AppInstallerPath $arm64FeedPath ` + -MsixUri $arm64Uri ` + -AllowGitHubContentTypes + + $branch = "automation/appinstaller-feed-$($tag -replace '[^A-Za-z0-9._-]', '-')" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git checkout -B $branch + git add $feedDir + + $changes = git status --short -- $feedDir + if ([string]::IsNullOrWhiteSpace($changes)) { + Write-Host "Stable AppInstaller feed already points at $tag; no PR needed." + exit 0 + } + + git commit -m "chore(msix): update AppInstaller feed for $tag" ` + -m "Advance the stable Windows AppInstaller feed to the signed MSIX assets from $tag." ` + -m "Merging this PR advances the auto-update source for installed MSIX clients." + git push --force-with-lease origin $branch + + $bodyPath = Join-Path $env:RUNNER_TEMP 'appinstaller-feed-pr.md' + @" + Updates the stable Windows AppInstaller feed files for `$tag`. + + Merging this PR advances installed MSIX clients that poll the stable feed: + + - x64 feed: `$rawBase/openclaw-x64.appinstaller` + - ARM64 feed: `$rawBase/openclaw-arm64.appinstaller` + - x64 MSIX: `$x64Uri` + - ARM64 MSIX: `$arm64Uri` + - x64 Windows App Runtime: `$x64RuntimeUri` + - ARM64 Windows App Runtime: `$arm64RuntimeUri` + + Validation performed: + + - Rendered both feed files from `scripts/render-appinstaller.ps1` + - Parsed local AppInstaller XML before publishing + - Validated GitHub release MSIX headers with candidate GitHub content-type compatibility enabled + - Blocked pre-release/alpha feed updates until channel policy is decided + "@ | Set-Content -Path $bodyPath -Encoding UTF8 + + $existingPr = gh pr list --repo $repo --base master --head $branch --json number --jq '.[0].number' + if ([string]::IsNullOrWhiteSpace($existingPr)) { + gh pr create ` + --repo $repo ` + --base master ` + --head $branch ` + --title "chore(msix): update AppInstaller feed for $tag" ` + --body-file $bodyPath + } + else { + gh pr edit $existingPr ` + --repo $repo ` + --title "chore(msix): update AppInstaller feed for $tag" ` + --body-file $bodyPath + } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60d819a9b..b101c8a15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -373,7 +373,6 @@ jobs: build-msix: needs: [test, e2etests] runs-on: ${{ matrix.rid == 'win-arm64' && 'windows-11-arm' || 'windows-latest' }} - continue-on-error: true strategy: fail-fast: false matrix: @@ -425,10 +424,86 @@ jobs: $xml.Package.Identity.Name = $identityName $xml.Package.Identity.Version = $version $xml.Package.Properties.DisplayName = $displayName - $xml.Package.Applications.Application.VisualElements.DisplayName = $displayName + $trayApp = $xml.Package.Applications.Application | Where-Object { $_.Id -eq "App" } | Select-Object -First 1 + if (-not $trayApp) { throw "Tray application Id='App' not found in $manifest" } + $trayApp.VisualElements.DisplayName = $displayName $xml.Save((Resolve-Path $manifest)) Write-Host "Patched MSIX manifest to identity $identityName, display name '$displayName', version $version" + - name: Patch CommandPalette MSIX manifest metadata + shell: pwsh + run: | + # Keep the CommandPalette extension package metadata in lockstep with the + # parent tray package so both sign under the same Trusted Signing cert + # subject and are discoverable as a coherent product. The repo template + # ships with placeholder Microsoft values that must NEVER ship. + $version = "${{ needs.test.outputs.majorMinorPatch }}.0" + $isAlpha = "${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, '-') }}" -eq "true" + $trayIdentity = if ($isAlpha) { "OpenClaw.Companion.Alpha" } else { "OpenClaw.Companion" } + $cmdpalIdentity = "$trayIdentity.CommandPalette" + $publisher = "CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US" + $publisherDisplay = "Scott Hanselman" + $manifest = "src/OpenClaw.CommandPalette/Package.appxmanifest" + [xml]$xml = Get-Content $manifest + $xml.Package.Identity.Name = $cmdpalIdentity + $xml.Package.Identity.Publisher = $publisher + $xml.Package.Identity.Version = $version + $xml.Package.Properties.PublisherDisplayName = $publisherDisplay + $xml.Save((Resolve-Path $manifest)) + Write-Host "Patched CommandPalette MSIX manifest to identity $cmdpalIdentity, publisher '$publisher', version $version" + + - name: Render embedded AppInstaller + shell: pwsh + run: | + $version = "${{ needs.test.outputs.majorMinorPatch }}.0" + $assetVersion = "${{ needs.test.outputs.semVer }}" + $tag = "${{ github.ref_name }}" + $arch = if ("${{ matrix.rid }}" -eq "win-arm64") { "arm64" } else { "x64" } + $runtimeAsset = "Microsoft.WindowsAppRuntime.2-2.0.1.0-${{ matrix.rid }}.msix" + $publisher = "CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US" + $base = "https://github.com/${{ github.repository }}/releases/download/$tag" + $msixUri = "$base/OpenClawCompanion-$assetVersion-${{ matrix.rid }}.msix" + $runtimeUri = "$base/$runtimeAsset" + $isAlpha = "${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, '-') }}" -eq "true" + $identityName = if ($isAlpha) { "OpenClaw.Companion.Alpha" } else { "OpenClaw.Companion" } + $feedPrefix = if ($isAlpha) { "openclaw-alpha" } else { "openclaw" } + $appInstallerUri = "https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/$feedPrefix-$arch.appinstaller" + ./scripts/render-appinstaller.ps1 ` + -Version $version ` + -Publisher $publisher ` + -IdentityName $identityName ` + -ProcessorArchitecture $arch ` + -MsixUri $msixUri ` + -WindowsAppRuntimeUri $runtimeUri ` + -AppInstallerUri $appInstallerUri ` + -OutputPath "src/OpenClaw.Tray.WinUI/openclaw.appinstaller" + + - name: Publish SetupEngine UI for MSIX payload + shell: pwsh + run: | + $publishDir = "artifacts/setupengine-${{ matrix.rid }}" + $payloadDir = "src/OpenClaw.Tray.WinUI/SetupEngine" + Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item $payloadDir -Recurse -Force -ErrorAction SilentlyContinue + dotnet publish src/OpenClaw.SetupEngine.UI ` + -c Release ` + -r ${{ matrix.rid }} ` + --self-contained ` + -p:PackageMsix=false ` + -p:WindowsAppSDKSelfContained=false ` + -p:WindowsAppSdkDeploymentManagerInitialize=false ` + -p:WindowsAppSdkBootstrapInitialize=false ` + -p:Version=${{ needs.test.outputs.semVer }} ` + -o $publishDir + New-Item -ItemType Directory -Force -Path $payloadDir | Out-Null + $robocopy = Start-Process robocopy.exe ` + -ArgumentList @($publishDir, $payloadDir, '/E', '/XF', '*.xbf', 'OpenClaw.SetupEngine.UI.pri') ` + -Wait ` + -PassThru ` + -NoNewWindow + if ($robocopy.ExitCode -gt 7) { throw "robocopy failed with exit code $($robocopy.ExitCode)" } + Get-ChildItem $payloadDir -Recurse | Select-Object FullName, Length | Format-Table -AutoSize + - name: Build MSIX Package run: > msbuild src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -456,6 +531,13 @@ jobs: echo "msix_path=$($msix.FullName)" >> $env:GITHUB_OUTPUT echo "msix_name=$($msix.Name)" >> $env:GITHUB_OUTPUT + - name: Inject SetupEngine XAML Resources + shell: pwsh + run: | + ./scripts/inject-setupengine-xaml-resources.ps1 ` + -MsixPath "${{ steps.find-msix.outputs.msix_path }}" ` + -SetupEnginePublishDir "artifacts/setupengine-${{ matrix.rid }}" + - name: Upload MSIX Artifact uses: actions/upload-artifact@v7 with: @@ -498,14 +580,23 @@ jobs: release: needs: [repo-hygiene, test, e2etests, build, build-msix, build-extension] - if: startsWith(github.ref, 'refs/tags/v') && needs.repo-hygiene.result == 'success' && needs.test.result == 'success' && needs.e2etests.result == 'success' && needs.build.result == 'success' && needs.build-extension.result == 'success' && !cancelled() + if: startsWith(github.ref, 'refs/tags/v') && needs.repo-hygiene.result == 'success' && needs.test.result == 'success' && needs.e2etests.result == 'success' && needs.build.result == 'success' && needs.build-msix.result == 'success' && needs.build-extension.result == 'success' && !cancelled() runs-on: windows-latest permissions: contents: write + actions: write steps: - uses: actions/checkout@v6 + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Restore Windows App SDK runtime package + run: dotnet restore src/OpenClaw.Tray.WinUI -r win-x64 + - name: Download win-x64 tray artifact uses: actions/download-artifact@v8 with: @@ -532,7 +623,6 @@ jobs: - name: Download win-x64 MSIX artifact uses: actions/download-artifact@v8 - continue-on-error: true id: msix-x64 with: name: openclaw-msix-win-x64 @@ -540,21 +630,21 @@ jobs: - name: Download win-arm64 MSIX artifact uses: actions/download-artifact@v8 - continue-on-error: true id: msix-arm64 with: name: openclaw-msix-win-arm64 path: artifacts/msix-arm64 - name: Rename MSIX packages - if: steps.msix-x64.outcome == 'success' || steps.msix-arm64.outcome == 'success' shell: pwsh run: | $assetVersion = "${{ needs.test.outputs.semVer }}" - $x64 = Get-ChildItem -Path artifacts/msix-x64 -Filter "*.msix" | Select-Object -First 1 - $arm64 = Get-ChildItem -Path artifacts/msix-arm64 -Filter "*.msix" | Select-Object -First 1 - if ($x64) { Copy-Item $x64.FullName "OpenClawCompanion-$assetVersion-win-x64.msix" } - if ($arm64) { Copy-Item $arm64.FullName "OpenClawCompanion-$assetVersion-win-arm64.msix" } + $x64 = @(Get-ChildItem -Path artifacts/msix-x64 -Recurse -Filter "*.msix") + $arm64 = @(Get-ChildItem -Path artifacts/msix-arm64 -Recurse -Filter "*.msix") + if ($x64.Count -ne 1) { throw "Expected exactly one x64 MSIX artifact, found $($x64.Count)." } + if ($arm64.Count -ne 1) { throw "Expected exactly one ARM64 MSIX artifact, found $($arm64.Count)." } + Copy-Item $x64[0].FullName "OpenClawCompanion-$assetVersion-win-x64.msix" + Copy-Item $arm64[0].FullName "OpenClawCompanion-$assetVersion-win-arm64.msix" - name: Disable NuGet source mapping for signing shell: pwsh @@ -584,64 +674,41 @@ jobs: timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 - - name: Sign Release MSIX Packages - if: steps.msix-x64.outcome == 'success' || steps.msix-arm64.outcome == 'success' - uses: azure/trusted-signing-action@v2 - with: - azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} - azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} - azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} - endpoint: https://wus2.codesigning.azure.net/ - signing-account-name: hanselman - certificate-profile-name: WindowsEdgeLight - files-folder: . - files-folder-filter: msix - files-folder-depth: 1 - file-digest: SHA256 - timestamp-rfc3161: http://timestamp.acs.microsoft.com - timestamp-digest: SHA256 - - # Create ZIP files for Updatum auto-update (needs "win-x64" in filename) + # Create ZIP files for the existing Updatum auto-update channel. - name: Create Release ZIPs + shell: pwsh run: | Compress-Archive -Path artifacts/tray-win-x64/* -DestinationPath OpenClawTray-${{ needs.test.outputs.semVer }}-win-x64.zip Compress-Archive -Path artifacts/tray-win-arm64/* -DestinationPath OpenClawTray-${{ needs.test.outputs.semVer }}-win-arm64.zip - # Inno Setup installer for x64 + # Inno installers stay in the release while MSIX/AppInstaller is added. - name: Install Inno Setup run: choco install innosetup -y - name: Build x64 Installer + shell: pwsh run: | - # Prepare x64 files - mkdir publish-x64 - copy artifacts/tray-win-x64/* publish-x64/ -Recurse - mkdir publish-x64\cmdpal + New-Item -ItemType Directory -Force -Path publish-x64 | Out-Null + Copy-Item artifacts/tray-win-x64/* publish-x64/ -Recurse -Force + New-Item -ItemType Directory -Force -Path publish-x64\cmdpal | Out-Null $manifestFolder = Get-ChildItem -Path "artifacts/cmdpal-x64" -Recurse -Filter "AppxManifest.xml" | Select-Object -First 1 if ($manifestFolder) { - Copy-Item "$($manifestFolder.DirectoryName)\*" -Destination publish-x64\cmdpal -Recurse + Copy-Item "$($manifestFolder.DirectoryName)\*" -Destination publish-x64\cmdpal -Recurse -Force } - # Build installer & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DMyAppVersion=${{ needs.test.outputs.majorMinorPatch }} /DMyAppArch=x64 /Dpublish=publish-x64 installer.iss - name: Build arm64 Installer + shell: pwsh run: | - # Prepare arm64 files - mkdir publish-arm64 - copy artifacts/tray-win-arm64/* publish-arm64/ -Recurse - mkdir publish-arm64\cmdpal + New-Item -ItemType Directory -Force -Path publish-arm64 | Out-Null + Copy-Item artifacts/tray-win-arm64/* publish-arm64/ -Recurse -Force + New-Item -ItemType Directory -Force -Path publish-arm64\cmdpal | Out-Null $manifestFolder = Get-ChildItem -Path "artifacts/cmdpal-arm64" -Recurse -Filter "AppxManifest.xml" | Select-Object -First 1 if ($manifestFolder) { - Copy-Item "$($manifestFolder.DirectoryName)\*" -Destination publish-arm64\cmdpal -Recurse + Copy-Item "$($manifestFolder.DirectoryName)\*" -Destination publish-arm64\cmdpal -Recurse -Force } - # Build installer & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DMyAppVersion=${{ needs.test.outputs.majorMinorPatch }} /DMyAppArch=arm64 /Dpublish=publish-arm64 installer.iss - - name: Azure Login for Signing - uses: azure/login@v3 - with: - creds: '{"clientId":"${{ secrets.AZURE_CLIENT_ID }}","clientSecret":"${{ secrets.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ secrets.AZURE_SUBSCRIPTION_ID }}","tenantId":"${{ secrets.AZURE_TENANT_ID }}"}' - - name: Sign Installer uses: azure/trusted-signing-action@v2 with: @@ -657,17 +724,142 @@ jobs: timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 + - name: Prepare MSIX Payloads for Inner Signing + shell: pwsh + run: | + ./scripts/prepare-msix-payload-signing.ps1 ` + -MsixPath "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix" ` + -OutputDirectory "artifacts/msix-payload-signing/win-x64" + ./scripts/prepare-msix-payload-signing.ps1 ` + -MsixPath "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix" ` + -OutputDirectory "artifacts/msix-payload-signing/win-arm64" + + - name: Sign MSIX Payload Files + uses: azure/trusted-signing-action@v2 + with: + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} + endpoint: https://wus2.codesigning.azure.net/ + signing-account-name: hanselman + certificate-profile-name: WindowsEdgeLight + files-folder: artifacts/msix-payload-signing + files-folder-filter: exe,dll,ps1,psm1,psd1 + files-folder-recurse: true + append-signature: true + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Repack MSIX Packages After Payload Signing + shell: pwsh + run: | + ./scripts/repack-msix.ps1 ` + -PayloadDirectory "artifacts/msix-payload-signing/win-x64" ` + -MsixPath "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix" + ./scripts/repack-msix.ps1 ` + -PayloadDirectory "artifacts/msix-payload-signing/win-arm64" ` + -MsixPath "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix" + + - name: Sign Release MSIX Packages + uses: azure/trusted-signing-action@v2 + with: + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} + endpoint: https://wus2.codesigning.azure.net/ + signing-account-name: hanselman + certificate-profile-name: WindowsEdgeLight + files-folder: . + files-folder-filter: msix + files-folder-depth: 1 + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Verify Signed MSIX Payloads + shell: pwsh + run: | + ./scripts/verify-msix-payload-signatures.ps1 ` + -MsixPath "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix" + ./scripts/verify-msix-payload-signatures.ps1 ` + -MsixPath "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix" + + - name: Copy Windows App SDK Framework Packages + shell: pwsh + run: | + $globalPackages = (dotnet nuget locals global-packages -l) -replace '^global-packages:\s*', '' + $runtimeRoot = Join-Path $globalPackages 'microsoft.windowsappsdk.runtime\2.0.1\tools\MSIX' + $x64Runtime = Join-Path $runtimeRoot 'win10-x64\Microsoft.WindowsAppRuntime.2.msix' + $arm64Runtime = Join-Path $runtimeRoot 'win10-arm64\Microsoft.WindowsAppRuntime.2.msix' + if (-not (Test-Path $x64Runtime)) { throw "Windows App Runtime x64 framework package not found: $x64Runtime" } + if (-not (Test-Path $arm64Runtime)) { throw "Windows App Runtime ARM64 framework package not found: $arm64Runtime" } + Copy-Item $x64Runtime "Microsoft.WindowsAppRuntime.2-2.0.1.0-win-x64.msix" -Force + Copy-Item $arm64Runtime "Microsoft.WindowsAppRuntime.2-2.0.1.0-win-arm64.msix" -Force + + # Render the hosted .appinstaller XML files that Windows AppInstaller polls + # after install. Each architecture has its own stable URL because the release + # currently publishes separate .msix packages, not a multi-arch .msixbundle. + - name: Render AppInstaller + if: ${{ !contains(github.ref_name, '-') }} + shell: pwsh + run: | + $version = "${{ needs.test.outputs.majorMinorPatch }}.0" + $tag = "${{ github.ref_name }}" + $publisher = "CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US" + $identityName = "OpenClaw.Companion" + $base = "https://github.com/${{ github.repository }}/releases/download/$tag" + $x64Uri = "$base/OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix" + $arm64Uri = "$base/OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix" + $x64RuntimeUri = "$base/Microsoft.WindowsAppRuntime.2-2.0.1.0-win-x64.msix" + $arm64RuntimeUri = "$base/Microsoft.WindowsAppRuntime.2-2.0.1.0-win-arm64.msix" + ./scripts/render-appinstaller.ps1 ` + -Version $version ` + -Publisher $publisher ` + -IdentityName $identityName ` + -ProcessorArchitecture x64 ` + -MsixUri $x64Uri ` + -WindowsAppRuntimeUri $x64RuntimeUri ` + -AppInstallerUri "https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller" ` + -OutputPath "openclaw-x64.appinstaller" + ./scripts/render-appinstaller.ps1 ` + -Version $version ` + -Publisher $publisher ` + -IdentityName $identityName ` + -ProcessorArchitecture arm64 ` + -MsixUri $arm64Uri ` + -WindowsAppRuntimeUri $arm64RuntimeUri ` + -AppInstallerUri "https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-arm64.appinstaller" ` + -OutputPath "openclaw-arm64.appinstaller" + + - name: Prepare Release File List + id: release-files + shell: pwsh + run: | + $files = @( + "Output/OpenClawTray-Setup-x64.exe", + "Output/OpenClawTray-Setup-arm64.exe", + "OpenClawTray-${{ needs.test.outputs.semVer }}-win-x64.zip", + "OpenClawTray-${{ needs.test.outputs.semVer }}-win-arm64.zip", + "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix", + "OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix", + "Microsoft.WindowsAppRuntime.2-2.0.1.0-win-x64.msix", + "Microsoft.WindowsAppRuntime.2-2.0.1.0-win-arm64.msix" + ) + if ("${{ contains(github.ref_name, '-') }}" -ne "true") { + $files += "openclaw-x64.appinstaller" + $files += "openclaw-arm64.appinstaller" + } + + "files<> $env:GITHUB_OUTPUT + $files >> $env:GITHUB_OUTPUT + "EOF" >> $env:GITHUB_OUTPUT + - name: Create Release uses: softprops/action-gh-release@v3 with: generate_release_notes: true - files: | - Output/OpenClawTray-Setup-x64.exe - Output/OpenClawTray-Setup-arm64.exe - OpenClawTray-${{ needs.test.outputs.semVer }}-win-x64.zip - OpenClawTray-${{ needs.test.outputs.semVer }}-win-arm64.zip - OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix - OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix + files: ${{ steps.release-files.outputs.files }} prerelease: ${{ contains(github.ref_name, '-') }} make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }} body: | @@ -678,15 +870,19 @@ jobs: - **Installer (ARM64)**: `OpenClawTray-Setup-arm64.exe` - Windows on ARM (Surface, etc.) - **Portable x64**: `OpenClawTray-${{ needs.test.outputs.semVer }}-win-x64.zip` - **Portable ARM64**: `OpenClawTray-${{ needs.test.outputs.semVer }}-win-arm64.zip` - - **MSIX x64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix` - Packaged (camera/mic consent) - - **MSIX ARM64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix` - Packaged (camera/mic consent) + - **MSIX x64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix` — Intel / AMD 64-bit, with embedded AppInstaller metadata + - **MSIX ARM64**: `OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix` — Windows on ARM, with embedded AppInstaller metadata + - **Windows App Runtime x64**: `Microsoft.WindowsAppRuntime.2-2.0.1.0-win-x64.msix` — framework dependency used by the x64 AppInstaller feed + - **Windows App Runtime ARM64**: `Microsoft.WindowsAppRuntime.2-2.0.1.0-win-arm64.msix` — framework dependency used by the ARM64 AppInstaller feed + - **AppInstaller x64**: `openclaw-x64.appinstaller` — stable architecture update source for Intel / AMD 64-bit (stable releases only) + - **AppInstaller ARM64**: `openclaw-arm64.appinstaller` — stable architecture update source for Windows on ARM (stable releases only) ### Features - 🦞 System tray integration with gateway status - 🎯 PowerToys Command Palette extension (optional) - - 🔄 Auto-updates from GitHub Releases + - 🔄 Auto-updates via Windows AppInstaller for MSIX and Updatum for the legacy installer/portable channel - ✅ Code-signed with Azure Trusted Signing - - 📦 MSIX package available for native camera/microphone consent prompts + - 📦 MSIX package available for native camera/microphone/location consent prompts under our package name ### Requirements - Windows 10 version 1903 or later @@ -695,7 +891,18 @@ jobs: - PowerToys (for Command Palette extension) ### Quick Start - 1. Run the installer for your architecture (or sideload the MSIX for camera consent) - 2. Optionally enable Command Palette extension during install - 3. Launch from Start Menu or system tray - 4. Right-click tray icon → Settings to configure + 1. Run the installer for your architecture, use the portable ZIP, or install the signed MSIX for packaged app identity. + 2. Optionally enable Command Palette extension during install. + 3. Launch from Start Menu or system tray. + 4. Right-click tray icon → Settings to configure. + + - name: Request AppInstaller feed PR + if: success() + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + gh workflow run appinstaller-feed-pr.yml ` + --repo "${{ github.repository }}" ` + --ref master ` + -f tag="${{ github.ref_name }}" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a92e2316d..1e612b61b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -618,19 +618,24 @@ On every build, the following artifacts are uploaded: When a tag is pushed (e.g., `git tag v1.2.3 && git push origin v1.2.3`): 1. **Build & Sign:** - - All artifacts built for x64 and ARM64 - - Executables signed with Azure Trusted Signing certificate - -2. **Create Installers:** - - Inno Setup creates Windows installers - - Includes both Tray app and Command Palette extension - - Separate installers for x64 and ARM64 + - MSIX packages built for x64 and ARM64 + - Both `.msix` files are signed with the Azure Trusted Signing certificate after the architecture-specific AppInstaller metadata is embedded + +2. **Render AppInstaller:** + - `scripts/render-appinstaller.ps1` produces architecture-specific AppInstaller files: + `OpenClawCompanion-X.Y.Z-win-x64.appinstaller`, + `OpenClawCompanion-X.Y.Z-win-arm64.appinstaller`, + `openclaw-x64.appinstaller`, and `openclaw-arm64.appinstaller` + - After release creation, a follow-up workflow opens a maintainer-reviewed PR + to update the raw GitHub stable feed files under `installer\appinstaller\`. + See [`docs/RELEASING.md`](./docs/RELEASING.md) for the AppInstaller update flow. 3. **GitHub Release:** - Automatic release created with tag name - - Includes: - - Installers: `OpenClawTray-Setup-x64.exe`, `OpenClawTray-Setup-arm64.exe` - - Portable ZIPs: `OpenClawTray-{version}-win-x64.zip`, `OpenClawTray-{version}-win-arm64.zip` + - Attached assets: + - `OpenClawCompanion-X.Y.Z-win-x64.msix` and `-win-arm64.msix` + - `openclaw-x64.appinstaller` and `openclaw-arm64.appinstaller` (stable-name feed copies) + - `OpenClawCompanion-X.Y.Z-win-x64.appinstaller` and `-win-arm64.appinstaller` (tag-pinned) - Release notes auto-generated from commits ### Monitoring CI diff --git a/README.md b/README.md index bb92e8be4..54dd196b0 100644 --- a/README.md +++ b/README.md @@ -353,7 +353,7 @@ PowerToys Command Palette extension for quick OpenClaw access. - **🧭 Setup Wizard** - Open pairing/setup - **🧭 Command Center** - Open diagnostics and support actions - **🔄 Run Health Check** - Refresh connection health -- **⬇️ Check for Updates** - Run a manual GitHub Releases update check +- **⬇️ Check for Updates** - Check the MSIX AppInstaller update feed - **⚡ Activity Stream** - Open recent activity - **📋 Notification History** - Open notification history in the Activity page - **⚙️ Settings** - Open the OpenClaw Tray Settings page diff --git a/docs/MSIX_E2E_TEST_RUNBOOK.md b/docs/MSIX_E2E_TEST_RUNBOOK.md new file mode 100644 index 000000000..e8a8fab79 --- /dev/null +++ b/docs/MSIX_E2E_TEST_RUNBOOK.md @@ -0,0 +1,196 @@ +# MSIX End-to-End Test Runbook + +Manual test matrix for an OpenClaw Companion MSIX release. Run on a fresh +Windows 11 24H2 VM and on a Windows-on-ARM device before promoting a tag to +`make_latest=true`. + +The automated counterpart lives in: + +- `scripts/test-msix-install.ps1` — install/launch/uninstall smoke test. +- `scripts/test-appinstaller-update.ps1` — `.appinstaller` upgrade simulation. +- `tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs` — manifest contract. +- `tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs` — template contract. + +The runbook below covers the things automation cannot — OS consent dialogs, +multi-launch behaviour, real WSL distros, and the dirty-uninstall recovery. + +## Pre-flight + +- [ ] Clean Windows 11 24H2 VM. No prior `OpenClaw*` package, no `openclaw-*` + WSL distros, no `%APPDATA%\OpenClawTray\` or `%LOCALAPPDATA%\OpenClawTray\`. +- [ ] `wsl --list` reports either no distros or only unrelated distros. +- [ ] Dev Mode **off** in Windows Settings (so sideload trust is exercised + end-to-end through Trusted Signing, not bypassed). + +## Scenarios + +### 1. Clean install via signed MSIX + +1. Open the GitHub release page in Edge. +2. Download the signed MSIX for the machine architecture: + `OpenClawCompanion-X.Y.Z-win-x64.msix` or + `OpenClawCompanion-X.Y.Z-win-arm64.msix`. +3. Ensure **Launch when ready** is checked, then **assert** Windows AppInstaller + opens with: + - Publisher: `CN=Scott Hanselman, O=Scott Hanselman, …` (no "untrusted") + - DisplayName: `OpenClaw Companion` + - Version: the tag version +4. Click **Install**. +5. **Assert** the installer closes and the tray icon appears in the notification + area within 5 s. If it does not, collect `%LOCALAPPDATA%\OpenClawTray\openclaw-tray.log` + plus Windows AppX/AppInstaller event logs for the failed activation. +6. **Assert** `Get-AppxPackage OpenClaw.Companion*` returns one row with the + expected `Publisher` and a 4-part `Version`. +7. **Assert** `Package.GetAppInstallerInfo()` or an equivalent package query + reports the embedded architecture-specific AppInstaller URL on Windows builds + that support embedded App Installer metadata. + +### 2. First-run permission consent (packaged path) + +1. On first launch, open Settings → Onboarding → Permissions. +2. **Assert** each row reports a status pulled from the per-package consent + API (the unpackaged code path would have said "denied" or "unknown" here + for camera/mic/location). +3. Trigger an action that uses each capability and **assert** the OS + consent prompt appears once, with **"OpenClaw Companion"** as the app + name (not "Desktop apps", which would mean we accidentally fell back to + the unpackaged DeviceAccessInformation surface). + - Camera: click "Take photo" in the onboarding camera widget. + - Microphone: click "Test microphone". + - Location: click "Use location" in the onboarding wizard. +4. **Assert** Settings → Privacy → Camera (and Microphone, Location) lists + "OpenClaw Companion" with a per-app toggle. + +### 3. Permission revocation while running + +1. With the tray running, open Settings → Privacy → Camera. +2. Turn the **OpenClaw Companion** toggle OFF. +3. **Assert** the tray's Permissions page (Settings → Permissions or the + onboarding row strip) updates within ~1 s without restart — this proves + the `AppCapability.AccessChanged` subscription wired up by + `PermissionChecker.SubscribeToAccessChangesPackaged` is firing. +4. Toggle it back ON and **assert** the row returns to "Granted". +5. **Note** Windows Settings privacy/permission pages may render the OpenClaw + icon on the user's system-accent tile background. That background is owned by + Windows Settings and is not controlled by the MSIX icon assets or manifest + `BackgroundColor`. + +### 3a. Notification settings registration + +1. Open Settings → System → Notifications. +2. **Assert** "OpenClaw Companion" appears as an app-level notification entry. +3. Toggle notifications OFF and ON for OpenClaw Companion. +4. Trigger a toast from the tray (for example copy device/node summary after a + gateway is paired) and **assert** the OS setting gates notification delivery. + +### 4. StartupTask (replaces the legacy HKCU\\…\\Run autostart) + +1. Open Settings → Auto-start. Toggle **Launch when Windows starts** ON. +2. **Assert** Windows shows the one-time consent dialog for the + `OpenClawCompanionStartup` task. +3. Sign out and back in (or reboot). +4. **Assert** the tray appears in the notification area shortly after sign-in. +5. **Assert** Task Manager → Startup apps lists "OpenClaw Companion" with + status "Enabled". +6. **Assert** `Get-ItemProperty 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name OpenClawTray -EA SilentlyContinue` + is empty — i.e. we did NOT also write the legacy Run key. + +### 5. Local-gateway install + in-app uninstall + +1. Open Onboarding → Local gateway → "Install WSL gateway". Wait for the + distro to register and the tray status to flip to "Connected". +2. **Assert** `wsl --list --quiet` shows `openclaw-local` (or the variant + the install chose). +3. Open Settings → Local Gateway → **Remove Local Gateway**. +4. **Assert** the in-app status reports success; `wsl --list --quiet` no + longer shows the distro; `%LOCALAPPDATA%\OpenClawTray\wsl-keepalive\` + markers are gone. + +### 6. Clean app uninstall + +1. Run the in-app **Settings → "Reset & remove"** (when implemented per + the Track 3 follow-up). Until that lands, run scenario 5 first, then: +2. Settings → Apps → OpenClaw Companion → Uninstall. +3. Reboot. +4. **Assert** `Get-AppxPackage OpenClaw.Companion*` returns nothing. +5. **Assert** the following are absent: `%APPDATA%\OpenClawTray\`, + `%LOCALAPPDATA%\OpenClawTray\`, `HKCU:\Software\Classes\openclaw`, + `HKCU:\Software\Microsoft\Windows\CurrentVersion\Run\OpenClawTray`, + and known app-owned WSL distros such as `OpenClawGateway` or + `openclaw-local`. + +### 7. Dirty uninstall + recovery (proves `--purge-wsl-orphans`) + +This scenario deliberately skips the in-app cleanup so we can verify the +support recipe works. + +1. Re-install per scenario 1 and re-do scenario 5 up to and including a + working WSL gateway. +2. Without using Settings → Local Gateway, go straight to Settings → Apps + and Uninstall. +3. **Assert** the WSL distro is still present (`wsl --list --quiet` shows + `openclaw-local`) — this is the failure mode we need to recover from. +4. Run the published one-liner from `docs/uninstall-msix.md`: + ```powershell + openclaw-winnode --purge-wsl-orphans --json-output + ``` +5. **Assert** exit code 1 and the JSON report enumerates the orphan distro + and any leftover folders. +6. Run with `--confirm-destructive`: + ```powershell + openclaw-winnode --purge-wsl-orphans --confirm-destructive --json-output + ``` +7. **Assert** exit code 0 and the `Removed` list contains everything from + the earlier `Orphans` list. `wsl --list --quiet` no longer shows the + distro; `%APPDATA%\OpenClawTray\` and `%LOCALAPPDATA%\OpenClawTray\` are + gone. + +### 8. `.appinstaller` auto-update (vN → vN+1) + +1. Install vN via the signed MSIX on Windows 11 24H2 and via the hosted + architecture-specific `.appinstaller` on a downlevel Windows target. +2. Publish vN+1 by tagging `vX.Y.Z+1`. After the release assets are created, + the feed-update workflow opens a PR that updates + `installer\appinstaller\openclaw-x64.appinstaller` and + `installer\appinstaller\openclaw-arm64.appinstaller`. Merge that PR to advance + the raw GitHub stable feed. +3. **Trigger 1 (AutomaticBackgroundTask):** Leave the tray running and give + Windows enough time to poll the stable URL. **Assert** no App Installer UI + appears during normal launch. +4. **Trigger 2 (in-app, on demand):** From a fresh vN install, click tray menu + → "Check for updates". **Assert** the tray is not force-closed by default and + the in-app status surfaces `Ready` / "restart when convenient" or `Current` + if vN+1 was not published. +5. **Trigger 3 (explicit restart):** If a manual "Update now" affordance is + exposed, invoke it and assert Windows applies vN+1 after the explicit restart. + +### 9. Sideload trust on a stock no-dev-mode box + +1. Fresh Win11 VM, Dev Mode OFF, no developer keys imported. +2. Double-click the `.msix` directly (not the `.appinstaller`) downloaded + from the release. +3. **Assert** the install succeeds with no "untrusted publisher" warning + — the Azure Trusted Signing cert chain is what's being validated here. + +### 10. ARM64 + +1. On a Windows-on-ARM device (Surface Pro X, Snapdragon X laptop), repeat + scenarios 1, 2, 5, 7, 8 against the `-win-arm64.msix`. +2. **Assert** every consent dialog still shows the package name "OpenClaw + Companion" (no name mangling on ARM64 manifests). +3. **Assert** scenario 8 step 3 also works — `.appinstaller` is + architecture-aware and Windows picks the ARM64 MSIX from the same URL. + +## Recording results + +Record outcomes per scenario in the release tracking issue with: + +- Build tag tested +- OS build (winver) +- Architecture (x64 / arm64) +- Pass / Fail / Skip +- Notes for any partial passes or unexpected dialogs + +Promote the generated feed PR, or fall back to a static host/CDN if raw GitHub +fails, only after scenarios 1, 2, 5, 6, 7, 8 (triggers 1 and 2), 9, and 10 all +pass on at least one VM. diff --git a/docs/MSIX_UPDATE_ENDPOINT_OPTIONS.md b/docs/MSIX_UPDATE_ENDPOINT_OPTIONS.md new file mode 100644 index 000000000..5a20071ad --- /dev/null +++ b/docs/MSIX_UPDATE_ENDPOINT_OPTIONS.md @@ -0,0 +1,30 @@ +# MSIX update endpoint options + +OpenClaw's MSIX/AppInstaller update feed needs a stable HTTPS URL for each architecture-specific `.appinstaller` file. The URL should outlive any one maintainer account, release host, or CI implementation because Windows stores it as the update source for installed packages. + +## Recommendation + +Use `raw.githubusercontent.com` as the first candidate because the Mac companion app already uses a raw GitHub Sparkle appcast (`https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`). Gate that choice on Windows App Installer two-version E2E validation because raw GitHub serves repo files with GitHub-controlled headers. If Windows rejects raw GitHub, keep the same generated feed files in `installer\appinstaller\` and publish them to a project-owned custom domain backed by Azure Static Web Apps, Azure Blob Storage plus CDN/Front Door, or another static host with AppInstaller-compatible headers. + +## Options + +| Option | Example endpoint | Pros | Cons / risks | Best fit | Recommendation | +|---|---|---|---|---|---| +| Raw GitHub file in this repo | `https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller` | Mirrors the Mac companion Sparkle appcast pattern; no separate hosting account; feed changes can be maintainer-reviewed PRs against `master`; raw URLs are simple and durable as long as the repo/branch/path remain stable | GitHub controls MIME/cache/header behavior; current HEAD checks show `text/plain` for raw files and no normal `Content-Length`; Windows App Installer may reject this even if Sparkle accepts it | First candidate if AppInstaller E2E proves Windows accepts it | **Preferred candidate, validation-gated** | +| Project-owned custom domain backed by Azure Static Web Apps | `https://updates.openclaw.dev/windows/msix/openclaw-x64.appinstaller` | Stable public contract; easy static hosting; GitHub Actions deploy works well; can serve required MIME, `Content-Length`, and range headers; backend can change later behind DNS | Requires domain/DNS ownership and a small Azure resource; maintainers must manage deployment credentials | Community-owned project that wants a durable updater URL without taking on much infra | **Fallback if raw GitHub fails E2E** | +| Project-owned custom domain backed by Azure Blob Storage + CDN or Front Door | `https://updates.openclaw.dev/windows/msix/openclaw-x64.appinstaller` | Very durable object storage; strong header control; CDN/range support; easy to keep historical MSIX assets available | More Azure configuration than Static Web Apps; CDN caching needs careful invalidation for stable `.appinstaller` filenames | Higher-scale or more operations-friendly version of the preferred model | **Fallback if maintainers prefer Azure ops** | +| GitHub Pages from the main repo, optionally behind a custom domain | `https://openclaw.github.io/openclaw-windows-node/openclaw-x64.appinstaller` or custom-domain equivalent | Simple; close to repo/release workflow; no separate cloud account if Pages is acceptable; good interim path | Requires Pages on the main repo; stable feed publishing is separate from GitHub Release attachment unless automated; direct `github.io` URL couples installed clients to GitHub Pages | Interim endpoint or long-term endpoint only if maintainers want GitHub Pages as project infrastructure | **Acceptable interim; better behind custom domain** | +| GitHub Pages from a dev/fork branch | `https://indierawk2k2.github.io/openclaw-windows-node/openclaw-x64.appinstaller` | Fast for testing; no main-repo Pages decision required | Not durable; tied to an individual fork/account; wrong trust boundary for production installs | Manual pre-merge update testing | **Testing only** | +| Direct GitHub Release asset URL | `https://github.com/openclaw/openclaw-windows-node/releases/download/vX.Y.Z/...` | Releases already contain signed artifacts; immutable tag URLs are good for package payloads | Not a stable feed URL by itself; "latest" redirects and release asset URLs are not ideal as Windows' stored `.appinstaller` source; harder to guarantee AppInstaller-friendly headers on redirects | Payload downloads referenced by a hosted `.appinstaller` | **Use for MSIX payloads, not as the stable feed** | +| `aka.ms` short link | `https://aka.ms/openclaw-msix-x64` | Very stable Microsoft-operated short URL; can redirect to any backend | This is not an official Microsoft project; ownership/approval may be inappropriate or unavailable; redirect adds another operational dependency | Official Microsoft-owned projects or Microsoft-sponsored distribution | **Do not use as canonical for this project** | +| Third-party object storage/static hosting with custom domain | `https://updates.openclaw.dev/windows/msix/openclaw-x64.appinstaller` backed by S3, Cloudflare R2, Netlify, etc. | Can be cheap and durable; custom domain keeps backend replaceable; many providers support correct headers | Provider-specific header/range behavior must be validated; maintainers need provider access and deployment secrets | Maintainers prefer non-Azure hosting | **Viable if header validation passes** | +| Self-hosted server | `https://updates.openclaw.dev/windows/msix/openclaw-x64.appinstaller` | Full control over headers, logs, and rollout behavior | Highest maintenance burden; uptime/TLS/security patching become project responsibilities | Projects with existing reliable infra | **Avoid unless existing infra already exists** | + +## Assumptions + +- OpenClaw remains distributed outside the Microsoft Store for this path. +- AppInstaller update metadata stays architecture-specific for now: `openclaw-x64.appinstaller` and `openclaw-arm64.appinstaller`. +- The stable `.appinstaller` URL is a long-lived contract stored by Windows for installed clients. +- MSIX package payloads may still live on GitHub Releases as long as the hosted `.appinstaller` points to versioned, signed assets and header validation passes. +- The update feed host must serve `.appinstaller` as `application/appinstaller`, MSIX payloads as `application/msix`, provide `Content-Length`, and support range requests for MSIX payloads. +- If raw GitHub is used, two-version E2E validation is the deciding proof because its headers do not match the conventional AppInstaller hosting recommendation. diff --git a/docs/POWERTOYS.md b/docs/POWERTOYS.md index 1d294443a..78060e94a 100644 --- a/docs/POWERTOYS.md +++ b/docs/POWERTOYS.md @@ -44,7 +44,7 @@ Open Command Palette (`Win+Alt+Space`), type **"OpenClaw"** — you should see t | **🧭 Setup Wizard** | Opens QR, setup code, and manual gateway pairing | | **🧭 Command Center** | Opens gateway, tunnel, node, browser, and support diagnostics | | **🔄 Run Health Check** | Refreshes gateway or node connection health | -| **⬇️ Check for Updates** | Runs a manual GitHub Releases update check | +| **⬇️ Check for Updates** | Checks the MSIX AppInstaller update feed | | **⚡ Activity Stream** | Opens recent tray activity and support bundle actions | | **📋 Notification History** | Opens recent OpenClaw tray notifications in the Activity page | | **⚙️ Settings** | Opens the OpenClaw Tray Settings page | diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1934ecd22..94e932a2b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -50,6 +50,72 @@ After pushing a tag, confirm in GitHub Actions: - jobs complete successfully (build, build-msix, release) - release assets are attached to the tag release +## Non-Store auto-update via `.appinstaller` + +OpenClaw Companion ships outside the Microsoft Store but still wants +quiet updates. The supported pattern is a signed MSIX with embedded +`.appinstaller` metadata plus a hosted `.appinstaller` XML file that Windows +AppInstaller polls. The CI release job renders one file per architecture from +`installer/openclaw-companion.appinstaller.template` via +`scripts/render-appinstaller.ps1` and attaches tag-pinned AppInstaller files plus +stable-name copies to the GitHub release: + +- `OpenClawCompanion-X.Y.Z-win-x64.appinstaller` +- `OpenClawCompanion-X.Y.Z-win-arm64.appinstaller` +- stable-name copy `openclaw-x64.appinstaller` +- stable-name copy `openclaw-arm64.appinstaller` + +The `.appinstaller` policy intentionally uses only: + +```xml + + + +``` + +Do not add `OnLaunch` or `ForceUpdateFromAnyVersion` to production output. +Updates should happen quietly in the background and bad releases should be +fixed by shipping a higher roll-forward version. + +### How an install gets to a new version + +1. **Embedded App Installer metadata** — on Windows builds that support + `uap13:AutoUpdate`, double-clicking the signed MSIX seeds the stable + architecture-specific `.appinstaller` URL. +2. **Hosted `.appinstaller` install** — users or enterprise tools can install + from `openclaw-x64.appinstaller` or `openclaw-arm64.appinstaller`, which + records the same stable source URL. +3. **Windows background task** — `AutomaticBackgroundTask` lets Windows poll + that source URL without cold-start App Installer UI. +4. **In-app, on demand** — the tray's "Check for updates" command asks Windows + to fetch the architecture-specific `.appinstaller` and avoids force-closing + the tray by default. If an update is accepted while the app is in use, the UI + should tell the user to restart OpenClaw when convenient. + +### Important caveats for the release operator + +- The `Version` attribute in the rendered `.appinstaller`, the `Version` + attribute inside ``, and the `` of the + matching MSIX must all match exactly. +- The rendered `` must match the MSIX URL: + x64 files point at x64 MSIX assets and arm64 files point at ARM64 assets. +- The embedded stable feed URLs currently point at raw GitHub files in this repo: + `installer\appinstaller\openclaw-x64.appinstaller` and + `installer\appinstaller\openclaw-arm64.appinstaller`. +- Those files are checked in as `0.0.0.0` bootstrap placeholders so the raw + URLs do not 404 before the first feed-update PR is merged. They intentionally + do not advertise a newer package. +- Raw GitHub mirrors the Mac companion app's Sparkle appcast pattern, but it + serves repo files with GitHub-controlled headers. Windows App Installer must + still be proven with two-version E2E validation before this endpoint is + treated as durable. +- After the release is created, CI dispatches + `.github\workflows\appinstaller-feed-pr.yml`. That workflow renders the stable + feed files from the signed release assets, validates them, and opens a PR. + Merging that PR advances the stable update source. +- Pre-release/alpha feed updates are intentionally blocked until maintainers + choose a separate channel strategy. + ## If you need to retag If a tag points to the wrong commit: @@ -61,3 +127,4 @@ git tag -a vX.Y.Z -m "Release vX.Y.Z" git push origin vX.Y.Z ``` + diff --git a/docs/SETUP.md b/docs/SETUP.md index c3cf2df26..ea03995a6 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -14,35 +14,43 @@ Before installing, make sure you have: ### 1. Download the Installer -Go to the [Releases page](https://github.com/openclaw/openclaw-windows-node/releases) and download the latest installer for your architecture: +Go to the [Releases page](https://github.com/openclaw/openclaw-windows-node/releases) and download the installer, portable ZIP, or signed MSIX for your architecture: -| File | Architecture | +| File | Description | |------|-------------| -| `OpenClawTray-Setup-x64.exe` | Intel / AMD (most PCs) | -| `OpenClawTray-Setup-arm64.exe` | ARM64 (Surface Pro X, Snapdragon laptops) | +| `OpenClawTray-Setup-x64.exe` | Legacy installer for Intel / AMD 64-bit | +| `OpenClawTray-Setup-arm64.exe` | Legacy installer for ARM64 | +| `OpenClawTray-X.Y.Z-win-x64.zip` | Portable/Updatum channel for Intel / AMD 64-bit | +| `OpenClawTray-X.Y.Z-win-arm64.zip` | Portable/Updatum channel for ARM64 | +| `OpenClawCompanion-X.Y.Z-win-x64.msix` | Recommended for Intel / AMD 64-bit; includes embedded AppInstaller metadata on supported Windows builds | +| `OpenClawCompanion-X.Y.Z-win-arm64.msix` | Recommended for ARM64 (Surface Pro X, Snapdragon laptops); includes embedded AppInstaller metadata on supported Windows builds | +| `openclaw-x64.appinstaller` | Stable update source / alternate install path for Intel / AMD 64-bit | +| `openclaw-arm64.appinstaller` | Stable update source / alternate install path for ARM64 | -If you're unsure, use the **x64** installer. +If you're unsure which architecture you need, most Intel/AMD PCs use x64 and Snapdragon/Surface-on-ARM devices use ARM64. A future MSIX bundle can collapse this to one download, but the current release uses architecture-specific packages. -### 2. Run the Installer +### 2. Install OpenClaw -Double-click the downloaded `.exe`. Windows may show a SmartScreen prompt — click **More info → Run anyway** (this is normal for code-signed apps that haven't yet accumulated reputation). +For the legacy installer, double-click the downloaded `.exe`. Windows may show a SmartScreen prompt — click **More info → Run anyway** if needed. -The installer runs without requiring administrator privileges. +For MSIX, double-click the signed `.msix`. Windows AppInstaller opens, shows the publisher (Scott Hanselman, code-signed via Azure Trusted Signing), and offers to install. On supported Windows builds, the MSIX also seeds the stable `.appinstaller` URL for background updates. -### 3. Choose Optional Components +The install runs without requiring administrator privileges. -The installer offers two optional components: +### 3. Optional integrations -- **Create Desktop Icon** — adds a shortcut to your desktop. -- **Start OpenClaw Tray when Windows starts** — launches Molty automatically at login (recommended). -- **Install PowerToys Command Palette extension** — enables OpenClaw commands in PowerToys Command Palette (requires [PowerToys](https://github.com/microsoft/PowerToys) to be installed). See [POWERTOYS.md](./POWERTOYS.md) for details. +The installer can create shortcuts, enable startup, and register the Command Palette extension. The MSIX installs the tray app; optional integrations are configured from inside the app: + +- **PowerToys Command Palette extension** — install separately if you use PowerToys; see [POWERTOYS.md](./POWERTOYS.md). +- **Launch at Windows sign-in** — toggle in **Settings → Auto-start** (uses the Windows StartupTask API; the user can revoke it from Task Manager → Startup). ### 4. First Launch -After the installer finishes, OpenClaw Tray starts automatically. Look for the 🦞 lobster icon in the system tray (bottom-right corner of the taskbar, near the clock). +After install, OpenClaw Tray starts automatically. Look for the 🦞 lobster icon in the system tray (bottom-right corner of the taskbar, near the clock). If you don't see it, check the **hidden icons** area (the `^` arrow next to the tray). + ### 5. Onboarding Wizard On first launch, Molty opens a **6-screen onboarding wizard** that walks you through setup: diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index de1ad8f03..ff859048b 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -33,15 +33,11 @@ dotnet build -p:Version=${{ needs.test.outputs.semVer }} This `-p:Version=...` argument overrides the `` property in the csproj, and consequently also sets `FileVersion` and `AssemblyVersion` to match. -### Auto-Updater Version Detection +### AppInstaller Version Detection -The Updatum auto-updater determines the current application version by reading the **AssemblyVersion** from the running executable using: +Windows AppInstaller compares the 4-part MSIX package identity version from `Package.appxmanifest` with the `Version` attributes in the hosted `.appinstaller` and its `` entry. CI patches the manifest to `${majorMinorPatch}.0` before packaging and renders the `.appinstaller` with the same value. -```csharp -Assembly.GetExecutingAssembly().GetName().Version -``` - -This is why it's critical that `AssemblyVersion` (and `FileVersion`) match the semantic version - otherwise, the updater will get confused and keep offering the same update repeatedly. +The tray still reports its managed assembly version in diagnostics, so `AssemblyVersion` and `FileVersion` should continue to match the semantic version for supportability. ## Historical Issue @@ -56,9 +52,8 @@ Previously, the csproj files had hardcoded values: This caused a version mismatch: - The semantic version was 0.3.0 - But the file and assembly versions were stuck at 0.2.0 -- Updatum would read 0.2.0 from the running EXE -- It would see 0.4.0 available on GitHub -- It would offer to update from "0.2.0" to "0.4.0" even though the user was already on 0.3.0 or 0.4.0 +- The updater would read/report 0.2.0 from the running EXE +- It could offer or diagnose an update from "0.2.0" to "0.4.0" even though the user was already on 0.3.0 or 0.4.0 ## Solution @@ -100,5 +95,5 @@ AppVersionInfo.TestOverride = "9.9.9"; ## References - [Microsoft Docs: Assembly Versioning](https://learn.microsoft.com/en-us/dotnet/standard/assembly/versioning) -- [Updatum Library](https://github.com/sn4k3/Updatum) +- [Microsoft Docs: App Installer file overview](https://learn.microsoft.com/en-us/windows/msix/app-installer/app-installer-file-overview) - [GitVersion Documentation](https://gitversion.net/) diff --git a/docs/WINDOWS_NODE_ARCHITECTURE.md b/docs/WINDOWS_NODE_ARCHITECTURE.md index 095c4747e..f18f9dcc0 100644 --- a/docs/WINDOWS_NODE_ARCHITECTURE.md +++ b/docs/WINDOWS_NODE_ARCHITECTURE.md @@ -27,6 +27,15 @@ Related issues: #5 (Canvas Panel), #6 (Skills Settings UI), #7 (DEVELOPMENT.md), - [Technical Deep Dives](#technical-deep-dives) - [Contributing](#contributing) +## Related design notes + +- **[MSIX packaging of `OpenClaw.WinNode.Cli`](./WINNODE_CLI_MSIX_PACKAGING.md)** — the + worker-node CLI and the tray today share state via `%APPDATA%\OpenClawTray\...`, + which works under MSIX only by coincidence. The committed decision is to + package the CLI inside the tray MSIX as a second `` with a + `windows.appExecutionAlias` so the shared-state contract becomes enforceable + rather than implicit. + --- ## Current State diff --git a/docs/WINNODE_CLI_MSIX_PACKAGING.md b/docs/WINNODE_CLI_MSIX_PACKAGING.md new file mode 100644 index 000000000..307f586da --- /dev/null +++ b/docs/WINNODE_CLI_MSIX_PACKAGING.md @@ -0,0 +1,163 @@ +# Node CLI packaging under MSIX + +> **Status:** Decision committed (recommended path: package the CLI inside the +> tray MSIX). Implementation is staged as a follow-up to PR #(this MSIX-E2E plan). + +## Why this matters + +`OpenClaw.WinNode.Cli` (the "worker node" CLI) and `OpenClaw.Tray.WinUI` (the +WinUI tray) share state via the file system. The contract today is implicit: + +| Artifact | Path | Writer | Reader | +|----------------------------------|-----------------------------------------------------|--------|--------| +| MCP bearer token | `%APPDATA%\OpenClawTray\mcp-token.txt` | Tray | CLI | +| Device identity (Ed25519 keypair)| `%APPDATA%\OpenClawTray\device-key-ed25519.json` | Tray | CLI | +| Exec-approval policy | `%LOCALAPPDATA%\OpenClawTray\exec-policy.json` | Tray | CLI | +| Operator pairing tokens | `%APPDATA%\OpenClawTray\settings.json` | Tray | CLI | + +Under MSIX with package identity this contract becomes load-bearing in a way +that is easy to break by accident: + +- `Environment.SpecialFolder.ApplicationData` returns the **user-profile** + `%APPDATA%` from both packaged and unpackaged processes, so today's CLI does + in fact see the same files the tray writes. This is the *only* reason the + contract works at all in MSIX mode. +- `ApplicationData.Current.LocalFolder` returns a **per-package** path + (`%LOCALAPPDATA%\Packages\\LocalState\`) that only packaged code can read. + Any future migration of the tray to that API would silently strand the CLI. +- MSIX file-system *redirection* (the legacy bridge that intercepts writes to + Program Files / HKLM / etc.) does **not** apply to `%APPDATA%`. So the shared + path keeps working — but only because nobody touches it. + +In short: the contract works today by coincidence. If anyone moves the tray to +`StorageFolder` APIs, the CLI stops finding the token without any compile-time +or runtime warning. We need to lock this down before the MSIX distribution path +becomes a primary install option. + +## Options considered + +### Option A — Keep the CLI unpackaged, formalize the shared path + +- CLI ships as a stand-alone signed `.exe`, downloaded separately or bundled in + the same release ZIP alongside the MSIX. +- Both processes resolve their state directory via a new `OPENCLAW_SHARED_DIR` + environment variable, defaulting to `%APPDATA%\OpenClawTray`. The MSIX + install writes this variable as part of first-run setup. +- Pros: zero MSIX manifest change, no impact on packaging pipeline, CLI can be + invoked with absolute paths from anywhere. +- Cons: still two binaries to sign and distribute; users have to discover the + CLI separately; CLI cannot use any packaged-app API (notifications under our + package identity, AppCapability checks, etc.). +- Hazard: anyone who looks at `Environment.SpecialFolder.ApplicationData` in + the tray code and "tidies it up" to `ApplicationData.Current.LocalFolder` + breaks the CLI in a way that is invisible until a user hits it. The env-var + contract has to be documented in big letters and enforced by a test. + +### Option B — Package the CLI inside the same MSIX (recommended) + +- Tray `Package.appxmanifest` declares a **second** `` element for + the CLI, with a `windows.appExecutionAlias` extension publishing the alias + `openclaw-winnode.exe` on `PATH`. +- Both processes use `ApplicationData.Current.LocalFolder` to resolve shared + state; the package container guarantees they see identical paths. +- The CLI runs with package identity, which gives it: + - notifications under the tray's package name, + - `AppCapability.CheckAccess(...)` for capabilities declared in the same + manifest, + - first-class participation in the OS-level uninstall (its state inside the + package container goes away cleanly when the package is removed). +- Pros: one signed artifact; uniform identity story; impossible-to-drift shared + state contract; no orphaned files on uninstall for state inside the container. +- Cons: requires a second `` element + `appExecutionAlias` plumbing; + CLI cold-start path goes through the AppExecutionAlias resolver (≈30 ms one-time + overhead, irrelevant for our usage). + +### Option C — Drop the CLI entirely + +- Subsume all CLI functionality into a tray command palette and deep links. +- Pros: simplest manifest; simplest packaging; no shared-state contract at all. +- Cons: breaks every existing script / integration that shells out to the CLI; + removes the "agent on a server has no UI" use case; explicitly out of scope + for this plan. + +## Recommendation: Option B (packaged CLI with `appExecutionAlias`) + +Option B is the only choice that eliminates the "two processes happen to agree +on a path" hazard. The OS gives us a single uninstall path and a single signing +artifact, the CLI gets a real identity, and the contract between tray and CLI +becomes "we are the same package", which is enforceable rather than implicit. + +### Manifest snippet (proposed, not yet wired) + +```xml + + + + + + + + + + + + + + + + + + +``` + +Required namespace additions on ``: + +``` +xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3" +xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10" +``` + +### Required CLI code changes (sketch, not yet committed) + +1. `OpenClaw.WinNode.Cli/Program.cs`: replace `Environment.SpecialFolder.ApplicationData` + resolution with a `PathResolver` that branches on `PackageHelper.IsPackaged` + (a copy of the tray's helper, or share via `OpenClaw.Shared`). Packaged → + `ApplicationData.Current.LocalFolder`. Unpackaged → today's path. +2. `OpenClaw.WinNode.Cli.csproj`: add `true` + so the CLI exe is properly bundled into the parent MSIX. +3. Tray `Package.appxmanifest`: insert the second `` from the + snippet above. +4. CI build job: add the CLI bin output to the AppPackages payload (today the + MSIX only includes the WinUI tray output). + +### Risks to track in the implementation PR + +- **Discovery**: the alias `openclaw-winnode.exe` only resolves once the user + has run the tray once (so the package registers). Anything that shells out + during install can't use it yet — use the full container path for installer + steps. +- **Console attach**: full-trust packaged consoles must `AllocConsole` / + `AttachConsole` to inherit the calling cmd's stdio; otherwise the CLI looks + like it does nothing when invoked from a terminal. The tray already has the + pattern in `App.xaml.cs`'s `RunCliUninstallAsync`; lift it into a helper. +- **AppContainer**: `runFullTrust` is still required for WSL / wsl.exe spawning + and for arbitrary file-system access. Do not remove it from the manifest. + +### Acceptance criteria for the follow-up implementation PR + +1. From a fresh PowerShell on a packaged install: `openclaw-winnode --version` + prints the same version the tray reports. +2. `Get-AppxPackage OpenClaw.Companion | Select Applications | fl *` shows both + applications. +3. CLI calls `--purge-wsl-orphans` and reports the same paths the tray would, + verified against a tray `--uninstall` golden output. +4. `Remove-AppxPackage` removes the CLI exe along with the tray (no orphan + `openclaw-winnode.exe` anywhere on disk). diff --git a/docs/uninstall-msix.md b/docs/uninstall-msix.md index 5a50ce336..783fddb81 100644 --- a/docs/uninstall-msix.md +++ b/docs/uninstall-msix.md @@ -45,31 +45,53 @@ Therefore, removing the MSIX package via **Settings → Apps → OpenClaw Tray ## Manual Recovery (After MSIX Removed Without In-Tray Cleanup) -If the MSIX was already removed and the WSL distro / app data remains: +If the MSIX was already removed and the WSL distro / app data remains, the +**supported recovery path** is the dedicated CLI flag: ```powershell -# 1. Unregister the distro (removes .vhdx from wsl's internal store) -wsl --unregister OpenClawGateway +# Detect orphans (dry-run; exits 1 if any found) +openclaw-winnode --purge-wsl-orphans --json-output -# 2. Remove VHD parent directory (wsl --unregister may leave the folder) -Remove-Item "$env:LOCALAPPDATA\OpenClawTray\wsl\OpenClawGateway" ` - -Recurse -Force -ErrorAction SilentlyContinue +# Apply the deletions +openclaw-winnode --purge-wsl-orphans --confirm-destructive --json-output +``` + +The CLI detects and removes: + +| Kind | Where | +|-----------------------|--------------------------------------------------------------------------------------| +| `wsl-distro` | Known app-owned WSL distro names such as `OpenClawGateway` and `openclaw-local` | +| `appdata-folder` | `%APPDATA%\OpenClawTray\` | +| `localappdata-folder` | `%LOCALAPPDATA%\OpenClawTray\` | +| `registry-uri-scheme` | `HKCU\Software\Classes\openclaw` (legacy unpackaged URI scheme) | +| `registry-run-key` | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\OpenClawTray` (legacy autostart) | + +When `--confirm-destructive` is used, the CLI refuses to delete anything if it +can still see the OpenClaw Companion MSIX registered for the current user or the +tray mutex is present. Use the in-app cleanup first. The +`--force-even-if-installed` override exists only for support cases where you +have independently verified the installed app is gone but Windows' package +registration check is stale or unavailable. + +If the CLI is not available (e.g., the package was uninstalled before this +fallback was published), the equivalent PowerShell one-liners are: + +```powershell +# 1. Unregister the WSL distro(s) +wsl --list --quiet | + Where-Object { $_ -in @('OpenClawGateway', 'openclaw-local', 'openclaw-staging') } | + ForEach-Object { wsl --unregister $_ } -# 3. Remove autostart registry entry +# 2. Remove autostart registry entry (legacy) Remove-ItemProperty ` -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" ` -Name "OpenClawTray" -ErrorAction SilentlyContinue -# 4. Remove local app data (setup state, logs) -Remove-Item "$env:LOCALAPPDATA\OpenClawTray" -Recurse -Force -ErrorAction SilentlyContinue +# 3. Remove openclaw:// URI scheme registration (legacy) +Remove-Item "HKCU:\SOFTWARE\Classes\openclaw" -Recurse -Force -ErrorAction SilentlyContinue -# 5. Remove roaming app data (settings, device key — only if you want full purge) -# NOTE: mcp-token.txt is intentionally preserved here; delete manually if needed. -Remove-Item "$env:APPDATA\OpenClawTray\setup-state.json" -Force -ErrorAction SilentlyContinue +# 4. Remove app data +Remove-Item "$env:LOCALAPPDATA\OpenClawTray" -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item "$env:APPDATA\OpenClawTray" -Recurse -Force -ErrorAction SilentlyContinue ``` -Or use the validation script if it is available separately: - -```powershell -.\validate-wsl-gateway-uninstall.ps1 -Mode Full -ConfirmDestructive -``` diff --git a/docs/uninstall-portable.md b/docs/uninstall-portable.md deleted file mode 100644 index 027b7e164..000000000 --- a/docs/uninstall-portable.md +++ /dev/null @@ -1,71 +0,0 @@ -# Uninstalling OpenClaw Tray — Portable ZIP - -> **Date:** 2026-05-07 -> **Branch:** feat/wsl-gateway-uninstall - -Portable (ZIP) installations have **no automatic uninstall hook**. -Simply deleting the folder leaves the WSL distro, app data, and autostart -entry behind. Follow one of the two paths below for a clean removal. - ---- - -## Recommended: In-Tray Removal (Requires the Tray Running) - -1. Open the tray icon. -2. Navigate to **Settings → Local Gateway**. -3. Click **"Remove Local Gateway"**. -4. The engine stops keepalive processes, unregisters the WSL distro, nulls - the device token, removes autostart, and cleans up app data. -5. After the operation completes, delete the portable folder. - ---- - -## CLI: Headless Removal (No Tray UI Required) - -Run from the portable folder: - -```powershell -# Destructive — removes the local WSL gateway cleanly, then print result to stdout -.\OpenClaw.Tray.WinUI.exe --uninstall --confirm-destructive - -# With JSON output for programmatic consumption (tokens redacted in output): -.\OpenClaw.Tray.WinUI.exe --uninstall --confirm-destructive --json-output .\uninstall-result.json - -# Dry-run — records what would happen without any destruction: -.\OpenClaw.Tray.WinUI.exe --uninstall --dry-run -``` - -**Exit codes:** - -| Code | Meaning | -|------|---------| -| 0 | Success — all steps completed, postconditions satisfied | -| 1 | Partial failure — one or more steps failed (see JSON output or stderr) | -| 2 | Bad arguments — `--confirm-destructive` or `--dry-run` missing | - -After the CLI command exits 0, delete the portable folder. - ---- - -## WARNING: Deleting the Folder Without Running Uninstall - -Deleting the portable folder **without** running the uninstall first leaves: - -- **WSL distro orphaned** — `OpenClawGateway` remains in `wsl --list`. - Manual cleanup: `wsl --unregister OpenClawGateway` - -- **App data** remains under: - - `%APPDATA%\OpenClawTray\` — device key, settings, mcp-token - - `%LOCALAPPDATA%\OpenClawTray\` — setup state, logs, exec policy, VHD parent dir - -- **Autostart entry** may remain in - `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run\OpenClawTray` - -Manual WSL + registry cleanup: - -```powershell -wsl --unregister OpenClawGateway -Remove-ItemProperty -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" ` - -Name "OpenClawTray" -ErrorAction SilentlyContinue -Remove-Item "$env:LOCALAPPDATA\OpenClawTray\wsl\OpenClawGateway" -Recurse -Force -ErrorAction SilentlyContinue -``` diff --git a/installer/appinstaller/README.md b/installer/appinstaller/README.md new file mode 100644 index 000000000..d52d159c1 --- /dev/null +++ b/installer/appinstaller/README.md @@ -0,0 +1,25 @@ +# Windows AppInstaller stable feed + +This directory is the source-controlled stable update feed for the OpenClaw +Companion MSIX channel. + +Installed MSIX packages poll these architecture-specific raw GitHub URLs: + +- `https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller` +- `https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-arm64.appinstaller` + +The checked-in feed files are bootstrap placeholders at `0.0.0.0` so the raw +URLs exist before the first signed MSIX embeds them. Release builds do not push +these files directly to `master`. After a successful stable release, +`.github/workflows/appinstaller-feed-pr.yml` renders the feed files from the +signed GitHub Release MSIX assets, validates them, and opens a PR. Merging that +PR advances the stable auto-update source for installed clients. + +Raw GitHub intentionally mirrors the Mac companion app's Sparkle appcast model, +but Windows App Installer has different hosting requirements. If two-version E2E +testing proves raw GitHub is not accepted by Windows App Installer, keep this +directory as the generated source of truth and publish the same files to a static +host or CDN that serves AppInstaller-compatible headers. + +Alpha/pre-release feed updates are blocked until maintainers choose a channel +strategy. Do not hand-edit stable feed files to point at alpha packages. diff --git a/installer/appinstaller/openclaw-arm64.appinstaller b/installer/appinstaller/openclaw-arm64.appinstaller new file mode 100644 index 000000000..a9dd48d8b --- /dev/null +++ b/installer/appinstaller/openclaw-arm64.appinstaller @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/installer/appinstaller/openclaw-x64.appinstaller b/installer/appinstaller/openclaw-x64.appinstaller new file mode 100644 index 000000000..6a7296884 --- /dev/null +++ b/installer/appinstaller/openclaw-x64.appinstaller @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/installer/openclaw-companion.appinstaller.template b/installer/openclaw-companion.appinstaller.template new file mode 100644 index 000000000..4b63af9e8 --- /dev/null +++ b/installer/openclaw-companion.appinstaller.template @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 deleted file mode 100644 index cde043c95..000000000 --- a/scripts/Uninstall-LocalGateway.ps1 +++ /dev/null @@ -1,79 +0,0 @@ -<# -.SYNOPSIS - Inno Setup [UninstallRun] helper — removes the local WSL gateway via the - OpenClaw tray CLI flag. - -.DESCRIPTION - INNO ORDERING CONTRACT - ---------------------- - Per Inno Setup documentation, [UninstallRun] entries execute BEFORE the - {app} directory is deleted. OpenClawTray.exe is therefore guaranteed to - be present when this script runs. - - WHAT THIS SCRIPT DOES - --------------------- - 1. Locates OpenClawTray.exe in the same directory as this script ({app}). - 2. Invokes: OpenClawTray.exe --uninstall --confirm-destructive --json-output - 3. Logs success or failure to {app}\uninstall-gateway-result.json. - 4. If the EXE is missing (e.g., partial install), logs the error and exits 0 - so the Inno uninstaller continues. The user may need to clean up manually - (see docs\uninstall-portable.md for manual steps). - - FALLBACK - -------- - Exit 0 in all error cases so Inno does not abort the uninstall if gateway - cleanup fails. The result JSON captures the failure for post-mortem. - -.NOTES - Date: 2026-05-07 - Author: Aaron (Backend / Infrastructure Engineer) - Branch: feat/wsl-gateway-uninstall - Commit: 5 of 7 - - Token / key material is NEVER written to the result log; the engine - and CLI layer both redact sensitive fields before serializing. -#> - -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' - -$scriptDir = $PSScriptRoot -$exePath = Join-Path $scriptDir 'OpenClaw.Tray.WinUI.exe' -$resultPath = Join-Path $scriptDir 'uninstall-gateway-result.json' -$errorPath = Join-Path $scriptDir 'uninstall-gateway-error.log' - -# --------------------------------------------------------------------------- -# EXE presence check — fallback if somehow missing -# --------------------------------------------------------------------------- -if (-not (Test-Path -LiteralPath $exePath)) { - $msg = "[$(Get-Date -Format 'o')] Uninstall-LocalGateway.ps1: " + - "OpenClawTray.exe not found at '$exePath'. " + - "WSL gateway cleanup skipped. Manual cleanup may be required." - try { $msg | Out-File -LiteralPath $errorPath -Encoding UTF8 -Force } catch {} - Write-Warning $msg - exit 0 -} - -# --------------------------------------------------------------------------- -# Invoke CLI uninstall -# --------------------------------------------------------------------------- -$exitCode = 0 -try { - & $exePath --uninstall --confirm-destructive --json-output $resultPath - $exitCode = if ($null -eq $global:LASTEXITCODE) { 0 } else { $global:LASTEXITCODE } - - if ($exitCode -eq 0) { - Write-Host "OpenClaw local WSL gateway removed successfully." -ForegroundColor Green - } else { - Write-Warning "OpenClaw gateway uninstall exited $exitCode; see '$resultPath' for details." - } -} catch { - $msg = "[$(Get-Date -Format 'o')] Uninstall-LocalGateway.ps1 error: $($_.Exception.Message)" - try { $msg | Out-File -LiteralPath $errorPath -Encoding UTF8 -Force } catch {} - Write-Warning $msg -} - -# Always exit 0 so Inno does not abort the broader uninstall. -exit 0 diff --git a/scripts/inject-setupengine-xaml-resources.ps1 b/scripts/inject-setupengine-xaml-resources.ps1 new file mode 100644 index 000000000..a48485dbe --- /dev/null +++ b/scripts/inject-setupengine-xaml-resources.ps1 @@ -0,0 +1,97 @@ +<# +.SYNOPSIS + Injects SetupEngine WinUI XAML resources into an unsigned MSIX package. + +.DESCRIPTION + The tray MSIX package owns its own PRI resource map. SetupEngine.UI is a + separate self-contained WinUI process copied under SetupEngine\. Its .xbf files + and OpenClaw.SetupEngine.UI.pri must be present beside the child executable, + but if those files are present while the tray project generates its PRI, their + resource keys collide with tray XAML resources that share names like + Pages/PermissionsPage.xbf. + + CI therefore builds the tray MSIX with the non-XAML SetupEngine payload, then + uses this script to inject SetupEngine's child-process XAML resources into the + unsigned MSIX before signing. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $MsixPath, + + [Parameter(Mandatory)] + [string] $SetupEnginePublishDir, + + [string] $MakeAppxPath +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $MsixPath -PathType Leaf)) { + throw "MSIX not found: $MsixPath" +} +if (-not (Test-Path -LiteralPath $SetupEnginePublishDir -PathType Container)) { + throw "SetupEngine publish directory not found: $SetupEnginePublishDir" +} + +if ([string]::IsNullOrWhiteSpace($MakeAppxPath)) { + $kitsRoot = "${env:ProgramFiles(x86)}\Windows Kits\10\bin" + $MakeAppxPath = Get-ChildItem $kitsRoot -Recurse -Filter makeappx.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\makeappx\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 -ExpandProperty FullName + if ([string]::IsNullOrWhiteSpace($MakeAppxPath)) { + $globalPackages = (dotnet nuget locals global-packages -l) -replace '^global-packages:\s*', '' + $MakeAppxPath = Get-ChildItem $globalPackages -Recurse -Filter makeappx.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\makeappx\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 -ExpandProperty FullName + } +} +if ([string]::IsNullOrWhiteSpace($MakeAppxPath) -or -not (Test-Path -LiteralPath $MakeAppxPath -PathType Leaf)) { + throw "makeappx.exe not found. Pass -MakeAppxPath or install Windows SDK build tools." +} + +$resources = Get-ChildItem -LiteralPath $SetupEnginePublishDir -Recurse -File | + Where-Object { $_.Extension -eq '.xbf' -or $_.Name -eq 'OpenClaw.SetupEngine.UI.pri' } +if (-not $resources) { + throw "No SetupEngine XAML resources found in $SetupEnginePublishDir" +} +if (-not ($resources | Where-Object { $_.Name -eq 'OpenClaw.SetupEngine.UI.pri' })) { + throw "SetupEngine publish output is missing OpenClaw.SetupEngine.UI.pri" +} +if (-not ($resources | Where-Object { $_.Name -eq 'SetupWindow.xbf' })) { + throw "SetupEngine publish output is missing SetupWindow.xbf" +} + +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "openclaw-msix-inject-$([System.Guid]::NewGuid().ToString('N'))" +$extractDir = Join-Path $tempRoot 'package' +$repacked = Join-Path $tempRoot ([System.IO.Path]::GetFileName($MsixPath)) + +try { + New-Item -ItemType Directory -Force -Path $extractDir | Out-Null + & $MakeAppxPath unpack /p $MsixPath /d $extractDir /o | Write-Host + if ($LASTEXITCODE -ne 0) { + throw "makeappx.exe unpack failed with exit code $LASTEXITCODE" + } + + $publishRoot = (Resolve-Path -LiteralPath $SetupEnginePublishDir).Path.TrimEnd('\') + foreach ($resource in $resources) { + $relative = $resource.FullName.Substring($publishRoot.Length).TrimStart('\') + $destination = Join-Path (Join-Path $extractDir 'SetupEngine') $relative + New-Item -ItemType Directory -Force -Path (Split-Path $destination -Parent) | Out-Null + Copy-Item -LiteralPath $resource.FullName -Destination $destination -Force + } + + & $MakeAppxPath pack /d $extractDir /p $repacked /o | Write-Host + if ($LASTEXITCODE -ne 0) { + throw "makeappx.exe failed with exit code $LASTEXITCODE" + } + + Copy-Item -LiteralPath $repacked -Destination $MsixPath -Force + Write-Host "Injected $($resources.Count) SetupEngine XAML resource file(s) into $MsixPath" +} +finally { + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/prepare-msix-payload-signing.ps1 b/scripts/prepare-msix-payload-signing.ps1 new file mode 100644 index 000000000..3648d5524 --- /dev/null +++ b/scripts/prepare-msix-payload-signing.ps1 @@ -0,0 +1,71 @@ +<# +.SYNOPSIS + Unpacks an MSIX package so its inner payload can be signed before final package signing. + +.DESCRIPTION + MSIX package signing protects package integrity, but executable payloads can + still be inspected or launched outside their package context during diagnostics. + This script prepares an unsigned MSIX for inner Authenticode signing by + extracting it to a directory and verifying that all script payloads are + signable PowerShell files. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $MsixPath, + + [Parameter(Mandatory)] + [string] $OutputDirectory, + + [string] $MakeAppxPath +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $MsixPath -PathType Leaf)) { + throw "MSIX not found: $MsixPath" +} + +Remove-Item -LiteralPath $OutputDirectory -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null + +if ([string]::IsNullOrWhiteSpace($MakeAppxPath)) { + $kitsRoot = "${env:ProgramFiles(x86)}\Windows Kits\10\bin" + $MakeAppxPath = Get-ChildItem $kitsRoot -Recurse -Filter makeappx.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\makeappx\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 -ExpandProperty FullName + if ([string]::IsNullOrWhiteSpace($MakeAppxPath)) { + $globalPackages = (dotnet nuget locals global-packages -l) -replace '^global-packages:\s*', '' + $MakeAppxPath = Get-ChildItem $globalPackages -Recurse -Filter makeappx.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\makeappx\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 -ExpandProperty FullName + } +} +if ([string]::IsNullOrWhiteSpace($MakeAppxPath) -or -not (Test-Path -LiteralPath $MakeAppxPath -PathType Leaf)) { + throw "makeappx.exe not found. Pass -MakeAppxPath or install Windows SDK build tools." +} + +& $MakeAppxPath unpack /p $MsixPath /d $OutputDirectory /o | Write-Host +if ($LASTEXITCODE -ne 0) { + throw "makeappx.exe unpack failed with exit code $LASTEXITCODE" +} + +$signableExtensions = @('.exe', '.dll', '.ps1', '.psm1', '.psd1') +$unsupportedScriptExtensions = @('.cmd', '.bat', '.vbs', '.js') + +$payloadFiles = Get-ChildItem -LiteralPath $OutputDirectory -Recurse -File +$unsupportedScripts = @($payloadFiles | Where-Object { $_.Extension.ToLowerInvariant() -in $unsupportedScriptExtensions }) +if ($unsupportedScripts.Count -gt 0) { + $list = ($unsupportedScripts | ForEach-Object { $_.FullName.Substring($OutputDirectory.Length).TrimStart('\') }) -join ', ' + throw "MSIX contains script files that cannot be Authenticode-signed by the release workflow: $list" +} + +$signableFiles = @($payloadFiles | Where-Object { $_.Extension.ToLowerInvariant() -in $signableExtensions }) +if ($signableFiles.Count -eq 0) { + throw "No signable payload files found in $MsixPath" +} + +Write-Host "Prepared $($signableFiles.Count) signable MSIX payload file(s) under $OutputDirectory" diff --git a/scripts/render-appinstaller.ps1 b/scripts/render-appinstaller.ps1 new file mode 100644 index 000000000..9764fbf62 --- /dev/null +++ b/scripts/render-appinstaller.ps1 @@ -0,0 +1,190 @@ +<# +.SYNOPSIS + Renders installer/openclaw-companion.appinstaller.template into a release-ready + AppInstaller XML by substituting the {{TOKEN}} placeholders. + +.DESCRIPTION + Used by .github/workflows/ci.yml during the release job. Also runnable + locally to validate template renders before tagging a release. + + The rendered AppInstaller XML must validate against the AppInstaller schema + (https://schemas.microsoft.com/appx/appinstaller/2018). We assert via XML + load rather than schema validation because the schema isn't shipped with the + Windows SDK on most runners. + +.PARAMETER Version + 4-part version string (e.g. "0.5.3.0"). Must match the MSIX . + +.PARAMETER Publisher + Publisher subject from the MSIX manifest, with quoting preserved. Example: + "CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US" + +.PARAMETER ProcessorArchitecture + MSIX processor architecture for this AppInstaller file. Must be x64 or arm64. + +.PARAMETER IdentityName + MSIX package identity for the MainPackage element. Stable releases use + OpenClaw.Companion. Channel-specific releases must pass the patched package + identity so AppInstaller never crosses channels. + +.PARAMETER MsixUri + Absolute https:// URL of the matching architecture .msix release asset. + +.PARAMETER WindowsAppRuntimeUri + Absolute https:// URL of the matching architecture Microsoft.WindowsAppRuntime.2 + framework .msix release asset. AppInstaller installs this dependency when it + is missing, avoiding a launch-time runtime acquisition prompt. + +.PARAMETER AppInstallerUri + Absolute https:// URL of THIS rendered .appinstaller file on the stable + channel (e.g. https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller). + Embedded inside the AppInstaller so Windows AppInstaller knows where to poll. + +.PARAMETER OutputPath + Destination path for the rendered .appinstaller file. + +.PARAMETER AllowHttpForLocalTest + Allows http:// loopback URIs for local AppInstaller smoke tests. Production + release rendering must omit this switch and use https:// URLs. + +.EXAMPLE + ./scripts/render-appinstaller.ps1 ` + -Version 0.5.3.0 ` + -Publisher 'CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US' ` + -IdentityName OpenClaw.Companion ` + -ProcessorArchitecture x64 ` + -MsixUri https://github.com/.../v0.5.3/OpenClawCompanion-0.5.3-win-x64.msix ` + -WindowsAppRuntimeUri https://github.com/.../v0.5.3/Microsoft.WindowsAppRuntime.2-2.0.1.0-win-x64.msix ` + -AppInstallerUri https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller ` + -OutputPath OpenClawCompanion-0.5.3-win-x64.appinstaller +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $Version, + [Parameter(Mandatory)] [string] $Publisher, + [string] $IdentityName = 'OpenClaw.Companion', + [Parameter(Mandatory)] [ValidateSet('x64', 'arm64')] [string] $ProcessorArchitecture, + [Parameter(Mandatory)] [string] $MsixUri, + [Parameter(Mandatory)] [string] $WindowsAppRuntimeUri, + [Parameter(Mandatory)] [string] $AppInstallerUri, + [Parameter(Mandatory)] [string] $OutputPath, + [switch] $AllowHttpForLocalTest +) + +$ErrorActionPreference = 'Stop' + +# Validate version is 4-part. AppInstaller silently accepts 1-3 part versions +# but Windows AppInstaller's update detector compares them as 4-part, so a +# 3-part value produces "no update available" forever. +$parts = $Version.Split('.') +if ($parts.Length -ne 4) { + throw "Version must be 4-part (X.Y.Z.W). Got: '$Version'" +} +foreach ($p in $parts) { + if (-not [int]::TryParse($p, [ref]([int]0))) { + throw "Version segment '$p' is not an integer." + } +} + +if ([string]::IsNullOrWhiteSpace($IdentityName)) { + throw "IdentityName must not be empty." +} + +# Validate URIs are absolute https:// for production. Local smoke tests may use +# http://127.0.0.1 with -AllowHttpForLocalTest. +foreach ($pair in @( + @{ Name = 'MsixUri'; Value = $MsixUri }, + @{ Name = 'WindowsAppRuntimeUri'; Value = $WindowsAppRuntimeUri }, + @{ Name = 'AppInstallerUri'; Value = $AppInstallerUri } + )) { + $u = $null + if (-not [Uri]::TryCreate($pair.Value, 'Absolute', [ref]$u)) { + throw "$($pair.Name) must be an absolute URL. Got: '$($pair.Value)'" + } + + $isAllowedHttpLoopback = $AllowHttpForLocalTest -and $u.Scheme -eq 'http' -and $u.IsLoopback + if ($u.Scheme -ne 'https' -and -not $isAllowedHttpLoopback) { + throw "$($pair.Name) must be an absolute https:// URL. Got: '$($pair.Value)'" + } +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$templatePath = Join-Path $repoRoot 'installer\openclaw-companion.appinstaller.template' +if (-not (Test-Path $templatePath)) { + throw "Template not found: $templatePath" +} + +$template = Get-Content $templatePath -Raw + +# Simple string substitution — inputs are not regex patterns and we don't want +# regex-metacharacter surprises from values like a publisher subject that +# contains literal commas/quotes or a URI with a percent-encoded character. +$rendered = $template +$rendered = $rendered.Replace('{{VERSION}}', $Version) +$rendered = $rendered.Replace('{{PUBLISHER}}', $Publisher) +$rendered = $rendered.Replace('{{IDENTITY_NAME}}', $IdentityName) +$rendered = $rendered.Replace('{{PROCESSOR_ARCHITECTURE}}', $ProcessorArchitecture) +$rendered = $rendered.Replace('{{MSIX_URI}}', $MsixUri) +$rendered = $rendered.Replace('{{WINDOWS_APP_RUNTIME_URI}}', $WindowsAppRuntimeUri) +$rendered = $rendered.Replace('{{APPINSTALLER_URI}}', $AppInstallerUri) +if ($rendered -match '\{\{[A-Z0-9_]+\}\}') { + throw "Rendered XML still contains unresolved template token(s): $($Matches[0])" +} + +# Validate the rendered XML parses. A bad template / bad substitution surfaces +# here instead of at deploy time when Windows refuses to install. +[xml]$xml = $rendered +if ($xml.AppInstaller.Version -ne $Version) { + throw "Rendered XML has Version '$($xml.AppInstaller.Version)' but expected '$Version'. Substitution failure." +} +$mainPackage = $xml.AppInstaller.MainPackage +if ($null -eq $mainPackage) { + throw "Rendered XML must contain exactly one MainPackage element." +} +if ($mainPackage.Publisher -ne $Publisher) { + throw "Rendered XML has Publisher '$($mainPackage.Publisher)' but expected '$Publisher'." +} +if ($mainPackage.Name -ne $IdentityName) { + throw "Rendered XML has MainPackage Name '$($mainPackage.Name)' but expected '$IdentityName'." +} +if ($mainPackage.Version -ne $Version) { + throw "Rendered XML has MainPackage Version '$($mainPackage.Version)' but expected '$Version'." +} +if ($mainPackage.ProcessorArchitecture -ne $ProcessorArchitecture) { + throw "Rendered XML has ProcessorArchitecture '$($mainPackage.ProcessorArchitecture)' but expected '$ProcessorArchitecture'." +} +if ($mainPackage.Uri -ne $MsixUri) { + throw "Rendered XML has package Uri '$($mainPackage.Uri)' but expected '$MsixUri'." +} +$dependency = $xml.AppInstaller.Dependencies.Package | Where-Object { $_.Name -eq 'Microsoft.WindowsAppRuntime.2' } | Select-Object -First 1 +if ($null -eq $dependency) { + throw "Rendered XML must contain a Microsoft.WindowsAppRuntime.2 dependency package." +} +if ($dependency.Publisher -ne 'CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US') { + throw "Rendered XML has Windows App Runtime Publisher '$($dependency.Publisher)'." +} +if ($dependency.Version -ne '2.0.1.0') { + throw "Rendered XML has Windows App Runtime Version '$($dependency.Version)' but expected '2.0.1.0'." +} +if ($dependency.ProcessorArchitecture -ne $ProcessorArchitecture) { + throw "Rendered XML has Windows App Runtime ProcessorArchitecture '$($dependency.ProcessorArchitecture)' but expected '$ProcessorArchitecture'." +} +if ($dependency.Uri -ne $WindowsAppRuntimeUri) { + throw "Rendered XML has Windows App Runtime Uri '$($dependency.Uri)' but expected '$WindowsAppRuntimeUri'." +} + +$outDir = Split-Path -Parent $OutputPath +if ($outDir -and -not (Test-Path $outDir)) { + New-Item -ItemType Directory -Force -Path $outDir | Out-Null +} +Set-Content -Path $OutputPath -Value $rendered -Encoding UTF8 + +Write-Host "Rendered AppInstaller: $OutputPath" +Write-Host " Version: $Version" +Write-Host " Publisher: $Publisher" +Write-Host " Identity: $IdentityName" +Write-Host " Architecture: $ProcessorArchitecture" +Write-Host " MSIX URI: $MsixUri" +Write-Host " Windows App Runtime URI: $WindowsAppRuntimeUri" +Write-Host " AppInstaller URI: $AppInstallerUri" diff --git a/scripts/repack-msix.ps1 b/scripts/repack-msix.ps1 new file mode 100644 index 000000000..48d524d40 --- /dev/null +++ b/scripts/repack-msix.ps1 @@ -0,0 +1,53 @@ +<# +.SYNOPSIS + Re-packs an extracted MSIX payload directory. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $PayloadDirectory, + + [Parameter(Mandatory)] + [string] $MsixPath, + + [string] $MakeAppxPath +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $PayloadDirectory -PathType Container)) { + throw "Payload directory not found: $PayloadDirectory" +} + +if ([string]::IsNullOrWhiteSpace($MakeAppxPath)) { + $kitsRoot = "${env:ProgramFiles(x86)}\Windows Kits\10\bin" + $MakeAppxPath = Get-ChildItem $kitsRoot -Recurse -Filter makeappx.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\makeappx\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 -ExpandProperty FullName + if ([string]::IsNullOrWhiteSpace($MakeAppxPath)) { + $globalPackages = (dotnet nuget locals global-packages -l) -replace '^global-packages:\s*', '' + $MakeAppxPath = Get-ChildItem $globalPackages -Recurse -Filter makeappx.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\makeappx\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 -ExpandProperty FullName + } +} +if ([string]::IsNullOrWhiteSpace($MakeAppxPath) -or -not (Test-Path -LiteralPath $MakeAppxPath -PathType Leaf)) { + throw "makeappx.exe not found. Pass -MakeAppxPath or install Windows SDK build tools." +} + +$tempPackage = Join-Path ([System.IO.Path]::GetTempPath()) "$([System.IO.Path]::GetFileNameWithoutExtension($MsixPath))-$([System.Guid]::NewGuid().ToString('N')).msix" +try { + & $MakeAppxPath pack /d $PayloadDirectory /p $tempPackage /o | Write-Host + if ($LASTEXITCODE -ne 0) { + throw "makeappx.exe failed with exit code $LASTEXITCODE" + } + + Copy-Item -LiteralPath $tempPackage -Destination $MsixPath -Force + Write-Host "Repacked MSIX: $MsixPath" +} +finally { + Remove-Item -LiteralPath $tempPackage -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/test-appinstaller-update.ps1 b/scripts/test-appinstaller-update.ps1 new file mode 100644 index 000000000..80e495371 --- /dev/null +++ b/scripts/test-appinstaller-update.ps1 @@ -0,0 +1,180 @@ +<# +.SYNOPSIS + Simulates a non-Store .appinstaller upgrade by hosting two MSIX versions + on a local HTTP server and walking the install vN -> publish vN+1 -> + trigger upgrade flow end-to-end. + +.DESCRIPTION + The point of this script is to catch regressions in the .appinstaller + XML and the PackageManager.AddPackageByAppInstallerFileAsync wiring + *without* needing a real GitHub release / stable feed PR cycle. Run this + before a release tag goes out; if it fails, the same failure will happen + to every user that installs from the stable architecture-specific AppInstaller URL. + + Steps: + 1. Launch a tiny HTTP server (HttpListener) on localhost:8765 that serves + the two MSIX files + a rendered .appinstaller pointing at vN+1. + 2. Render an "old" .appinstaller pointing at vN, install it (this records + the source URL with Windows AppInstaller). + 3. Re-render the .appinstaller in place pointing at vN+1. + 4. Invoke PackageManager.AddPackageByAppInstallerFileAsync against the + local URL — this is the same call the in-app "Check for updates" + button makes. + 5. Assert Get-AppxPackage reports the new Version. + 6. Tear down. + +.PARAMETER MsixVnPath + Path to the "older" .msix (used as the seed install). + +.PARAMETER MsixVn1Path + Path to the "newer" .msix (used as the upgrade target). + +.PARAMETER VnVersion + 4-part version of the older .msix (e.g. 0.5.3.0). + +.PARAMETER Vn1Version + 4-part version of the newer .msix (e.g. 0.5.4.0). + +.PARAMETER Publisher + Publisher subject that must match BOTH MSIX manifests. + +.EXAMPLE + ./scripts/test-appinstaller-update.ps1 ` + -MsixVnPath .\OpenClawCompanion-0.5.3-win-x64.msix -VnVersion 0.5.3.0 ` + -MsixVn1Path .\OpenClawCompanion-0.5.4-win-x64.msix -Vn1Version 0.5.4.0 +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $MsixVnPath, + [Parameter(Mandatory)] [string] $VnVersion, + [Parameter(Mandatory)] [string] $MsixVn1Path, + [Parameter(Mandatory)] [string] $Vn1Version, + [string] $Publisher = 'CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US', + [int] $Port = 8765 +) + +$ErrorActionPreference = 'Stop' + +foreach ($p in @($MsixVnPath, $MsixVn1Path)) { + if (-not (Test-Path $p)) { throw "MSIX not found: $p" } +} + +$tmp = Join-Path ([System.IO.Path]::GetTempPath()) "openclaw-appinstaller-test-$(Get-Random)" +New-Item -ItemType Directory -Force -Path $tmp | Out-Null + +try { + Copy-Item $MsixVnPath (Join-Path $tmp 'vN.msix') + Copy-Item $MsixVn1Path (Join-Path $tmp 'vNplus1.msix') + $globalPackages = (dotnet nuget locals global-packages -l) -replace '^global-packages:\s*', '' + $runtimeMsix = Join-Path $globalPackages 'microsoft.windowsappsdk.runtime\2.0.1\tools\MSIX\win10-x64\Microsoft.WindowsAppRuntime.2.msix' + if (-not (Test-Path $runtimeMsix)) { + throw "Windows App Runtime framework package not found: $runtimeMsix" + } + Copy-Item $runtimeMsix (Join-Path $tmp 'Microsoft.WindowsAppRuntime.2.msix') -Force + + $baseUri = "http://127.0.0.1:$Port" + $repoRoot = Split-Path -Parent $PSScriptRoot + + function Render-AppInstaller { + param([string]$Version, [string]$MsixFileName, [string]$OutputPath) + & "$repoRoot\scripts\render-appinstaller.ps1" ` + -Version $Version ` + -Publisher $Publisher ` + -ProcessorArchitecture x64 ` + -MsixUri "$baseUri/$MsixFileName" ` + -WindowsAppRuntimeUri "$baseUri/Microsoft.WindowsAppRuntime.2.msix" ` + -AppInstallerUri "$baseUri/openclaw.appinstaller" ` + -OutputPath $OutputPath ` + -AllowHttpForLocalTest + } + + Render-AppInstaller -Version $VnVersion -MsixFileName 'vN.msix' -OutputPath (Join-Path $tmp 'openclaw.appinstaller') + + # Spin up exactly one HttpListener in a background job. Binding the same + # prefix in both parent and job makes the smoke test fail before AppInstaller + # is exercised. + $listenerJob = Start-Job -ScriptBlock { + param($prefix, $root) + $l = [System.Net.HttpListener]::new() + $l.Prefixes.Add("$prefix/") + $l.Start() + while ($l.IsListening) { + $ctx = $l.GetContext() + $name = [System.IO.Path]::GetFileName($ctx.Request.Url.LocalPath) + $path = Join-Path $root $name + if (Test-Path $path) { + $bytes = [System.IO.File]::ReadAllBytes($path) + $ctx.Response.ContentType = if ($name.EndsWith('.appinstaller')) { 'application/appinstaller' } else { 'application/octet-stream' } + $ctx.Response.ContentLength64 = $bytes.Length + $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length) + } else { + $ctx.Response.StatusCode = 404 + } + $ctx.Response.Close() + } + } -ArgumentList $baseUri, $tmp + + $listenerReady = $false + for ($i = 0; $i -lt 20; $i++) { + if ($listenerJob.State -eq 'Failed') { + Receive-Job $listenerJob -Keep | Out-String | Write-Error + throw "AppInstaller test HTTP listener failed to start." + } + + try { + Invoke-WebRequest "$baseUri/openclaw.appinstaller" -UseBasicParsing -TimeoutSec 2 | Out-Null + $listenerReady = $true + break + } catch { + Start-Sleep -Milliseconds 250 + } + } + if (-not $listenerReady) { + throw "AppInstaller test HTTP listener did not serve $baseUri/openclaw.appinstaller." + } + Write-Host "Listening on $baseUri/" -ForegroundColor Cyan + + try { + # Step 2: install vN via the .appinstaller URL. + Write-Host "Installing vN via $baseUri/openclaw.appinstaller ..." -ForegroundColor Cyan + Add-AppxPackage -AppInstallerFile "$baseUri/openclaw.appinstaller" -ForceApplicationShutdown + $pkg = Get-AppxPackage -Name 'OpenClaw.Companion*' | Select-Object -First 1 + if ($pkg.Version -ne $VnVersion) { + throw "Expected vN install to land version $VnVersion, got $($pkg.Version)" + } + Write-Host " vN installed: $($pkg.Version)" -ForegroundColor Green + + # Step 3: re-render the .appinstaller in place pointing at vN+1. + Render-AppInstaller -Version $Vn1Version -MsixFileName 'vNplus1.msix' -OutputPath (Join-Path $tmp 'openclaw.appinstaller') + + # Step 4: trigger the in-app update path via PackageManager. + Write-Host "Triggering upgrade to vN+1 via PackageManager.AddPackageByAppInstallerFileAsync ..." -ForegroundColor Cyan + Add-Type -AssemblyName 'Windows.Management.Deployment.PackageManager, ContentType=WindowsRuntime' + $pm = [Windows.Management.Deployment.PackageManager,Windows.Management.Deployment,ContentType=WindowsRuntime]::new() + $op = $pm.AddPackageByAppInstallerFileAsync( + [Uri]"$baseUri/openclaw.appinstaller", + [Windows.Management.Deployment.AddPackageByAppInstallerOptions]::None, + $pm.GetDefaultPackageVolume()) + $result = $op.AsTask().GetAwaiter().GetResult() + if (-not $result.IsRegistered) { + throw "Upgrade failed: $($result.ErrorText) (HRESULT 0x$('{0:X8}' -f $result.ExtendedErrorCode.HResult))" + } + Write-Host " PackageManager reported IsRegistered=$($result.IsRegistered)" -ForegroundColor Green + + # Step 5: assert. + $pkg = Get-AppxPackage -Name 'OpenClaw.Companion*' | Select-Object -First 1 + if ($pkg.Version -ne $Vn1Version) { + throw "Expected upgrade to land version $Vn1Version, got $($pkg.Version)" + } + Write-Host "vN+1 verified at $($pkg.Version)" -ForegroundColor Green + + Write-Host "`nAppInstaller upgrade simulation: PASS" -ForegroundColor Green + } + finally { + if ($listenerJob) { Stop-Job $listenerJob -ErrorAction SilentlyContinue; Remove-Job $listenerJob -Force -ErrorAction SilentlyContinue } + } +} +finally { + if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue } +} diff --git a/scripts/test-msix-install.ps1 b/scripts/test-msix-install.ps1 new file mode 100644 index 000000000..e084b14c3 --- /dev/null +++ b/scripts/test-msix-install.ps1 @@ -0,0 +1,166 @@ +<# +.SYNOPSIS + End-to-end smoke test for the OpenClaw Companion MSIX install / launch / + health-check / uninstall cycle. Runnable locally on a developer Windows box + and from CI (windows-latest runner). + +.DESCRIPTION + Designed to be the automated counterpart to the manual runbook in + docs/WINDOWS_NODE_TESTING.md. Each step is independent, prints PASS/FAIL, + and the script exits non-zero on the first failure. + + Steps: + 1. Install the MSIX via Add-AppxPackage. + 2. Assert the package shows up in Get-AppxPackage with the expected + Publisher and a 4-part Version. + 3. Launch the tray (Start-Process via the package family activation alias) + and wait for the singleton named-pipe ("OpenClawTray-DeepLink") to come + up — that's the readiness signal. + 4. Send an `openclaw://health` deep link through the pipe. + 5. Stop the tray process(es). + 6. Remove-AppxPackage and assert no orphan files remain in + %APPDATA%\OpenClawTray\ or %LOCALAPPDATA%\OpenClawTray\. + +.PARAMETER MsixPath + Path to the .msix produced by build-msix CI job (or by a local + `msbuild /p:PackageMsix=true` invocation). + +.PARAMETER ExpectedPublisher + Publisher subject the package must declare. Defaults to the Trusted Signing + cert subject used by CI. + +.PARAMETER KeepInstall + Don't run the uninstall step at the end. Useful when debugging an install + problem and you want the package to stay registered between runs. + +.EXAMPLE + ./scripts/test-msix-install.ps1 -MsixPath .\OpenClawCompanion-0.5.3-win-x64.msix + +.NOTES + This script does NOT exercise the AppInstaller (`.appinstaller`) flow — + for that, see scripts/test-appinstaller-update.ps1 which spins up a local + HTTP server and walks the vN -> vN+1 upgrade path. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $MsixPath, + [string] $ExpectedPublisher = 'CN=Scott Hanselman, O=Scott Hanselman, L=Forest Grove, S=Oregon, C=US', + [switch] $KeepInstall +) + +$ErrorActionPreference = 'Stop' +$script:failed = 0 + +function Assert-True { + param([bool]$Condition, [string]$Message) + if ($Condition) { + Write-Host " PASS: $Message" -ForegroundColor Green + } else { + Write-Host " FAIL: $Message" -ForegroundColor Red + $script:failed++ + } +} + +function Section { param([string]$Title) Write-Host "`n=== $Title ===" -ForegroundColor Cyan } + +if (-not (Test-Path $MsixPath)) { + throw "MSIX not found: $MsixPath" +} + +Section 'Step 1: Install MSIX' +try { + Add-AppxPackage -Path $MsixPath -ForceApplicationShutdown -ErrorAction Stop + Assert-True $true "Add-AppxPackage exited cleanly" +} catch { + Assert-True $false "Add-AppxPackage failed: $($_.Exception.Message)" + exit 1 +} + +Section 'Step 2: Assert package presence' +$pkg = Get-AppxPackage -Name 'OpenClaw.Companion*' | Select-Object -First 1 +Assert-True ($null -ne $pkg) "Get-AppxPackage finds OpenClaw.Companion*" +if ($pkg) { + Assert-True ($pkg.Publisher -eq $ExpectedPublisher) "Publisher matches: $($pkg.Publisher)" + $versionParts = $pkg.Version.Split('.') + Assert-True ($versionParts.Length -eq 4) "Version is 4-part: $($pkg.Version)" +} + +Section 'Step 3: Launch + wait for singleton named pipe' +if ($pkg) { + # Activate via the package family — same path users hit from Start menu. + $appId = ($pkg.PackageFamilyName + '!App') + Start-Process -FilePath "shell:AppsFolder\$appId" -ErrorAction SilentlyContinue + # Wait up to 30s for the OpenClawTray-DeepLink named pipe to appear. + $deadline = (Get-Date).AddSeconds(30) + $pipeUp = $false + while ((Get-Date) -lt $deadline) { + $pipes = [System.IO.Directory]::GetFiles('\\.\pipe\') 2>$null + if ($pipes -and ($pipes | Where-Object { $_ -match 'OpenClawTray-DeepLink' })) { + $pipeUp = $true + break + } + Start-Sleep -Milliseconds 500 + } + Assert-True $pipeUp "Named pipe 'OpenClawTray-DeepLink' came up within 30s" +} + +Section 'Step 4: Health deep link round-trip' +if ($pkg -and $pipeUp) { + try { + $client = [System.IO.Pipes.NamedPipeClientStream]::new('.', 'OpenClawTray-DeepLink', 'Out') + $client.Connect(5000) + $writer = [System.IO.StreamWriter]::new($client) + $writer.WriteLine('openclaw://health') + $writer.Flush() + $writer.Dispose() + $client.Dispose() + Assert-True $true "Wrote openclaw://health to the deep-link pipe" + } catch { + Assert-True $false "Pipe write failed: $($_.Exception.Message)" + } +} + +Section 'Step 5: Stop tray process' +Get-Process -Name 'OpenClaw.Tray.WinUI' -ErrorAction SilentlyContinue | + ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue } +Assert-True $true "Tray processes stopped (best-effort)" + +Section 'Step 6: Uninstall + orphan check' +if ($KeepInstall) { + Write-Host " (skipping uninstall due to -KeepInstall)" -ForegroundColor Yellow +} elseif ($pkg) { + try { + Remove-AppxPackage -Package $pkg.PackageFullName -ErrorAction Stop + Assert-True $true "Remove-AppxPackage exited cleanly" + } catch { + Assert-True $false "Remove-AppxPackage failed: $($_.Exception.Message)" + } + + $stillThere = Get-AppxPackage -Name 'OpenClaw.Companion*' -ErrorAction SilentlyContinue + Assert-True ($null -eq $stillThere) "Package removed from Get-AppxPackage" + + # File orphans: MSIX uninstall removes the package container but does NOT + # touch the historical %APPDATA%\OpenClawTray\ / %LOCALAPPDATA%\OpenClawTray\ + # folders. We assert that the smoke-test install didn't write to them + # (a fresh install on a clean profile shouldn't create them at all). This + # is the case the in-app Reset & remove flow targets. + $appDataOrphan = Test-Path (Join-Path $env:APPDATA 'OpenClawTray') + $localAppDataOrphan = Test-Path (Join-Path $env:LOCALAPPDATA 'OpenClawTray') + if ($appDataOrphan -or $localAppDataOrphan) { + Write-Host " WARNING: orphan folders detected (likely from a prior install):" -ForegroundColor Yellow + if ($appDataOrphan) { Write-Host " %APPDATA%\OpenClawTray\" } + if ($localAppDataOrphan) { Write-Host " %LOCALAPPDATA%\OpenClawTray\" } + Write-Host " Run 'openclaw-winnode --purge-wsl-orphans --confirm-destructive' to clean." + } else { + Assert-True $true "No orphan %APPDATA% / %LOCALAPPDATA% folders" + } +} + +Section 'Summary' +if ($script:failed -gt 0) { + Write-Host "$script:failed assertion(s) failed." -ForegroundColor Red + exit 1 +} +Write-Host "All assertions passed." -ForegroundColor Green +exit 0 diff --git a/scripts/validate-appinstaller-hosting.ps1 b/scripts/validate-appinstaller-hosting.ps1 new file mode 100644 index 000000000..b59403e2b --- /dev/null +++ b/scripts/validate-appinstaller-hosting.ps1 @@ -0,0 +1,217 @@ +<# +.SYNOPSIS + Validates hosted AppInstaller and MSIX URLs before promoting a release. + +.DESCRIPTION + Windows AppInstaller is strict about hosted metadata and package assets. This + script checks the stable .appinstaller URL, parses its MainPackage URI when + -MsixUri is not provided, then validates the MSIX endpoint. It is intended for + release operators before promoting openclaw-x64.appinstaller or + openclaw-arm64.appinstaller to the stable feed location. + +.PARAMETER AppInstallerUri + Stable hosted .appinstaller URL, e.g. + https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller. + +.PARAMETER MsixUri + Optional MSIX URL. When omitted, the script fetches AppInstallerUri and reads + the MainPackage Uri attribute. + +.PARAMETER AppInstallerPath + Optional local .appinstaller file to parse instead of fetching AppInstallerUri. + This is used by the feed-update PR workflow before the rendered file has been + merged into the stable raw GitHub location. + +.PARAMETER AllowGitHubContentTypes + Candidate-mode compatibility switch for GitHub-hosted release assets. GitHub + release downloads currently serve MSIX files as application/octet-stream. This + switch keeps strict validation as the default while allowing two-version E2E + testing to prove whether Windows AppInstaller accepts GitHub's headers. + +.EXAMPLE + ./scripts/validate-appinstaller-hosting.ps1 ` + -AppInstallerUri https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller +#> + +[CmdletBinding()] +param( + [Uri] $AppInstallerUri, + [string] $AppInstallerPath, + [Uri] $MsixUri, + [switch] $AllowGitHubContentTypes +) + +$ErrorActionPreference = 'Stop' + +function Get-HeaderValue { + param( + [Parameter(Mandatory)] $Response, + [Parameter(Mandatory)] [string] $Name + ) + + $value = $Response.Headers[$Name] + if ($value -is [array]) { return $value[0] } + return $value +} + +function Invoke-Head { + param([Parameter(Mandatory)] [Uri] $Uri) + + try { + return Invoke-WebRequest -Uri $Uri -Method Head -MaximumRedirection 5 -UseBasicParsing + } + catch { + throw "HEAD $Uri failed: $($_.Exception.Message)" + } +} + +function Assert-HttpsUri { + param( + [Parameter(Mandatory)] [Uri] $Uri, + [Parameter(Mandatory)] [string] $Description + ) + + if ($Uri.Scheme -ne 'https') { + throw "$Description must use https: $Uri" + } +} + +function Assert-ContentType { + param( + [Parameter(Mandatory)] $Response, + [Parameter(Mandatory)] [Uri] $Uri, + [Parameter(Mandatory)] [string] $Expected, + [string[]] $AlsoAllowed = @() + ) + + $contentType = Get-HeaderValue -Response $Response -Name 'Content-Type' + $allowed = @($Expected) + $AlsoAllowed + foreach ($candidate in $allowed) { + if (-not [string]::IsNullOrWhiteSpace($contentType) -and + $contentType.StartsWith($candidate, [StringComparison]::OrdinalIgnoreCase)) { + Write-Host " Content-Type OK: $contentType" + return + } + } + + if ([string]::IsNullOrWhiteSpace($contentType) -or + $allowed.Count -eq 1) { + throw "$Uri returned Content-Type '$contentType'; expected '$Expected'." + } + throw "$Uri returned Content-Type '$contentType'; expected one of: $($allowed -join ', ')." +} + +function Get-MainPackageUri { + param([Parameter(Mandatory)] [xml] $AppInstallerXml) + + $namespaceManager = [System.Xml.XmlNamespaceManager]::new($AppInstallerXml.NameTable) + $namespaceManager.AddNamespace('ai', 'http://schemas.microsoft.com/appx/appinstaller/2018') + $mainPackage = $AppInstallerXml.SelectSingleNode('/ai:AppInstaller/ai:MainPackage', $namespaceManager) + if ($null -eq $mainPackage) { + $mainPackage = $AppInstallerXml.SelectSingleNode('/AppInstaller/MainPackage') + } + + $mainPackageUri = if ($null -eq $mainPackage) { $null } else { $mainPackage.GetAttribute('Uri') } + if ([string]::IsNullOrWhiteSpace($mainPackageUri)) { + throw "AppInstaller XML does not contain a MainPackage Uri." + } + + return [Uri]$mainPackageUri +} + +function Assert-ContentLength { + param( + [Parameter(Mandatory)] $Response, + [Parameter(Mandatory)] [Uri] $Uri + ) + + $contentLength = Get-HeaderValue -Response $Response -Name 'Content-Length' + if ([string]::IsNullOrWhiteSpace($contentLength)) { + throw "$Uri did not return Content-Length." + } + $parsedContentLength = 0L + if (-not [long]::TryParse($contentLength, [ref]$parsedContentLength)) { + throw "$Uri returned non-numeric Content-Length '$contentLength'." + } + Write-Host " Content-Length OK: $contentLength" +} + +function Assert-MsixRangeRequest { + param([Parameter(Mandatory)] [Uri] $Uri) + + try { + $response = Invoke-WebRequest -Uri $Uri ` + -Method Get ` + -Headers @{ Range = 'bytes=0-0' } ` + -MaximumRedirection 5 ` + -UseBasicParsing + } + catch { + throw "Range GET $Uri failed: $($_.Exception.Message)" + } + + if ($response.StatusCode -ne 206) { + throw "$Uri did not honor range request. Expected HTTP 206, got HTTP $($response.StatusCode)." + } + + $contentRange = Get-HeaderValue -Response $response -Name 'Content-Range' + if ([string]::IsNullOrWhiteSpace($contentRange)) { + throw "$Uri returned HTTP 206 but omitted Content-Range." + } + Write-Host " Range request OK: $contentRange" +} + +if ([string]::IsNullOrWhiteSpace($AppInstallerPath) -and $null -eq $AppInstallerUri) { + throw "Provide either -AppInstallerUri or -AppInstallerPath." +} + +if (-not [string]::IsNullOrWhiteSpace($AppInstallerPath)) { + if (-not (Test-Path $AppInstallerPath)) { + throw "AppInstallerPath not found: $AppInstallerPath" + } + + Write-Host "Validating local AppInstaller XML: $AppInstallerPath" + [xml]$appInstallerXml = Get-Content -Path $AppInstallerPath -Raw + if ($null -eq $MsixUri) { + $MsixUri = Get-MainPackageUri -AppInstallerXml $appInstallerXml + Write-Host "Discovered MSIX URI from local AppInstaller: $MsixUri" + } +} +else { + Write-Host "Validating AppInstaller hosting: $AppInstallerUri" + Assert-HttpsUri -Uri $AppInstallerUri -Description 'AppInstallerUri' + $appInstallerHead = Invoke-Head -Uri $AppInstallerUri + $allowedAppInstallerTypes = if ($AllowGitHubContentTypes -and + $AppInstallerUri.Host.Equals('raw.githubusercontent.com', [StringComparison]::OrdinalIgnoreCase)) { + @('text/plain') + } else { + @() + } + Assert-ContentType -Response $appInstallerHead -Uri $AppInstallerUri -Expected 'application/appinstaller' -AlsoAllowed $allowedAppInstallerTypes + if (-not ($AllowGitHubContentTypes -and + $AppInstallerUri.Host.Equals('raw.githubusercontent.com', [StringComparison]::OrdinalIgnoreCase))) { + Assert-ContentLength -Response $appInstallerHead -Uri $AppInstallerUri + } + + if ($null -eq $MsixUri) { + $appInstallerBody = Invoke-WebRequest -Uri $AppInstallerUri -Method Get -MaximumRedirection 5 -UseBasicParsing + [xml]$appInstallerXml = $appInstallerBody.Content + $MsixUri = Get-MainPackageUri -AppInstallerXml $appInstallerXml + Write-Host "Discovered MSIX URI from AppInstaller: $MsixUri" + } +} + +Write-Host "Validating MSIX hosting: $MsixUri" +Assert-HttpsUri -Uri $MsixUri -Description 'MsixUri' +$msixHead = Invoke-Head -Uri $MsixUri +$allowedMsixTypes = if ($AllowGitHubContentTypes -and + $MsixUri.Host.Equals('github.com', [StringComparison]::OrdinalIgnoreCase)) { + @('application/octet-stream') +} else { + @() +} +Assert-ContentType -Response $msixHead -Uri $MsixUri -Expected 'application/msix' -AlsoAllowed $allowedMsixTypes +Assert-ContentLength -Response $msixHead -Uri $MsixUri +Assert-MsixRangeRequest -Uri $MsixUri + +Write-Host "AppInstaller hosting validation passed." -ForegroundColor Green diff --git a/scripts/validate-msix-storage-paths.ps1 b/scripts/validate-msix-storage-paths.ps1 index 4a2d62924..ac6cfcdc3 100644 --- a/scripts/validate-msix-storage-paths.ps1 +++ b/scripts/validate-msix-storage-paths.ps1 @@ -38,10 +38,10 @@ - MUST add in-app pre-uninstall warning banner gated on: PackageHelper.IsPackaged() && File.Exists(setupStatePath) so users are warned before removing the MSIX package. - - The Inno uninstaller script (Uninstall-LocalGateway.ps1) targets real paths - unconditionally — no change needed there. - - Recovery: scripts/validate-wsl-gateway-uninstall.ps1 -Scenario Full - -ConfirmDestructiveClean is still relevant for orphaned state. + - Recovery (after MSIX was removed without using the in-tray button): + openclaw-winnode --purge-wsl-orphans --confirm-destructive --json-output + (see docs/uninstall-msix.md for the equivalent PowerShell one-liners if the + CLI is not available.) If "PathB-CleanRemove": - Remove-AppxPackage handles file-based artifact cleanup automatically. diff --git a/scripts/verify-msix-payload-signatures.ps1 b/scripts/verify-msix-payload-signatures.ps1 new file mode 100644 index 000000000..3c035db22 --- /dev/null +++ b/scripts/verify-msix-payload-signatures.ps1 @@ -0,0 +1,82 @@ +<# +.SYNOPSIS + Verifies that an MSIX and all signable payload files inside it are signed. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $MsixPath, + + [string] $MakeAppxPath +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $MsixPath -PathType Leaf)) { + throw "MSIX not found: $MsixPath" +} + +$outerSignature = Get-AuthenticodeSignature -LiteralPath $MsixPath +if ($outerSignature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { + throw "Outer MSIX signature is not valid for $MsixPath. Status=$($outerSignature.Status)" +} + +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "openclaw-msix-verify-$([System.Guid]::NewGuid().ToString('N'))" +$signableExtensions = @('.exe', '.dll', '.ps1', '.psm1', '.psd1') +$unsupportedScriptExtensions = @('.cmd', '.bat', '.vbs', '.js') + +try { + New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null + if ([string]::IsNullOrWhiteSpace($MakeAppxPath)) { + $kitsRoot = "${env:ProgramFiles(x86)}\Windows Kits\10\bin" + $MakeAppxPath = Get-ChildItem $kitsRoot -Recurse -Filter makeappx.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\makeappx\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 -ExpandProperty FullName + if ([string]::IsNullOrWhiteSpace($MakeAppxPath)) { + $globalPackages = (dotnet nuget locals global-packages -l) -replace '^global-packages:\s*', '' + $MakeAppxPath = Get-ChildItem $globalPackages -Recurse -Filter makeappx.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\makeappx\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 -ExpandProperty FullName + } + } + if ([string]::IsNullOrWhiteSpace($MakeAppxPath) -or -not (Test-Path -LiteralPath $MakeAppxPath -PathType Leaf)) { + throw "makeappx.exe not found. Pass -MakeAppxPath or install Windows SDK build tools." + } + + & $MakeAppxPath unpack /p $MsixPath /d $tempRoot /o | Write-Host + if ($LASTEXITCODE -ne 0) { + throw "makeappx.exe unpack failed with exit code $LASTEXITCODE" + } + + $payloadFiles = Get-ChildItem -LiteralPath $tempRoot -Recurse -File + $unsupportedScripts = @($payloadFiles | Where-Object { $_.Extension.ToLowerInvariant() -in $unsupportedScriptExtensions }) + if ($unsupportedScripts.Count -gt 0) { + $list = ($unsupportedScripts | ForEach-Object { $_.FullName.Substring($tempRoot.Length).TrimStart('\') }) -join ', ' + throw "MSIX contains script files that cannot be Authenticode-signed by this workflow: $list" + } + + $signableFiles = @($payloadFiles | Where-Object { $_.Extension.ToLowerInvariant() -in $signableExtensions }) + $invalid = @() + foreach ($file in $signableFiles) { + $signature = Get-AuthenticodeSignature -LiteralPath $file.FullName + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { + $invalid += [pscustomobject]@{ + Path = $file.FullName.Substring($tempRoot.Length).TrimStart('\') + Status = $signature.Status.ToString() + } + } + } + + if ($invalid.Count -gt 0) { + $details = ($invalid | ConvertTo-Json -Depth 3) + throw "Unsigned or invalid signable payload file(s) in $MsixPath`: $details" + } + + Write-Host "Verified $($signableFiles.Count) signed payload file(s) and valid outer MSIX signature for $MsixPath" +} +finally { + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/src/OpenClaw.CommandPalette/Package.appxmanifest b/src/OpenClaw.CommandPalette/Package.appxmanifest index 1035c5855..cc5f3d353 100644 --- a/src/OpenClaw.CommandPalette/Package.appxmanifest +++ b/src/OpenClaw.CommandPalette/Package.appxmanifest @@ -8,22 +8,25 @@ xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" IgnorableNamespaces="uap uap3 rescap"> + - - OpenClaw - A Lone Developer + OpenClaw Companion Command Palette + Scott Hanselman Assets\StoreLogo.png - - + + diff --git a/src/OpenClaw.CommandPalette/Pages/OpenClawPage.cs b/src/OpenClaw.CommandPalette/Pages/OpenClawPage.cs index 7419905da..c5063f4de 100644 --- a/src/OpenClaw.CommandPalette/Pages/OpenClawPage.cs +++ b/src/OpenClaw.CommandPalette/Pages/OpenClawPage.cs @@ -67,7 +67,7 @@ public override IListItem[] GetItems() new ListItem(new OpenUrlCommand("openclaw://check-updates")) { Title = "⬇️ Check for Updates", - Subtitle = "Run a manual GitHub Releases update check" + Subtitle = "Check the MSIX AppInstaller update feed" }, new ListItem(new OpenUrlCommand("openclaw://activity")) { diff --git a/src/OpenClaw.SetupEngine.UI/OpenClaw.SetupEngine.UI.csproj b/src/OpenClaw.SetupEngine.UI/OpenClaw.SetupEngine.UI.csproj index a46df90ae..9dac97618 100644 --- a/src/OpenClaw.SetupEngine.UI/OpenClaw.SetupEngine.UI.csproj +++ b/src/OpenClaw.SetupEngine.UI/OpenClaw.SetupEngine.UI.csproj @@ -49,4 +49,15 @@ CopyToOutputDirectory="PreserveNewest" /> + + + <_SetupEngineXamlResource Include="$(OutputPath)**\*.xbf;$(OutputPath)*.pri" /> + + + + diff --git a/src/OpenClaw.SetupEngine.UI/Program.cs b/src/OpenClaw.SetupEngine.UI/Program.cs index 194936269..b98b339dd 100644 --- a/src/OpenClaw.SetupEngine.UI/Program.cs +++ b/src/OpenClaw.SetupEngine.UI/Program.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.InteropServices; using System.Threading; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; @@ -16,6 +17,7 @@ private static int Main(string[] args) return OpenClaw.SetupEngine.Program.Main(args).GetAwaiter().GetResult(); } + EnsureWindowsAppRuntimeLoaded(); WinRT.ComWrappersSupport.InitializeComWrappers(); Application.Start(p => { @@ -26,4 +28,16 @@ private static int Main(string[] args) }); return 0; } + + [DllImport("Microsoft.WindowsAppRuntime.dll", ExactSpelling = true)] + private static extern int WindowsAppRuntime_EnsureIsLoaded(); + + private static void EnsureWindowsAppRuntimeLoaded() + { + var hresult = WindowsAppRuntime_EnsureIsLoaded(); + if (hresult != 0) + { + Marshal.ThrowExceptionForHR(hresult); + } + } } diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 2c2fe93df..baf743a0f 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -136,6 +136,7 @@ public IntPtr GetHubWindowHandle() private GatewayService? _gatewayService; private CancellationTokenSource? _deepLinkCts; private bool _isExiting; + private int _applyUpdateInFlight; /// /// Cached connection status — sole writer is OnManagerStateChanged. @@ -475,6 +476,7 @@ _dispatcherQueue is null // Register toast activation handler ToastNotificationManagerCompat.OnActivated += OnToastActivated; + NotificationSettingsRegistrationService.EnsureRegistered(); _sshTunnelService = new SshTunnelService(new AppLogger()); _sshTunnelService.TunnelExited += OnSshTunnelExited; @@ -2761,8 +2763,6 @@ private void OnSettingsSaved(object? sender, EventArgs e) _globalHotkey?.Unregister(); } - AutoStartManager.SetAutoStart(_settings.AutoStart); - // Notify ad-hoc listeners (e.g. ChatWindow may be alive but not // owned by the hub) that settings have changed. Marshal onto the // UI thread because IAppCommands.NotifySettingsSaved is a public @@ -2974,6 +2974,12 @@ private async Task ShowOnboardingAsync() { if (_settings == null) return; + if (OpenClawTray.Helpers.PackageHelper.IsPackaged) + { + await LaunchPackagedSetupEngineAsync(); + return; + } + var setupExePath = ResolveSetupEngineUiPath(); if (setupExePath == null) { @@ -2998,7 +3004,8 @@ private async Task ShowOnboardingAsync() { var psi = new System.Diagnostics.ProcessStartInfo(setupExePath) { - UseShellExecute = true + UseShellExecute = false, + WorkingDirectory = Path.GetDirectoryName(setupExePath) ?? AppContext.BaseDirectory }; var process = System.Diagnostics.Process.Start(psi); if (process != null) @@ -3015,6 +3022,49 @@ private async Task ShowOnboardingAsync() } } + private async Task LaunchPackagedSetupEngineAsync() + { + var setupProcesses = System.Diagnostics.Process.GetProcessesByName("OpenClaw.SetupEngine.UI"); + if (setupProcesses.Length > 0) + { + Logger.Info("SetupEngine.UI already running — focusing existing instance"); + foreach (var p in setupProcesses) + { + TryBringSetupEngineToFront(p); + } + foreach (var p in setupProcesses) p.Dispose(); + return; + } + + try + { + var setupExePath = ResolveSetupEngineUiPath(); + if (setupExePath == null) + { + Logger.Error($"SetupEngine.UI not found (searched {AppContext.BaseDirectory})"); + return; + } + + var psi = new System.Diagnostics.ProcessStartInfo(setupExePath) + { + UseShellExecute = false, + WorkingDirectory = Path.GetDirectoryName(setupExePath) ?? AppContext.BaseDirectory + }; + var process = System.Diagnostics.Process.Start(psi); + if (process != null) + { + await Task.Delay(500); + TryBringSetupEngineToFront(process); + process.Dispose(); + } + Logger.Info("Launched packaged SetupEngine.UI for setup"); + } + catch (Exception ex) + { + Logger.Error($"Failed to activate packaged SetupEngine.UI: {ex.Message}"); + } + } + private void ShowSurfaceImprovementsTipIfNeeded() { if (_settings == null || _settings.HasSeenActivityStreamTip) return; @@ -3118,6 +3168,7 @@ void IAppCommands.Disconnect() void IAppCommands.ShowVoiceOverlay() => ShowHub("voice"); void IAppCommands.ShowChat() => ShowChatWindow(); void IAppCommands.CheckForUpdates() => _ = CheckForUpdatesUserInitiatedAsync(); + void IAppCommands.ApplyUpdateNow() => _ = ApplyUpdateNowUserInitiatedAsync(); void IAppCommands.ShowOnboarding() => _ = ShowOnboardingAsync(); void IAppCommands.ShowConnectionStatus() => ShowConnectionStatusWindow(); void IAppCommands.NotifySettingsSaved() => OnSettingsSaved(this, EventArgs.Empty); @@ -3160,12 +3211,24 @@ private async Task ToggleChannelAsync(string channelName) } } - private void ToggleAutoStart() + private void ToggleAutoStart() => + AsyncEventHandlerGuard.Run( + ToggleAutoStartAsync, + new AppLogger(), + nameof(ToggleAutoStart)); + + private async Task ToggleAutoStartAsync() { if (_settings == null) return; _settings.AutoStart = !_settings.AutoStart; _settings.Save(); - AutoStartManager.SetAutoStart(_settings.AutoStart); + var requestedAutoStart = _settings.AutoStart; + var autoStartApplied = await AutoStartManager.SetAutoStartAsync(requestedAutoStart); + if (!autoStartApplied) + { + _settings.AutoStart = !requestedAutoStart; + _settings.Save(); + } } private void OpenLogFile() @@ -3260,20 +3323,33 @@ private void OnSettingsHotkeyPressed(object? sender, EventArgs e) private static UpdateCommandCenterInfo BuildInitialUpdateInfo() => new() { Status = "Not checked", - CurrentVersion = AppVersionInfo.Version + CurrentVersion = AppVersionHelper.CurrentVersionText }; - // Cross-path concurrency for update checks, split into two phases: + // Cross-path concurrency for legacy Updatum checks, split into two phases: // - _updateCheckGate: held only during the metadata/network check. - // Short timeout so contended callers don't block on user thinking. - // - _updateInstallInProgress: Interlocked flag covering the user-facing - // UpdateDialog + download + install. Prevents two parallel installs - // without holding a lock across user interaction. + // - _updateInstallInProgress: held while the prompt/download/install runs. private readonly System.Threading.SemaphoreSlim _updateCheckGate = new(1, 1); private int _updateInstallInProgress; private async Task CheckForUpdatesAsync(bool userInitiated = false) { + // Packaged apps under MSIX don't need an in-app startup poll. Windows + // AppInstaller polls our hosted .appinstaller with AutomaticBackgroundTask + // and the tray only surfaces pending/manual update state on demand. + if (OpenClawTray.Helpers.PackageHelper.IsPackaged) + { + Logger.Info("Skipping startup update check (packaged build; AppInstaller polls in the background)"); + _appState!.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Managed", + CurrentVersion = AppVersionHelper.CurrentVersionText, + CheckedAt = DateTime.UtcNow, + Detail = "managed by Windows AppInstaller" + }; + return true; + } + // === Stage 1: metadata check (gate-protected) === if (!await _updateCheckGate.WaitAsync(TimeSpan.FromSeconds(30))) { @@ -3288,7 +3364,7 @@ private async Task CheckForUpdatesAsync(bool userInitiated = false) Detail = "another update check is already in progress; try again in a moment" }; } - return true; // Don't block launch + return true; } #if DEBUG @@ -3338,9 +3414,6 @@ private async Task CheckForUpdatesAsync(bool userInitiated = false) var release = AppUpdater.LatestRelease!; if (string.IsNullOrEmpty(release.TagName)) { - // Defensive: AppUpdater says an update is available but the - // release has no tag. Don't silently claim "up to date" — - // surface as Failed so the user sees something is off. Logger.Warn("Update reported available but release has no TagName"); _appState!.UpdateInfo = new UpdateCommandCenterInfo { @@ -3378,9 +3451,6 @@ private async Task CheckForUpdatesAsync(bool userInitiated = false) Logger.Info("Update check cancelled"); if (_appState != null) { - // Avoid leaving Status="Checking" stale for the manual flow - // or the command-center UI. Surface as Failed with a clear - // "cancelled" detail. _appState.UpdateInfo = new UpdateCommandCenterInfo { Status = "Failed", @@ -3408,15 +3478,10 @@ private async Task CheckForUpdatesAsync(bool userInitiated = false) } finally { - // Release the gate BEFORE user interaction & download/install. - // Holding it across these long phases would silently time-out - // any concurrent manual click. _updateCheckGate.Release(); } // === Stage 2: user-interactive prompt + download/install === - // Gate is released. Use Interlocked flag so concurrent callers can't - // start a second parallel install while we're prompting/downloading. if (System.Threading.Interlocked.CompareExchange(ref _updateInstallInProgress, 1, 0) != 0) { Logger.Info("Update prompt/install already in progress; skipping"); @@ -3443,26 +3508,17 @@ private async Task CheckForUpdatesAsync(bool userInitiated = false) } catch (System.Runtime.InteropServices.COMException ex) { - // Visual tree torn down mid-await (e.g. window closed). - // Treat as "remind me later" rather than tainting Status with - // "Failed" — the network check itself succeeded. Logger.Warn($"[Update] Prompt dialog dismissed before completion: 0x{ex.HResult:X8}"); return true; } catch (InvalidOperationException ex) { - // Another ContentDialog is already open on this XamlRoot. Logger.Warn($"[Update] Prompt dialog could not be shown: {ex.Message}"); return true; } if (result == UpdateDialogResult.Download) { - // Assign a fresh object rather than mutating .Detail in place: - // a concurrent loser of the install-flag CAS may have just - // overwritten _appState.UpdateInfo with a "Failed" object, - // and mutating its Detail would leave Status="Failed" with - // our "download requested" detail — briefly inconsistent. _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Available", @@ -3479,8 +3535,6 @@ private async Task CheckForUpdatesAsync(bool userInitiated = false) var installed = await DownloadAndInstallUpdateAsync(); if (!installed) { - // Surface the failure so callers (and the manual-check - // dialog) don't show stale "download requested" state. _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Failed", @@ -3489,7 +3543,7 @@ private async Task CheckForUpdatesAsync(bool userInitiated = false) Detail = "download or install failed" }; } - return !installed; // Don't launch if update succeeded + return !installed; } if (result == UpdateDialogResult.Skip && _settings != null) @@ -3509,13 +3563,11 @@ private async Task CheckForUpdatesAsync(bool userInitiated = false) && string.Equals(_settings.SkippedUpdateTag, releaseTag, StringComparison.OrdinalIgnoreCase)) { - // User explicitly bypassed the remembered skip for THIS - // release and picked RemindLater — clear the stale tag. _settings.SkippedUpdateTag = string.Empty; _settings.Save(); } - return true; // RemindLater or Skip - continue launch + return true; } catch (Exception ex) { @@ -3541,8 +3593,7 @@ private async Task CheckForUpdatesAsync(bool userInitiated = false) // Re-entrancy guard: the button/menu/deep-link are all fire-and-forget // (`_ = CheckForUpdatesUserInitiatedAsync()`), so a double-click would - // otherwise open two ContentDialogs on the same XamlRoot which throws - // COMException. One in-flight manual check at a time is enough. + // otherwise open two ContentDialogs on the same XamlRoot which throws. private int _manualUpdateCheckInFlight; private async Task CheckForUpdatesUserInitiatedAsync() @@ -3556,15 +3607,16 @@ private async Task CheckForUpdatesUserInitiatedAsync() try { Logger.Info("Manual update check requested"); - // Pass userInitiated=true so an explicit click bypasses the - // "remind me later" SkippedUpdateTag — the user is asking *now*. + + if (OpenClawTray.Helpers.PackageHelper.IsPackaged) + { + await CheckForPackagedAppInstallerUpdateAsync(); + return; + } + var shouldContinue = await CheckForUpdatesAsync(userInitiated: true); UpdateStatusDetailWindow(); - // The "Available" path already prompts via UpdateDialog. For the - // other terminal states a manual click would otherwise produce no - // UI at all, leaving users wondering whether the click registered. - // Surface each explicitly with a small OK dialog. var info = _appState?.UpdateInfo; if (info != null) { @@ -3577,10 +3629,6 @@ await ShowUpdateInfoDialogAsync( LocalizationHelper.Format("Update_Message_UpToDate", info.CurrentVersion)); break; case "Failed": - // Format string ends with "\n\n{0}"; an empty Detail - // would leave a dangling blank line. Trim only the - // newline characters we added, never arbitrary - // whitespace from the localized string. var failedMessage = LocalizationHelper .Format("Update_Message_Failed", info.Detail ?? "") .TrimEnd('\r', '\n'); @@ -3590,11 +3638,6 @@ await ShowUpdateInfoDialogAsync( failedMessage); break; #if DEBUG - // Status="Skipped" is only produced by the DEBUG short-circuit - // in CheckForUpdatesAsync. User-skipped versions keep - // Status="Available", so this case must not exist in RELEASE - // or it would surface a confusing "disabled in debug builds" - // dialog to end users. case "Skipped": await ShowUpdateInfoDialogAsync( "Skipped", @@ -3616,11 +3659,129 @@ await ShowUpdateInfoDialogAsync( } } + private async Task CheckForPackagedAppInstallerUpdateAsync() + { + _appState!.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Checking", + CurrentVersion = AppVersionHelper.CurrentVersionText, + CheckedAt = DateTime.UtcNow, + Detail = $"querying {AppInstallerUpdateService.LatestAppInstallerUri}" + }; + UpdateStatusDetailWindow(); + + var outcome = await AppInstallerUpdateService.CheckForUpdateAsync(); + _appState!.UpdateInfo = outcome.Outcome switch + { + AppInstallerUpdateService.UpdateOutcome.UpdateAvailable => new UpdateCommandCenterInfo + { + Status = "Available", + CurrentVersion = AppVersionHelper.CurrentVersionText, + CheckedAt = DateTime.UtcNow, + Detail = outcome.DetailMessage ?? "update available" + }, + AppInstallerUpdateService.UpdateOutcome.UpdateQueued => new UpdateCommandCenterInfo + { + Status = "Ready", + CurrentVersion = AppVersionHelper.CurrentVersionText, + CheckedAt = DateTime.UtcNow, + Detail = outcome.DetailMessage ?? "update accepted; restart OpenClaw when convenient" + }, + AppInstallerUpdateService.UpdateOutcome.UpdatePendingRestart => new UpdateCommandCenterInfo + { + Status = "Ready", + CurrentVersion = AppVersionHelper.CurrentVersionText, + CheckedAt = DateTime.UtcNow, + Detail = outcome.DetailMessage ?? "update available; close and reopen OpenClaw to finish" + }, + AppInstallerUpdateService.UpdateOutcome.NoUpdateAvailable => new UpdateCommandCenterInfo + { + Status = "Current", + CurrentVersion = AppVersionHelper.CurrentVersionText, + CheckedAt = DateTime.UtcNow, + Detail = outcome.DetailMessage ?? "no updates available" + }, + _ => new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionHelper.CurrentVersionText, + CheckedAt = DateTime.UtcNow, + Detail = outcome.DetailMessage ?? "update failed" + } + }; + UpdateStatusDetailWindow(); + } + + private async Task ApplyUpdateNowUserInitiatedAsync() + { + if (System.Threading.Interlocked.CompareExchange(ref _applyUpdateInFlight, 1, 0) != 0) + { + Logger.Info("Apply update ignored: another apply request is already in progress"); + return; + } + + Logger.Info("Apply update now requested"); + + try + { + if (!OpenClawTray.Helpers.PackageHelper.IsPackaged) + { + await CheckForUpdatesAsync(); + UpdateStatusDetailWindow(); + return; + } + + _appState!.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Applying", + CurrentVersion = AppVersionHelper.CurrentVersionText, + CheckedAt = DateTime.UtcNow, + Detail = "asking Windows AppInstaller to apply the available update" + }; + UpdateStatusDetailWindow(); + + var outcome = await AppInstallerUpdateService.TryApplyUpdateAsync(forceRestart: true); + _appState!.UpdateInfo = outcome.Outcome switch + { + AppInstallerUpdateService.UpdateOutcome.UpdateQueued => new UpdateCommandCenterInfo + { + Status = "Ready", + CurrentVersion = AppVersionHelper.CurrentVersionText, + CheckedAt = DateTime.UtcNow, + Detail = outcome.DetailMessage ?? "update accepted; restart OpenClaw when convenient" + }, + AppInstallerUpdateService.UpdateOutcome.UpdatePendingRestart => new UpdateCommandCenterInfo + { + Status = "Ready", + CurrentVersion = AppVersionHelper.CurrentVersionText, + CheckedAt = DateTime.UtcNow, + Detail = outcome.DetailMessage ?? "update available; close and reopen OpenClaw to finish" + }, + AppInstallerUpdateService.UpdateOutcome.NoUpdateAvailable => new UpdateCommandCenterInfo + { + Status = "Current", + CurrentVersion = AppVersionHelper.CurrentVersionText, + CheckedAt = DateTime.UtcNow, + Detail = outcome.DetailMessage ?? "no updates available" + }, + _ => new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionHelper.CurrentVersionText, + CheckedAt = DateTime.UtcNow, + Detail = outcome.DetailMessage ?? "update failed" + } + }; + UpdateStatusDetailWindow(); + } + finally + { + System.Threading.Interlocked.Exchange(ref _applyUpdateInFlight, 0); + } + } + private async Task ShowUpdateInfoDialogAsync(string logKey, string title, string message) { - // Prefer the Hub window when open so the dialog appears modal to what - // the user is actually looking at; fall back to the hidden keep-alive - // window so the dialog still renders if the Hub has been dismissed. XamlRoot? xamlRoot = null; if (_hubWindow != null && !_hubWindow.IsClosed) xamlRoot = (_hubWindow.Content as FrameworkElement)?.XamlRoot; @@ -3628,8 +3789,6 @@ private async Task ShowUpdateInfoDialogAsync(string logKey, string title, string xamlRoot = (_keepAliveWindow?.Content as FrameworkElement)?.XamlRoot; if (xamlRoot == null) { - // Log the stable English key, not the localized title, so log - // grepping works across locales. Logger.Warn($"[Update] No XAML root available to show dialog: {logKey}"); return; } @@ -3648,17 +3807,10 @@ private async Task ShowUpdateInfoDialogAsync(string logKey, string title, string } catch (System.Runtime.InteropServices.COMException ex) { - // ContentDialog.ShowAsync throws COMException if its XamlRoot's - // visual tree is torn down mid-await (e.g. Hub window closed). Logger.Warn($"[Update] Dialog dismissed before completion ({logKey}): 0x{ex.HResult:X8}"); } catch (InvalidOperationException ex) { - // WinUI throws InvalidOperationException when another ContentDialog - // is already open on the same thread/XamlRoot. The re-entrancy - // guard only blocks duplicate *update* dialogs; collisions with - // other features' dialogs (onboarding, connection, etc.) must be - // tolerated here so the fire-and-forget call sites don't crash. Logger.Warn($"[Update] Dialog could not be shown ({logKey}): {ex.Message}"); } } @@ -3669,7 +3821,7 @@ private async Task DownloadAndInstallUpdateAsync() try { progressDialog = new DownloadProgressDialog(AppUpdater); - progressDialog.ShowAsync(); // Fire and forget + progressDialog.ShowAsync(); var downloadedAsset = await AppUpdater.DownloadUpdateAsync(); @@ -3702,13 +3854,9 @@ private static void TryCloseProgressDialog(DownloadProgressDialog? dialog) } catch (System.Runtime.InteropServices.COMException) { - // Window already closed — closing a closed WinUI window throws - // COMException 0x80070578. Swallow so a real exception in the - // outer catch isn't masked by this cleanup failure. } catch (InvalidOperationException) { - // Same as above for other "already-disposed" race variants. } } @@ -4204,3 +4352,4 @@ private async Task OnSshTunnelExitedAsync(int exitCode) } } } + diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-16_altform-unplated.png b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-16_altform-unplated.png new file mode 100644 index 000000000..516e2c9bd Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-16_altform-unplated.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-20_altform-unplated.png b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-20_altform-unplated.png new file mode 100644 index 000000000..b52f5564d Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-20_altform-unplated.png differ diff --git a/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-44_altform-unplated.png b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-44_altform-unplated.png new file mode 100644 index 000000000..cc00a0afc Binary files /dev/null and b/src/OpenClaw.Tray.WinUI/Assets/Square44x44Logo.targetsize-44_altform-unplated.png differ diff --git a/src/OpenClaw.Tray.WinUI/Helpers/AppVersionHelper.cs b/src/OpenClaw.Tray.WinUI/Helpers/AppVersionHelper.cs new file mode 100644 index 000000000..7e45aac35 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/AppVersionHelper.cs @@ -0,0 +1,30 @@ +using System; + +namespace OpenClawTray.Helpers; + +internal static class AppVersionHelper +{ + public static string CurrentVersionText + { + get + { + if (PackageHelper.IsPackaged) + { + try + { + var version = global::Windows.ApplicationModel.Package.Current.Id.Version; + return $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}"; + } + catch + { + // Fall through to assembly version for unpackaged/test contexts + // where Package.Current may be unavailable despite stale state. + } + } + + return typeof(AppVersionHelper).Assembly.GetName().Version?.ToString() ?? "unknown"; + } + } + + public static string DisplayVersion => $"v{CurrentVersionText}"; +} diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index 57a27d932..88b2c046d 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -33,7 +33,8 @@ MSIX - true + false + false false true Never @@ -74,6 +75,14 @@ + + + @@ -185,6 +194,10 @@ + + + + diff --git a/src/OpenClaw.Tray.WinUI/Package.appxmanifest b/src/OpenClaw.Tray.WinUI/Package.appxmanifest index 7016ca5c7..8dd090f7f 100644 --- a/src/OpenClaw.Tray.WinUI/Package.appxmanifest +++ b/src/OpenClaw.Tray.WinUI/Package.appxmanifest @@ -3,8 +3,13 @@ + IgnorableNamespaces="uap uap5 uap13 uap17 com desktop rescap"> + + + + + + + + + + + + + + + + @@ -64,3 +115,5 @@ + + diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs index ebead6719..7650632c5 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs @@ -116,15 +116,38 @@ private void Persist(Action mutate) } } - private void PersistAutoStart() + private void PersistAutoStart() => + AsyncEventHandlerGuard.Run( + PersistAutoStartAsync, + new OpenClawTray.AppLogger(), + nameof(PersistAutoStart)); + + private async Task PersistAutoStartAsync() { - if (_loading || CurrentApp.Settings == null) return; + var settings = CurrentApp.Settings; + if (_loading || settings == null) return; _saving = true; try { - CurrentApp.Settings.AutoStart = AutoStartToggle.IsOn; - CurrentApp.Settings.Save(); - AutoStartManager.SetAutoStart(CurrentApp.Settings.AutoStart); + var requestedAutoStart = AutoStartToggle.IsOn; + settings.AutoStart = requestedAutoStart; + settings.Save(); + var autoStartApplied = await AutoStartManager.SetAutoStartAsync(requestedAutoStart); + if (!autoStartApplied) + { + settings.AutoStart = !requestedAutoStart; + settings.Save(); + _loading = true; + try + { + AutoStartToggle.IsOn = settings.AutoStart; + } + finally + { + _loading = false; + } + } + ((IAppCommands)CurrentApp).NotifySettingsSaved(); ShowSavedIndicator(); } diff --git a/src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs b/src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs new file mode 100644 index 000000000..1ae42e024 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/AppInstallerUpdateService.cs @@ -0,0 +1,376 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using System.Xml.Linq; +using OpenClawTray.Helpers; + +namespace OpenClawTray.Services; + +/// +/// MSIX packaged update path. When the tray is running as a packaged app the +/// canonical non-Store auto-update channel is an .appinstaller file +/// hosted at a stable URL (see installer/openclaw-companion.appinstaller.template +/// and docs/RELEASING.md). Windows AppInstaller polls that URL via +/// its background task and applies package registration when the app is not +/// in use. This service exposes the manual path the user takes when they +/// click "Check for updates" without making force-shutdown the default. +/// +/// This service is only invoked when +/// is true. The unpackaged installer/portable path continues to use Updatum. +/// +internal static class AppInstallerUpdateService +{ + private static readonly HttpClient SharedHttpClient = new() + { + Timeout = TimeSpan.FromSeconds(15) + }; + + /// + /// Stable x64 URL of the AppInstaller XML in the Windows repo. + /// + public const string LatestX64AppInstallerUri = + "https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller"; + + /// + /// Stable ARM64 URL of the AppInstaller XML in the Windows repo. + /// + public const string LatestArm64AppInstallerUri = + "https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-arm64.appinstaller"; + + public static string LatestAppInstallerUri => + ResolveAppInstallerUri() ?? ArchitectureFallbackAppInstallerUri; + + internal static string ArchitectureFallbackAppInstallerUri => + RuntimeInformation.ProcessArchitecture == Architecture.Arm64 + ? LatestArm64AppInstallerUri + : LatestX64AppInstallerUri; + + /// + /// Reflects the outcome of so the caller + /// can surface a meaningful status to the user without coupling to WinRT. + /// + public enum UpdateOutcome + { + /// A newer version is advertised by the AppInstaller feed. + UpdateAvailable, + /// Windows accepted the update request; registration may complete after restart. + UpdateQueued, + /// An update is available but Windows needs OpenClaw to exit before registration can finish. + UpdatePendingRestart, + /// No newer version is currently published at the AppInstaller URL. + NoUpdateAvailable, + /// The call ran but Windows reported a non-fatal failure (e.g. network). + Failed, + /// Caller invoked the service from an unpackaged process (programming error). + NotPackaged + } + + public record UpdateResult(UpdateOutcome Outcome, string? DetailMessage); + + internal const int HResultPackagesInUse = unchecked((int)0x80073D02); + internal const int HResultPackageAlreadyExists = unchecked((int)0x80073CFB); + + /// + /// Reads the hosted AppInstaller XML and compares its version with the + /// installed package version without staging or registering any package. + /// + public static async Task CheckForUpdateAsync( + string? appInstallerUri = null, + HttpClient? httpClient = null) + { + if (!PackageHelper.IsPackaged) + { + return new UpdateResult(UpdateOutcome.NotPackaged, + "AppInstallerUpdateService called from an unpackaged process. " + + "branch on PackageHelper.IsPackaged before invoking this service."); + } + + var resolvedUri = ResolveAppInstallerUri(appInstallerUri); + if (resolvedUri is null) + { + return new UpdateResult(UpdateOutcome.Failed, + $"No AppInstaller update feed is configured for package identity '{TryGetCurrentPackageName() ?? "unknown"}'."); + } + + var uri = new Uri(resolvedUri, UriKind.Absolute); + + try + { + var client = httpClient ?? SharedHttpClient; + var xml = await client.GetStringAsync(uri); + var identityResult = ClassifyPublishedIdentity(ParseAppInstallerPackageName(xml)); + if (identityResult is not null) + return identityResult; + + var publishedVersion = ParseAppInstallerVersion(xml); + var currentVersion = GetCurrentPackageVersion(); + return ClassifyPublishedVersion(currentVersion, publishedVersion); + } + catch (Exception ex) + { + return new UpdateResult(UpdateOutcome.Failed, ex.Message); + } + } + + /// + /// Asks Windows to fetch the MSIX advertised at the AppInstaller URL. + /// By default this does not force-close the tray; callers that expose an + /// explicit "Update now and restart" affordance may opt in to force restart. + /// + public static async Task TryApplyUpdateAsync( + string? appInstallerUri = null, + bool forceRestart = false) + { + if (!PackageHelper.IsPackaged) + { + return new UpdateResult(UpdateOutcome.NotPackaged, + "AppInstallerUpdateService called from an unpackaged process. " + + "branch on PackageHelper.IsPackaged before invoking this service."); + } + + var resolvedUri = ResolveAppInstallerUri(appInstallerUri); + if (resolvedUri is null) + { + return new UpdateResult(UpdateOutcome.Failed, + $"No AppInstaller update feed is configured for package identity '{TryGetCurrentPackageName() ?? "unknown"}'."); + } + + var uri = new Uri(resolvedUri, UriKind.Absolute); + + try + { + var client = SharedHttpClient; + var xml = await client.GetStringAsync(uri); + var identityResult = ClassifyPublishedIdentity(ParseAppInstallerPackageName(xml)); + if (identityResult is not null) + return identityResult; + + // Late-bind PackageManager so the file compiles on unpackaged test + // builds that don't actually link against Windows.Management.Deployment. + // Same global:: prefix dance as AutoStartManager — `Windows` resolves + // to OpenClawTray.Windows here otherwise. + var manager = new global::Windows.Management.Deployment.PackageManager(); + var options = forceRestart + ? global::Windows.Management.Deployment.AddPackageByAppInstallerOptions.ForceTargetAppShutdown + : global::Windows.Management.Deployment.AddPackageByAppInstallerOptions.None; + var deploymentOperation = manager.AddPackageByAppInstallerFileAsync( + uri, + options, + ResolveCurrentPackageVolume(manager)); + + var result = await deploymentOperation.AsTask(); + return ClassifyDeploymentResult( + result.IsRegistered, + result.ExtendedErrorCode?.HResult ?? 0, + result.ErrorText, + forceRestart); + } + catch (Exception ex) + { + return new UpdateResult(UpdateOutcome.Failed, ex.Message); + } + } + + internal static UpdateResult ClassifyDeploymentResult( + bool isRegistered, + int hResult, + string? errorText, + bool forceRestart) + { + if (isRegistered) + { + return new UpdateResult(UpdateOutcome.UpdateQueued, + forceRestart + ? "Update applied; Windows will restart the app." + : "Update accepted; restart OpenClaw when convenient to finish."); + } + + return hResult switch + { + HResultPackagesInUse => new UpdateResult(UpdateOutcome.UpdatePendingRestart, + "An update is available, but OpenClaw is running. Close and reopen OpenClaw to finish installing it."), + HResultPackageAlreadyExists => new UpdateResult(UpdateOutcome.NoUpdateAvailable, + "Already on the latest version published at the AppInstaller URL."), + 0 => new UpdateResult(UpdateOutcome.Failed, + $"PackageManager did not register the package and did not report an HRESULT: {errorText ?? "no error text"}"), + _ => new UpdateResult(UpdateOutcome.Failed, + $"PackageManager reported HRESULT 0x{unchecked((uint)hResult):X8}: {errorText}") + }; + } + + internal static string? ResolveAppInstallerUri(string? appInstallerUri = null) + { + if (!string.IsNullOrWhiteSpace(appInstallerUri)) + return appInstallerUri; + + return TryGetRegisteredAppInstallerUri() ?? TryGetChannelFallbackAppInstallerUri(); + } + + private static string? TryGetRegisteredAppInstallerUri() + { + if (!PackageHelper.IsPackaged) + return null; + + try + { + var uri = global::Windows.ApplicationModel.Package.Current + .GetAppInstallerInfo() + ?.Uri + ?.AbsoluteUri is { Length: > 0 } uriText + ? new Uri(uriText, UriKind.Absolute) + : null; + if (uri is null) + return null; + + if (uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || + uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + return uri.AbsoluteUri; + + Logger.Warn($"Ignoring non-HTTP AppInstaller source '{uri}'. Falling back to configured architecture feed."); + return null; + } + catch (Exception ex) when (ex is COMException or InvalidOperationException) + { + Logger.Warn($"Failed to read package AppInstaller source; falling back to architecture feed: {ex.Message}"); + return null; + } + } + + private static string? TryGetChannelFallbackAppInstallerUri() + { + var packageName = TryGetCurrentPackageName(); + if (string.IsNullOrWhiteSpace(packageName) || + string.Equals(packageName, "OpenClaw.Companion", StringComparison.OrdinalIgnoreCase)) + { + return ArchitectureFallbackAppInstallerUri; + } + + if (packageName.StartsWith("OpenClaw.Companion.", StringComparison.OrdinalIgnoreCase)) + { + Logger.Warn($"No fallback AppInstaller feed is configured for package identity '{packageName}'."); + return null; + } + + return ArchitectureFallbackAppInstallerUri; + } + + private static string? TryGetCurrentPackageName() + { + if (!PackageHelper.IsPackaged) + return null; + + try + { + return global::Windows.ApplicationModel.Package.Current.Id.Name; + } + catch (Exception ex) when (ex is COMException or InvalidOperationException) + { + Logger.Warn($"Failed to read package identity name: {ex.Message}"); + return null; + } + } + + private static UpdateResult? ClassifyPublishedIdentity(string? publishedPackageName) + { + var currentPackageName = TryGetCurrentPackageName(); + if (string.IsNullOrWhiteSpace(currentPackageName) || + string.IsNullOrWhiteSpace(publishedPackageName) || + string.Equals(currentPackageName, publishedPackageName, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return new UpdateResult(UpdateOutcome.Failed, + $"AppInstaller feed package identity '{publishedPackageName}' does not match installed package identity '{currentPackageName}'."); + } + + internal static UpdateResult ClassifyPublishedVersion(Version currentVersion, Version publishedVersion) + { + if (publishedVersion.CompareTo(currentVersion) > 0) + { + return new UpdateResult(UpdateOutcome.UpdateAvailable, + $"Version {publishedVersion} is available. Windows AppInstaller will install it in the background when possible."); + } + + return new UpdateResult(UpdateOutcome.NoUpdateAvailable, + $"Already on version {currentVersion}; latest published version is {publishedVersion}."); + } + + internal static Version ParseAppInstallerVersion(string appInstallerXml) + { + var doc = XDocument.Parse(appInstallerXml); + var mainPackage = doc.Root is null + ? null + : doc.Root.Elements().SingleOrDefault(element => element.Name.LocalName == "MainPackage"); + var versionText = (string?)mainPackage?.Attribute("Version"); + if (!Version.TryParse(versionText, out var version) || version.Revision < 0) + throw new FormatException("AppInstaller MainPackage Version must be a four-part version."); + + return version; + } + + internal static string? ParseAppInstallerPackageName(string appInstallerXml) + { + var doc = XDocument.Parse(appInstallerXml); + var mainPackage = doc.Root is null + ? null + : doc.Root.Elements().SingleOrDefault(element => element.Name.LocalName == "MainPackage"); + return (string?)mainPackage?.Attribute("Name"); + } + + private static Version GetCurrentPackageVersion() + { + var version = global::Windows.ApplicationModel.Package.Current.Id.Version; + return new Version(version.Major, version.Minor, version.Build, version.Revision); + } + + private static global::Windows.Management.Deployment.PackageVolume ResolveCurrentPackageVolume( + global::Windows.Management.Deployment.PackageManager manager) + { + var fallback = manager.GetDefaultPackageVolume(); + + try + { + var installedPath = global::Windows.ApplicationModel.Package.Current.InstalledLocation.Path; + foreach (var volume in manager.FindPackageVolumes()) + { + if (PathIsUnderRoot(installedPath, volume.MountPoint)) + return volume; + } + } + catch (COMException ex) + { + LogPackageVolumeFallback(ex); + } + catch (InvalidOperationException ex) + { + LogPackageVolumeFallback(ex); + } + catch (IOException ex) + { + LogPackageVolumeFallback(ex); + } + catch (UnauthorizedAccessException ex) + { + LogPackageVolumeFallback(ex); + } + + return fallback; + } + + private static void LogPackageVolumeFallback(Exception ex) => + Logger.Warn($"Failed to resolve current package volume; falling back to default volume: {ex.Message}"); + + private static bool PathIsUnderRoot(string path, string? root) + { + if (string.IsNullOrWhiteSpace(root)) + return false; + + var normalizedRoot = root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return path.Equals(normalizedRoot, StringComparison.OrdinalIgnoreCase) || + path.StartsWith(normalizedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + path.StartsWith(normalizedRoot + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs index dd5d36da1..2a68c04d3 100644 --- a/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/AutoStartManager.cs @@ -1,18 +1,54 @@ using Microsoft.Win32; +using OpenClawTray.Helpers; using System; +using System.Threading.Tasks; namespace OpenClawTray.Services; /// -/// Manages Windows auto-start registry entries. +/// Manages "Launch when Windows starts" for the tray. +/// +/// For MSIX-packaged installs (the shipping channel) the only correct API is +/// Windows.ApplicationModel.StartupTask. The corresponding +/// windows.startupTask extension is declared in Package.appxmanifest +/// with TaskId="OpenClawCompanionStartup" and Enabled="false"; the +/// user opts in via Settings, which surfaces the one-time Windows consent dialog +/// (and which the user can subsequently revoke via Task Manager → Startup). +/// +/// For unpackaged dev / debug builds we fall back to the legacy +/// HKCU\...\Run entry. The two paths are not interchangeable: an MSIX +/// install must NEVER write to HKCU\...\Run because (a) Windows ignores +/// it under MSIX governance and (b) the entry orphans when the package is +/// removed. /// public static class AutoStartManager { private const string RegistryKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; private const string AppName = "OpenClawTray"; + /// + /// StartupTask TaskId. Must match the TaskId attribute in + /// Package.appxmanifest under windows.startupTask. + /// + internal const string StartupTaskId = "OpenClawCompanionStartup"; + public static bool IsAutoStartEnabled() { + if (PackageHelper.IsPackaged) + { + try + { + var task = global::Windows.ApplicationModel.StartupTask.GetAsync(StartupTaskId).AsTask().GetAwaiter().GetResult(); + return task.State == global::Windows.ApplicationModel.StartupTaskState.Enabled + || task.State == global::Windows.ApplicationModel.StartupTaskState.EnabledByPolicy; + } + catch (Exception ex) + { + Logger.Warn($"StartupTask query failed (packaged): {ex.Message}"); + return false; + } + } + try { using var key = Registry.CurrentUser.OpenSubKey(RegistryKey, false); @@ -24,32 +60,73 @@ public static bool IsAutoStartEnabled() } } - public static void SetAutoStart(bool enable) + public static async Task SetAutoStartAsync(bool enable) { + if (PackageHelper.IsPackaged) + { + return await SetAutoStartPackagedAsync(enable); + } + try { using var key = Registry.CurrentUser.CreateSubKey(RegistryKey, true); if (key == null) { Logger.Warn($"Auto-start registry key unavailable: HKCU\\{RegistryKey}"); - return; + return false; } if (enable) { var exePath = Environment.ProcessPath ?? System.Reflection.Assembly.GetExecutingAssembly().Location; key.SetValue(AppName, $"\"{exePath}\""); - Logger.Info("Auto-start enabled"); + Logger.Info("Auto-start enabled (unpackaged, HKCU\\...\\Run)"); + return true; } else { key.DeleteValue(AppName, false); - Logger.Info("Auto-start disabled"); + Logger.Info("Auto-start disabled (unpackaged, HKCU\\...\\Run)"); + return true; + } + } + catch (Exception ex) + { + Logger.Error($"Failed to set auto-start (unpackaged): {ex.Message}"); + return false; + } + } + + private static async Task SetAutoStartPackagedAsync(bool enable) + { + try + { + var task = await global::Windows.ApplicationModel.StartupTask.GetAsync(StartupTaskId); + if (enable) + { + // RequestEnableAsync surfaces the one-time consent prompt on first call + // and returns the resulting state. DisabledByUser / DisabledByPolicy mean + // the user revoked it via Task Manager and the toggle is essentially + // read-only until they re-enable it there. + var state = await task.RequestEnableAsync(); + Logger.Info($"StartupTask enable requested → state={state}"); + var enabled = state == global::Windows.ApplicationModel.StartupTaskState.Enabled + || state == global::Windows.ApplicationModel.StartupTaskState.EnabledByPolicy; + if (!enabled) + Logger.Warn($"StartupTask enable did not take effect; state={state}"); + return enabled; + } + else + { + task.Disable(); + Logger.Info("StartupTask disabled"); + return true; } } catch (Exception ex) { - Logger.Error($"Failed to set auto-start: {ex.Message}"); + Logger.Error($"Failed to set auto-start (packaged): {ex.Message}"); + return false; } } } diff --git a/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs b/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs index 69a20985b..35bb8505b 100644 --- a/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs +++ b/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs @@ -15,6 +15,7 @@ internal interface IAppCommands void ShowVoiceOverlay(); void ShowChat(); void CheckForUpdates(); + void ApplyUpdateNow(); void ShowOnboarding(); void ShowConnectionStatus(); void NotifySettingsSaved(); diff --git a/src/OpenClaw.Tray.WinUI/Services/NotificationSettingsRegistrationService.cs b/src/OpenClaw.Tray.WinUI/Services/NotificationSettingsRegistrationService.cs new file mode 100644 index 000000000..8557c51ce --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/NotificationSettingsRegistrationService.cs @@ -0,0 +1,96 @@ +using Microsoft.Toolkit.Uwp.Notifications; +using System; +using System.Runtime.InteropServices; +using System.Threading.Tasks; + +namespace OpenClawTray.Services; + +/// +/// Seeds Windows Settings > System > Notifications for the packaged app. +/// +internal static class NotificationSettingsRegistrationService +{ + internal const string RegistrationToastTag = "openclaw-notification-settings-registration"; + internal const string RegistrationToastGroup = "openclaw-system-registration"; + private const string LocalSettingsKey = "OpenClaw.NotificationSettingsSeeded"; + + public static void EnsureRegistered() + { + if (!OpenClawTray.Helpers.PackageHelper.IsPackaged) + return; + + if (HasAlreadySeeded()) + return; + + try + { + var notifier = ToastNotificationManagerCompat.CreateToastNotifier(); + notifier.Show(CreateSuppressedRegistrationToast()); + MarkSeeded(); + _ = RemoveRegistrationToastFromHistoryAsync(); + Logger.Info("Seeded Windows notification settings registration"); + } + catch (Exception ex) when (ex is COMException or InvalidOperationException or UnauthorizedAccessException or ArgumentException) + { + Logger.Warn($"Failed to seed Windows notification settings registration: {ex.Message}"); + } + } + + private static global::Windows.UI.Notifications.ToastNotification CreateSuppressedRegistrationToast() + { + var xml = new global::Windows.Data.Xml.Dom.XmlDocument(); + xml.LoadXml( + "" + + "" + + "OpenClaw Companion" + + "Notifications are registered for this app." + + "" + + ""); + + return new global::Windows.UI.Notifications.ToastNotification(xml) + { + Tag = RegistrationToastTag, + Group = RegistrationToastGroup, + SuppressPopup = true + }; + } + + private static async Task RemoveRegistrationToastFromHistoryAsync() + { + try + { + await Task.Delay(TimeSpan.FromSeconds(3)); + ToastNotificationManagerCompat.History.Remove(RegistrationToastTag, RegistrationToastGroup); + } + catch (Exception ex) when (ex is COMException or InvalidOperationException or UnauthorizedAccessException or ArgumentException) + { + Logger.Warn($"Failed to remove notification settings registration toast from history: {ex.Message}"); + } + } + + private static bool HasAlreadySeeded() + { + try + { + var values = global::Windows.Storage.ApplicationData.Current.LocalSettings.Values; + return values.TryGetValue(LocalSettingsKey, out var value) && value is bool seeded && seeded; + } + catch (Exception ex) when (ex is COMException or InvalidOperationException or UnauthorizedAccessException) + { + Logger.Warn($"Failed to read notification settings registration state: {ex.Message}"); + return false; + } + } + + private static void MarkSeeded() + { + try + { + global::Windows.Storage.ApplicationData.Current.LocalSettings.Values[LocalSettingsKey] = true; + } + catch (Exception ex) when (ex is COMException or InvalidOperationException or UnauthorizedAccessException) + { + Logger.Warn($"Failed to persist notification settings registration state: {ex.Message}"); + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/SingleInstanceLaunchGuard.cs b/src/OpenClaw.Tray.WinUI/Services/SingleInstanceLaunchGuard.cs new file mode 100644 index 000000000..40d8e21ab --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/SingleInstanceLaunchGuard.cs @@ -0,0 +1,104 @@ +using System; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Threading; + +namespace OpenClawTray.Services; + +internal static class SingleInstanceLaunchGuard +{ + public const string DefaultMutexName = "OpenClawTray"; + public static readonly TimeSpan PackagedLaunchRetryTimeout = TimeSpan.FromSeconds(15); + public static readonly TimeSpan PackagedLaunchRetryDelay = TimeSpan.FromMilliseconds(250); + + public enum AcquisitionStatus + { + Acquired, + AlreadyRunning, + AcquiredAfterWait, + TimedOut + } + + public sealed class AcquisitionResult + { + public AcquisitionResult(Mutex? mutex, AcquisitionStatus status, int attempts) + { + Mutex = mutex; + Status = status; + Attempts = attempts; + } + + public Mutex? Mutex { get; } + public AcquisitionStatus Status { get; } + public int Attempts { get; } + public bool HasMutex => Mutex is not null; + } + + public static string BuildMutexName(string? dataDirOverride) + { + if (dataDirOverride is null) + return DefaultMutexName; + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(dataDirOverride)); + return $"{DefaultMutexName}-{Convert.ToHexString(hash, 0, 4)}"; + } + + public static AcquisitionResult Acquire( + string mutexName, + bool retryWhenBusy, + TimeSpan retryTimeout, + TimeSpan retryDelay, + Action? trace = null, + Action? delay = null) + { + if (retryTimeout < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(retryTimeout), retryTimeout, "Retry timeout cannot be negative."); + if (retryDelay <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(retryDelay), retryDelay, "Retry delay must be positive."); + + var stopwatch = Stopwatch.StartNew(); + var attempts = 0; + + while (true) + { + var mutex = new Mutex(true, mutexName, out var createdNew); + if (createdNew) + { + var status = attempts == 0 ? AcquisitionStatus.Acquired : AcquisitionStatus.AcquiredAfterWait; + trace?.Invoke(attempts == 0 ? "acquired" : $"acquired-after-wait attempts={attempts}"); + return new AcquisitionResult(mutex, status, attempts); + } + + // Do not keep a non-owning handle open while waiting. Holding these + // handles can keep the kernel mutex object alive after the old process + // exits, causing the retry loop to time out even though launch is safe. + mutex.Dispose(); + + if (!retryWhenBusy) + { + trace?.Invoke("already-running"); + return new AcquisitionResult(null, AcquisitionStatus.AlreadyRunning, attempts); + } + + if (stopwatch.Elapsed >= retryTimeout) + { + trace?.Invoke($"timed-out attempts={attempts}"); + return new AcquisitionResult(null, AcquisitionStatus.TimedOut, attempts); + } + + attempts++; + trace?.Invoke($"busy-retry attempts={attempts}"); + + var remaining = retryTimeout - stopwatch.Elapsed; + var sleepFor = remaining < retryDelay ? remaining : retryDelay; + if (sleepFor <= TimeSpan.Zero) + continue; + + if (delay is null) + Thread.Sleep(sleepFor); + else + delay(sleepFor); + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs index 3ae334bca..3d103fc86 100644 --- a/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs +++ b/src/OpenClaw.Tray.WinUI/Services/TrayMenuStateBuilder.cs @@ -305,6 +305,10 @@ internal void Build(TrayMenuWindow menu) // Setup Guide / Reconfigure entry — label flips based on whether prior // configuration exists; routes to the existing "setup" action handler. menu.AddMenuItem(_snapshot.SetupMenuLabel, FluentIconCatalog.Build(FluentIconCatalog.Setup), "setup"); + menu.AddMenuItem( + LocalizationHelper.GetString("Menu_CheckForUpdates"), + FluentIconCatalog.Build(FluentIconCatalog.Refresh), + "checkupdates"); // ── Footer ── menu.AddSeparator(); diff --git a/src/OpenClaw.WinNode.Cli/OrphanPurger.cs b/src/OpenClaw.WinNode.Cli/OrphanPurger.cs new file mode 100644 index 000000000..48e25f60a --- /dev/null +++ b/src/OpenClaw.WinNode.Cli/OrphanPurger.cs @@ -0,0 +1,499 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Win32; + +namespace OpenClaw.WinNode.Cli; + +/// +/// Recovery path for the case where the user uninstalled the OpenClaw Companion +/// MSIX *without* first running the in-app "Reset & remove" flow. MSIX has no +/// supported CustomUninstall hook for non-Store packages, so anything we wrote +/// outside the package container (WSL distros installed by the local-gateway +/// flow, files under %APPDATA%\OpenClawTray\, the openclaw:// URI +/// registration, the auto-start Run key) becomes orphaned after Remove-AppxPackage. +/// +/// This class detects those orphans and (with --confirm-destructive) +/// removes them. Without that flag it dry-runs and emits JSON describing what +/// it would delete. Surfaced via OpenClaw.WinNode.Cli --purge-wsl-orphans. +/// +/// Exit codes: +/// +/// 0 — no orphans found, or all detected orphans were removed. +/// 1 — orphans were found but not removed (dry-run mode, missing +/// --confirm-destructive). The JSON report enumerates them. +/// 2 — orphan removal was attempted and at least one item failed. +/// +/// +internal static class OrphanPurger +{ + /// + /// WSL distro names that belong to the OpenClaw local-gateway flow. + /// + /// + /// The local-gateway installer has used two naming conventions across the + /// project's history: + /// + /// OpenClawGateway — the original PascalCase + /// name used by the WSL gateway installer (still in production as of + /// 2026-05; observed on Mike's dev box during the MSIX-E2E manual test + /// prep). + /// Known kebab-case names — the newer + /// convention adopted for variants like openclaw-local, + /// openclaw-staging. + /// + /// Match is case-insensitive because wsl --list --quiet echoes + /// the user-specified case verbatim and we cannot rely on either form. + /// Matching is intentionally exact so user-created distros named + /// my-openclaw-experiments or openclaw-personal are never + /// destroyed by the cleanup tool. + /// + internal const string LegacyOpenClawGatewayDistroName = "OpenClawGateway"; + internal static readonly string[] OpenClawOwnedWslDistroNames = new[] + { + LegacyOpenClawGatewayDistroName, + "openclaw-local", + "openclaw-staging", + }; + + /// + /// Retained for backward compatibility with OrphanPurgerContractTests + /// and for any external script that pattern-matches the historical + /// "openclaw-" prefix. New detection logic should use + /// . + /// + internal const string OrphanWslDistroPrefix = "openclaw-"; + internal const string ForceEvenIfInstalledFlag = "--force-even-if-installed"; + internal const string TrayMutexName = "OpenClawTray"; + internal static readonly string[] CompanionPackageNames = new[] + { + "OpenClaw.Companion", + "OpenClaw.Companion.Alpha", + }; + + /// + /// Registry subkeys under HKCU\Software\Classes we treat as + /// orphan URI scheme registrations. Both the lowercase + /// openclaw form (which the unpackaged DeepLinkHandler writes) + /// and the PascalCase OpenClaw form (observed in the wild from + /// older builds) are listed because Windows Explorer-driven user + /// scrubbers can leave one but not the other. + /// + internal static readonly string[] OrphanUriSchemeKeys = new[] + { + @"Software\Classes\openclaw", + @"Software\Classes\OpenClaw", + }; + + public record OrphanItem(string Kind, string Name, string Detail); + public record PurgeReport( + IReadOnlyList Orphans, + IReadOnlyList Removed, + IReadOnlyList Failed, + bool ConfirmDestructive, + string? BlockedReason = null); + + public static async Task RunAsync( + bool confirmDestructive, + bool jsonOutput, + TextWriter stdout, + TextWriter stderr, + Func? envLookup = null, + bool forceEvenIfInstalled = false) + { + envLookup ??= Environment.GetEnvironmentVariable; + + var orphans = new List(); + orphans.AddRange(DetectWslDistros(stderr)); + orphans.AddRange(DetectFileOrphans(envLookup)); + orphans.AddRange(DetectRegistryOrphans()); + + var removed = new List(); + var failed = new List(); + string? blockedReason = null; + + if (confirmDestructive && orphans.Count > 0 && !forceEvenIfInstalled && + TryGetLiveInstallBlockReason(stderr, out blockedReason)) + { + stderr.WriteLine($"[purge] Refusing destructive cleanup: {blockedReason}"); + stderr.WriteLine($"[purge] Run Reset & remove from the installed app first, or pass {ForceEvenIfInstalledFlag} if you already verified the install is gone."); + } + else if (confirmDestructive) + { + foreach (var orphan in orphans) + { + try + { + await RemoveAsync(orphan, stderr); + removed.Add(orphan); + } + catch (Exception ex) + { + failed.Add(orphan with { Detail = $"{orphan.Detail} (remove failed: {ex.Message})" }); + } + } + } + + var report = new PurgeReport(orphans, removed, failed, confirmDestructive, blockedReason); + if (jsonOutput) + { + stdout.WriteLine(JsonSerializer.Serialize(report, + new JsonSerializerOptions { WriteIndented = true })); + } + else + { + WriteHumanReport(report, stdout); + } + + if (blockedReason is not null) return 2; + if (failed.Count > 0) return 2; + if (!confirmDestructive && orphans.Count > 0) return 1; + return 0; + } + + private static IEnumerable DetectWslDistros(TextWriter stderr) + { + // wsl.exe --list --quiet writes one distro name per line, encoded as + // UTF-16LE without BOM (a known wsl.exe quirk). We force the codepage + // via cmd /U-equivalent and read raw bytes, then strip the BOM-less + // UTF-16. + ProcessStartInfo psi; + try + { + psi = new ProcessStartInfo("wsl.exe", "--list --quiet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = System.Text.Encoding.Unicode + }; + } + catch (Exception ex) + { + stderr.WriteLine($"[purge] WSL not available ({ex.Message}); skipping distro detection."); + yield break; + } + + Process? proc = null; + try + { + proc = Process.Start(psi); + } + catch (Exception ex) + { + stderr.WriteLine($"[purge] wsl.exe failed to launch ({ex.Message}); skipping distro detection."); + yield break; + } + if (proc is null) yield break; + + var output = proc.StandardOutput.ReadToEnd(); + proc.WaitForExit(); + if (proc.ExitCode != 0) + { + // Most likely "WSL has no distributions installed" — exit code 1 + // with empty output. Nothing to do. + yield break; + } + + foreach (var rawLine in output.Split('\n')) + { + var line = rawLine.Trim().Trim('\u0000'); + if (line.Length == 0) continue; + if (!IsOpenClawOwnedWslDistroName(line)) continue; + yield return new OrphanItem( + Kind: "wsl-distro", + Name: line, + Detail: $"WSL distribution installed by the OpenClaw local-gateway flow"); + } + } + + internal static bool IsOpenClawOwnedWslDistroName(string distroName) => + OpenClawOwnedWslDistroNames.Any(owned => + distroName.Equals(owned, StringComparison.OrdinalIgnoreCase)); + + private static bool TryGetLiveInstallBlockReason(TextWriter stderr, out string reason) + { + if (IsTrayMutexPresent()) + { + reason = "OpenClaw tray is still running"; + return true; + } + + var packageState = TryGetCompanionPackageInstalledForCurrentUser(stderr); + if (packageState == true) + { + reason = "OpenClaw Companion MSIX is still installed for the current user"; + return true; + } + + if (packageState is null) + { + reason = "could not verify that the OpenClaw Companion MSIX is removed"; + return true; + } + + reason = string.Empty; + return false; + } + + private static bool IsTrayMutexPresent() + { + try + { + if (!Mutex.TryOpenExisting(TrayMutexName, out var mutex)) + return false; + + mutex.Dispose(); + return true; + } + catch + { + return false; + } + } + + private static bool? TryGetCompanionPackageInstalledForCurrentUser(TextWriter stderr) + { + try + { + var packageNameList = string.Join(",", + CompanionPackageNames.Select(name => $"'{name.Replace("'", "''")}'")); + var script = $"$names=@({packageNameList}); " + + "$pkg=@(); foreach ($name in $names) { " + + "$pkg += @(Get-AppxPackage -Name $name -ErrorAction SilentlyContinue) }; " + + "if ($pkg.Count -gt 0) { exit 0 }; exit 1"; + var psi = new ProcessStartInfo("powershell.exe") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add("-NoProfile"); + psi.ArgumentList.Add("-NonInteractive"); + psi.ArgumentList.Add("-ExecutionPolicy"); + psi.ArgumentList.Add("Bypass"); + psi.ArgumentList.Add("-Command"); + psi.ArgumentList.Add(script); + + using var proc = Process.Start(psi); + if (proc is null) + return null; + + if (!proc.WaitForExit(10_000)) + { + try { proc.Kill(entireProcessTree: true); } catch { /* best effort */ } + stderr.WriteLine("[purge] timed out verifying MSIX package registration"); + return null; + } + + return proc.ExitCode switch + { + 0 => true, + 1 => false, + _ => null + }; + } + catch (Exception ex) + { + stderr.WriteLine($"[purge] failed to verify MSIX package registration ({ex.Message})"); + return null; + } + } + + private static IEnumerable DetectFileOrphans(Func envLookup) + { + foreach (var candidate in new[] + { + (Env: "APPDATA", Sub: "OpenClawTray", Kind: "appdata-folder"), + (Env: "LOCALAPPDATA", Sub: "OpenClawTray", Kind: "localappdata-folder"), + }) + { + var root = envLookup(candidate.Env); + if (string.IsNullOrEmpty(root)) continue; + var path = Path.Combine(root, candidate.Sub); + if (!Directory.Exists(path)) continue; + + long byteCount = 0; + int fileCount = 0; + try + { + foreach (var f in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) + { + fileCount++; + byteCount += new FileInfo(f).Length; + } + } + catch { /* best-effort size; reporting still useful */ } + + yield return new OrphanItem( + Kind: candidate.Kind, + Name: path, + Detail: $"{fileCount} file(s), {byteCount} byte(s)"); + } + } + + private static IEnumerable DetectRegistryOrphans() + { + // openclaw:// URI scheme (unpackaged-only; PR #310 path). Packaged + // installs use the windows.protocol manifest extension and there is + // nothing in HKCU\Software\Classes for them. We check both casing + // variants because the registry is case-insensitive for lookup but + // can hold both keys simultaneously if different scrubbing scripts + // touched them. + foreach (var subkey in OrphanUriSchemeKeys) + { + if (TryRegistryKeyExists(Registry.CurrentUser, subkey, out var detail)) + { + yield return new OrphanItem("registry-uri-scheme", + $@"HKCU\{subkey}", + detail); + } + } + + // HKCU\...\Run entry for the legacy auto-start path (now superseded by + // the MSIX StartupTask extension; an orphan here would silently re-launch + // the no-longer-installed exe at sign-in). + if (TryRegistryValueExists(Registry.CurrentUser, + @"Software\Microsoft\Windows\CurrentVersion\Run", "OpenClawTray", out var runDetail)) + { + yield return new OrphanItem("registry-run-key", + @"HKCU\Software\Microsoft\Windows\CurrentVersion\Run\OpenClawTray", + runDetail); + } + } + + private static bool TryRegistryKeyExists(RegistryKey root, string path, out string detail) + { + detail = "registry key present"; + try + { + using var key = root.OpenSubKey(path, writable: false); + return key != null; + } + catch + { + return false; + } + } + + private static bool TryRegistryValueExists(RegistryKey root, string path, string valueName, out string detail) + { + detail = $"value '{valueName}' present"; + try + { + using var key = root.OpenSubKey(path, writable: false); + if (key == null) return false; + return key.GetValue(valueName) != null; + } + catch + { + return false; + } + } + + private static async Task RemoveAsync(OrphanItem orphan, TextWriter stderr) + { + switch (orphan.Kind) + { + case "wsl-distro": + await RunWslUnregister(orphan.Name, stderr); + break; + case "appdata-folder": + case "localappdata-folder": + Directory.Delete(orphan.Name, recursive: true); + break; + case "registry-uri-scheme": + // Strip the HKCU\ prefix re-attached at detection time to + // recover the original subkey path. We delete *that* subtree + // rather than the hard-coded lowercase variant so the + // PascalCase key (HKCU\Software\Classes\OpenClaw) also gets + // removed when it's the one detected. + var subkey = orphan.Name.StartsWith(@"HKCU\") + ? orphan.Name[5..] + : orphan.Name; + Registry.CurrentUser.DeleteSubKeyTree(subkey, throwOnMissingSubKey: false); + break; + case "registry-run-key": + using (var key = Registry.CurrentUser.OpenSubKey( + @"Software\Microsoft\Windows\CurrentVersion\Run", writable: true)) + { + key?.DeleteValue("OpenClawTray", throwOnMissingValue: false); + } + break; + default: + throw new InvalidOperationException($"Unknown orphan kind: {orphan.Kind}"); + } + } + + private static async Task RunWslUnregister(string distroName, TextWriter stderr) + { + // We deliberately do NOT shell out via cmd /c. ArgumentList keeps distro + // names with spaces as a single argv element without opening command + // injection risk. + var psi = new ProcessStartInfo("wsl.exe") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add("--unregister"); + psi.ArgumentList.Add(distroName); + var proc = Process.Start(psi) + ?? throw new InvalidOperationException("wsl.exe failed to launch"); + await proc.WaitForExitAsync(); + if (proc.ExitCode != 0) + { + var err = await proc.StandardError.ReadToEndAsync(); + throw new InvalidOperationException( + $"wsl --unregister {distroName} exited {proc.ExitCode}: {err.Trim()}"); + } + } + + private static void WriteHumanReport(PurgeReport report, TextWriter stdout) + { + if (report.Orphans.Count == 0) + { + stdout.WriteLine("No OpenClaw orphans detected."); + return; + } + + stdout.WriteLine($"Detected {report.Orphans.Count} orphan(s):"); + foreach (var o in report.Orphans) + { + stdout.WriteLine($" [{o.Kind}] {o.Name} — {o.Detail}"); + } + + if (!report.ConfirmDestructive) + { + stdout.WriteLine(); + stdout.WriteLine("Dry-run; pass --confirm-destructive to actually remove them."); + return; + } + + if (report.Removed.Count > 0) + { + stdout.WriteLine(); + stdout.WriteLine($"Removed {report.Removed.Count}:"); + foreach (var o in report.Removed) + { + stdout.WriteLine($" [{o.Kind}] {o.Name}"); + } + } + if (report.Failed.Count > 0) + { + stdout.WriteLine(); + stdout.WriteLine($"Failed to remove {report.Failed.Count}:"); + foreach (var o in report.Failed) + { + stdout.WriteLine($" [{o.Kind}] {o.Name} — {o.Detail}"); + } + } + } +} diff --git a/src/OpenClaw.WinNode.Cli/Program.cs b/src/OpenClaw.WinNode.Cli/Program.cs index a05158bdd..0296248ca 100644 --- a/src/OpenClaw.WinNode.Cli/Program.cs +++ b/src/OpenClaw.WinNode.Cli/Program.cs @@ -57,6 +57,19 @@ public static async Task RunAsync( return args.Length == 0 ? 2 : 0; } + // Standalone subcommands intercepted BEFORE argument parsing because + // they don't need a --command and bypass the MCP transport entirely. + // Currently: --purge-wsl-orphans (recovery path for users who removed + // the MSIX without running the in-app "Reset & remove" first; see + // docs/uninstall-msix.md). + if (args.Contains("--purge-wsl-orphans")) + { + var confirm = args.Contains("--confirm-destructive"); + var json = args.Contains("--json-output"); + var forceEvenIfInstalled = args.Contains(OrphanPurger.ForceEvenIfInstalledFlag); + return await OrphanPurger.RunAsync(confirm, json, stdout, stderr, envLookup, forceEvenIfInstalled); + } + WinNodeOptions options; try { @@ -831,11 +844,22 @@ internal static void PrintUsage(TextWriter stdout) stdout.WriteLine(" --verbose Print endpoint + ignored flags to stderr"); stdout.WriteLine(" --help, -h Show this help"); stdout.WriteLine(); + stdout.WriteLine("Recovery subcommands (do not require --command):"); + stdout.WriteLine(" --purge-wsl-orphans Detect WSL distros / %APPDATA% files / openclaw://"); + stdout.WriteLine(" registry keys left behind by a Remove-AppxPackage that"); + stdout.WriteLine(" skipped the in-app Reset & remove. Dry-run by default;"); + stdout.WriteLine(" pass --confirm-destructive to actually delete."); + stdout.WriteLine(" --confirm-destructive Apply the deletions (otherwise dry-run; exit 1 if dirty)"); + stdout.WriteLine(" --force-even-if-installed Override the installed/running safety guard"); + stdout.WriteLine(" --json-output Emit the orphan/removed/failed report as JSON"); + stdout.WriteLine(); stdout.WriteLine("Examples:"); stdout.WriteLine(" winnode --command system.which --params '{\"bins\":[\"git\",\"node\"]}'"); stdout.WriteLine(" winnode --list-tools"); stdout.WriteLine(" winnode --command screen.snapshot"); stdout.WriteLine(" winnode --command canvas.present --params '{\"url\":\"https://example.com\"}'"); + stdout.WriteLine(" winnode --purge-wsl-orphans --json-output # dry-run"); + stdout.WriteLine(" winnode --purge-wsl-orphans --confirm-destructive --json-output"); stdout.WriteLine(); stdout.WriteLine("See skill.md (next to this exe) for the full agent reference: every supported"); stdout.WriteLine("command, its argument schema, and the A2UI v0.8 JSONL grammar."); diff --git a/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs b/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs new file mode 100644 index 000000000..86f976bc8 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs @@ -0,0 +1,394 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace OpenClaw.Tray.Tests; + +/// +/// Structural assertions on the AppInstaller template + the in-app update +/// service contract. The template is rendered by CI and parsed by Windows +/// AppInstaller; even a single attribute typo silently breaks auto-update +/// for every user who installed via that link, with no in-app surface to +/// notice. The tests here pin: +/// +/// 1. The template is well-formed XML against the AppInstaller schema URI. + /// 2. The placeholder tokens are present (so the CI render script's +/// substitution table is exhaustive). +/// 3. The UpdateSettings block stays quiet: AutomaticBackgroundTask only, +/// no OnLaunch UI and no downgrade rollback. +/// 4. The in-app service points at the same hosted URL the release pipeline +/// publishes (drift here would split-brain installs that polled the +/// stable architecture URL against installs that polled the in-app URL). +/// +public sealed class AppInstallerTemplateAssertionTests +{ + private static string GetRepositoryRoot() + { + var envRepoRoot = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (!string.IsNullOrWhiteSpace(envRepoRoot) && Directory.Exists(envRepoRoot)) + return envRepoRoot; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if ((Directory.Exists(Path.Combine(directory.FullName, ".git")) || + File.Exists(Path.Combine(directory.FullName, ".git"))) && + File.Exists(Path.Combine(directory.FullName, "README.md"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new InvalidOperationException( + "Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path."); + } + + private static string LoadTemplate() => + File.ReadAllText(Path.Combine( + GetRepositoryRoot(), "installer", "openclaw-companion.appinstaller.template")); + + [Fact] + public void Template_IsWellFormedXml() + { + // Parse with placeholders intact — XML parsing tolerates {{TOKEN}} as + // attribute *values* because they're just strings. If we added attribute + // names with placeholders we'd have to render before parsing. + var doc = XDocument.Parse(LoadTemplate()); + Assert.Equal("AppInstaller", doc.Root!.Name.LocalName); + Assert.Equal("http://schemas.microsoft.com/appx/appinstaller/2018", + doc.Root.Name.NamespaceName); + } + + [Theory] + [InlineData("{{VERSION}}")] + [InlineData("{{PUBLISHER}}")] + [InlineData("{{IDENTITY_NAME}}")] + [InlineData("{{PROCESSOR_ARCHITECTURE}}")] + [InlineData("{{MSIX_URI}}")] + [InlineData("{{WINDOWS_APP_RUNTIME_URI}}")] + [InlineData("{{APPINSTALLER_URI}}")] + public void Template_DeclaresExpectedPlaceholder(string token) + { + // scripts/render-appinstaller.ps1 substitutes exactly these tokens. + // If you add a new placeholder here, also add a -replace in the script + // AND a CI step parameter. If you remove one, the renderer silently + // ships the literal {{TOKEN}} string to AppInstaller which fails to parse. + Assert.Contains(token, LoadTemplate()); + } + + [Fact] + public void Template_UsesQuietBackgroundUpdateSettingsOnly() + { + var doc = XDocument.Parse(LoadTemplate()); + XNamespace ns = "http://schemas.microsoft.com/appx/appinstaller/2018"; + + Assert.Empty(doc.Descendants(ns + "OnLaunch")); + Assert.Empty(doc.Descendants(ns + "ForceUpdateFromAnyVersion")); + + var backgroundTasks = doc.Descendants(ns + "AutomaticBackgroundTask").ToArray(); + Assert.Single(backgroundTasks); + } + + [Fact] + public void Template_UsesArchitectureSpecificMainPackage() + { + var doc = XDocument.Parse(LoadTemplate()); + XNamespace ns = "http://schemas.microsoft.com/appx/appinstaller/2018"; + Assert.Empty(doc.Descendants(ns + "MainBundle")); + + var mainPackage = doc.Descendants(ns + "MainPackage").Single(); + Assert.Equal("{{IDENTITY_NAME}}", (string?)mainPackage.Attribute("Name")); + Assert.Equal("{{PROCESSOR_ARCHITECTURE}}", (string?)mainPackage.Attribute("ProcessorArchitecture")); + Assert.Equal("{{MSIX_URI}}", (string?)mainPackage.Attribute("Uri")); + } + + [Fact] + public void Template_DeclaresWindowsAppRuntimeFrameworkDependency() + { + var doc = XDocument.Parse(LoadTemplate()); + XNamespace ns = "http://schemas.microsoft.com/appx/appinstaller/2018"; + + var dependency = doc.Descendants(ns + "Dependencies") + .Elements(ns + "Package") + .Single(e => (string?)e.Attribute("Name") == "Microsoft.WindowsAppRuntime.2"); + + Assert.Equal("CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US", + (string?)dependency.Attribute("Publisher")); + Assert.Equal("2.0.1.0", (string?)dependency.Attribute("Version")); + Assert.Equal("{{PROCESSOR_ARCHITECTURE}}", (string?)dependency.Attribute("ProcessorArchitecture")); + Assert.Equal("{{WINDOWS_APP_RUNTIME_URI}}", (string?)dependency.Attribute("Uri")); + } + + [Fact] + public void InAppService_PointsAtSameStableArchitectureUrlsAsReleaseChannel() + { + var service = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Services", "AppInstallerUpdateService.cs")); + + Assert.Contains("https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller", service); + Assert.Contains("https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-arm64.appinstaller", service); + + var ci = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "ci.yml")); + Assert.Contains("https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-x64.appinstaller", ci); + Assert.Contains("https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/openclaw-arm64.appinstaller", ci); + } + + [Fact] + public void ReleaseWorkflow_RequiresBothArchitectureMsixAndAppInstallerArtifacts() + { + var ci = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "ci.yml")); + var releaseStart = ci.IndexOf(" release:", StringComparison.Ordinal); + Assert.True(releaseStart >= 0, "release job not found in ci.yml"); + var releaseJob = ci[releaseStart..]; + + Assert.Contains("Where-Object { $_.Id -eq \"App\" }", ci); + Assert.Contains("Tray application Id='App' not found", ci); + Assert.Contains("needs.build-msix.result == 'success'", releaseJob); + Assert.DoesNotContain("steps.msix-x64.outcome", releaseJob); + Assert.DoesNotContain("steps.msix-arm64.outcome", releaseJob); + Assert.Contains("Expected exactly one x64 MSIX artifact", releaseJob); + Assert.Contains("Expected exactly one ARM64 MSIX artifact", releaseJob); + Assert.Contains("OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.msix", releaseJob); + Assert.Contains("OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.msix", releaseJob); + Assert.Contains("Output/OpenClawTray-Setup-x64.exe", releaseJob); + Assert.Contains("Output/OpenClawTray-Setup-arm64.exe", releaseJob); + Assert.Contains("OpenClawTray-${{ needs.test.outputs.semVer }}-win-x64.zip", releaseJob); + Assert.Contains("OpenClawTray-${{ needs.test.outputs.semVer }}-win-arm64.zip", releaseJob); + Assert.Contains("Microsoft.WindowsAppRuntime.2-2.0.1.0-win-x64.msix", releaseJob); + Assert.Contains("Microsoft.WindowsAppRuntime.2-2.0.1.0-win-arm64.msix", releaseJob); + Assert.DoesNotContain("OpenClawCompanion-" + "r" + "ed", releaseJob); + Assert.DoesNotContain("OpenClawCompanion-" + "b" + "lue", releaseJob); + Assert.DoesNotContain("OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-x64.appinstaller", releaseJob); + Assert.DoesNotContain("OpenClawCompanion-${{ needs.test.outputs.semVer }}-win-arm64.appinstaller", releaseJob); + Assert.Contains("openclaw-x64.appinstaller", releaseJob); + Assert.Contains("openclaw-arm64.appinstaller", releaseJob); + Assert.DoesNotContain("openclaw-alpha", releaseJob); + Assert.DoesNotContain("openclaw*.appinstaller", releaseJob); + Assert.Contains("Prepare Release File List", releaseJob); + Assert.Contains("!contains(github.ref_name, '-')", releaseJob); + Assert.Contains("Sign Release MSIX Packages", releaseJob); + Assert.Contains("files-folder-filter: msix", releaseJob); + Assert.Contains("Prepare MSIX Payloads for Inner Signing", releaseJob); + Assert.Contains("Sign MSIX Payload Files", releaseJob); + Assert.Contains("files-folder-filter: exe,dll,ps1,psm1,psd1", releaseJob); + Assert.Contains("files-folder-recurse: true", releaseJob); + Assert.Contains("append-signature: true", releaseJob); + Assert.Contains("Repack MSIX Packages After Payload Signing", releaseJob); + Assert.Contains("Verify Signed MSIX Payloads", releaseJob); + Assert.Contains("verify-msix-payload-signatures.ps1", releaseJob); + Assert.Contains("Copy Windows App SDK Framework Packages", releaseJob); + Assert.Contains("Create Release ZIPs", releaseJob); + Assert.Contains("Install Inno Setup", releaseJob); + Assert.Contains("Build x64 Installer", releaseJob); + Assert.Contains("Build arm64 Installer", releaseJob); + Assert.Contains("Sign Installer", releaseJob); + Assert.Contains("certificate-profile-name: WindowsEdgeLight", releaseJob); + } + + [Fact] + public void InAppService_DoesNotForceShutdownByDefault() + { + var service = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Services", "AppInstallerUpdateService.cs")); + var app = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + var applyIndex = app.IndexOf("ApplyUpdateNowUserInitiatedAsync", StringComparison.Ordinal); + var applyMethod = applyIndex >= 0 ? app[applyIndex..] : string.Empty; + + Assert.Contains("bool forceRestart = false", service); + Assert.Contains("CheckForUpdateAsync()", app); + Assert.DoesNotContain("TryApplyUpdateAsync(forceRestart: true", app[..applyIndex]); + Assert.Contains("TryApplyUpdateAsync(forceRestart: true", applyMethod); + Assert.Contains("bool forceRestart = false", service); + Assert.Contains("? global::Windows.Management.Deployment.AddPackageByAppInstallerOptions.ForceTargetAppShutdown", service); + Assert.Contains(": global::Windows.Management.Deployment.AddPackageByAppInstallerOptions.None", service); + } + + [Fact] + public void InAppService_DoesNotReportPackagesInUseAsNoUpdateAvailable() + { + var service = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Services", "AppInstallerUpdateService.cs")); + var app = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + + Assert.Contains("HResultPackagesInUse", service); + Assert.Contains("UpdatePendingRestart", service); + Assert.Contains("UpdatePendingRestart", app); + Assert.DoesNotContain("0x80073D02 => new UpdateResult(UpdateOutcome.NoUpdateAvailable", service); + } + + [Fact] + public void InAppService_DoesNotReportMissingDeploymentHResultAsCurrent() + { + var service = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Services", "AppInstallerUpdateService.cs")); + + Assert.Contains("HResultPackageAlreadyExists => new UpdateResult(UpdateOutcome.NoUpdateAvailable", service); + Assert.Contains("0 => new UpdateResult(UpdateOutcome.Failed", service); + Assert.DoesNotContain("0 or HResultPackageAlreadyExists", service); + } + + [Fact] + public void ManualUpdateCheck_IsMetadataOnly() + { + var service = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Services", "AppInstallerUpdateService.cs")); + var app = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + + Assert.Contains("CheckForUpdateAsync", service); + Assert.Contains("Timeout = TimeSpan.FromSeconds(15)", service); + Assert.Contains("ResolveAppInstallerUri", service); + Assert.Contains("GetAppInstallerInfo()", service); + Assert.Contains("ArchitectureFallbackAppInstallerUri", service); + Assert.Contains("uri.Scheme.Equals(Uri.UriSchemeHttp", service); + Assert.Contains("uri.Scheme.Equals(Uri.UriSchemeHttps", service); + Assert.Contains("TryGetChannelFallbackAppInstallerUri", service); + Assert.Contains("OpenClaw.Companion.", service); + Assert.Contains("No fallback AppInstaller feed is configured", service); + Assert.Contains("ParseAppInstallerPackageName", service); + Assert.Contains("ClassifyPublishedIdentity", service); + Assert.Contains("ParseAppInstallerVersion", service); + Assert.Contains("element.Name.LocalName == \"MainPackage\"", service); + Assert.Contains("AppInstaller MainPackage Version must be a four-part version", service); + Assert.Contains("ClassifyPublishedVersion", service); + Assert.Contains("UpdateAvailable", service); + Assert.Contains("AppInstallerUpdateService.CheckForUpdateAsync()", app); + Assert.DoesNotContain("var outcome = await AppInstallerUpdateService.TryApplyUpdateAsync()", app); + } + + [Fact] + public void StableFeedFiles_ExistAsBootstrapPlaceholders() + { + foreach (var (fileName, arch) in new[] + { + ("openclaw-x64.appinstaller", "x64"), + ("openclaw-arm64.appinstaller", "arm64") + }) + { + var path = Path.Combine(GetRepositoryRoot(), "installer", "appinstaller", fileName); + Assert.True(File.Exists(path), $"Missing stable feed bootstrap file: {fileName}"); + + var doc = XDocument.Load(path); + XNamespace ns = "http://schemas.microsoft.com/appx/appinstaller/2018"; + Assert.Equal("0.0.0.0", (string?)doc.Root!.Attribute("Version")); + Assert.Equal($"https://raw.githubusercontent.com/openclaw/openclaw-windows-node/master/installer/appinstaller/{fileName}", + (string?)doc.Root.Attribute("Uri")); + + var mainPackage = doc.Descendants(ns + "MainPackage").Single(); + Assert.Equal("OpenClaw.Companion", (string?)mainPackage.Attribute("Name")); + Assert.Equal("0.0.0.0", (string?)mainPackage.Attribute("Version")); + Assert.Equal(arch, (string?)mainPackage.Attribute("ProcessorArchitecture")); + } + } + + [Fact] + public void InAppService_UsesCurrentPackageVolumeBeforeDefaultFallback() + { + var service = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Services", "AppInstallerUpdateService.cs")); + + Assert.Contains("ResolveCurrentPackageVolume(manager)", service); + Assert.Contains("Package.Current.InstalledLocation.Path", service); + Assert.Contains("manager.FindPackageVolumes()", service); + Assert.Contains("manager.GetDefaultPackageVolume()", service); + } + + [Fact] + public void ToastActivatorColdLaunch_DoesNotExitBeforeSingleInstanceGuard() + { + var app = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + + Assert.Contains("new Mutex(true, mutexName, out bool createdNew)", app); + Assert.DoesNotContain("Environment.Exit(0);", app); + } + + [Fact] + public void ProductionSettingsUi_DoesNotContainBlueLobsterTestMarker() + { + var hub = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Windows", "HubWindow.xaml")); + var settings = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Pages", "SettingsPage.xaml")); + + Assert.Contains("Assets/SidebarIcons/Settings.svg", hub); + Assert.DoesNotContain("SettingsBlueLobster", hub); + Assert.DoesNotContain("SettingsBlueLobster", settings); + Assert.DoesNotContain("Blue lobster update test icon", settings); + Assert.False(File.Exists(Path.Combine( + GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Assets", "SidebarIcons", "SettingsBlueLobster.svg"))); + } + + [Fact] + public void HostingValidationScript_ChecksMimeLengthAndRange() + { + var script = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), "scripts", "validate-appinstaller-hosting.ps1")); + + Assert.Contains("application/appinstaller", script); + Assert.Contains("application/msix", script); + Assert.Contains("AppInstallerPath", script); + Assert.Contains("AllowGitHubContentTypes", script); + Assert.Contains("application/octet-stream", script); + Assert.Contains("Scheme -ne 'https'", script); + Assert.Contains("Content-Length", script); + Assert.Contains("Range = 'bytes=0-0'", script); + Assert.Contains("StatusCode -ne 206", script); + } + + [Fact] + public void AppInstallerUpdateSmokeScript_BindsSingleHttpListenerAndSelfChecks() + { + var script = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), "scripts", "test-appinstaller-update.ps1")); + + Assert.DoesNotContain("$listener = [System.Net.HttpListener]::new()", script); + Assert.Contains("Invoke-WebRequest \"$baseUri/openclaw.appinstaller\"", script); + Assert.Contains("$listenerJob.State -eq 'Failed'", script); + } + + [Fact] + public void FeedUpdateWorkflow_OpensMaintainerPrAndBlocksPrereleaseFeeds() + { + var workflow = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), ".github", "workflows", "appinstaller-feed-pr.yml")); + var ci = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "ci.yml")); + + Assert.Contains("appinstaller-feed-pr.yml", ci); + Assert.Contains("actions: write", ci); + Assert.Contains("workflow_dispatch", workflow); + Assert.Contains("pull-requests: write", workflow); + Assert.Contains("contents: write", workflow); + Assert.Contains("Pre-release AppInstaller feed updates are blocked", workflow); + Assert.Contains("installer\\appinstaller", workflow); + Assert.Contains("openclaw-x64.appinstaller", workflow); + Assert.Contains("openclaw-arm64.appinstaller", workflow); + Assert.Contains("gh pr create", workflow); + Assert.Contains("--base master", workflow); + Assert.Contains("validate-appinstaller-hosting.ps1", workflow); + Assert.Contains("-AllowGitHubContentTypes", workflow); + Assert.Contains("OpenClawCompanion-$versionText-win-x64.msix", workflow); + Assert.Contains("OpenClawCompanion-$versionText-win-arm64.msix", workflow); + Assert.Contains("Microsoft.WindowsAppRuntime.2-2.0.1.0-win-x64.msix", workflow); + Assert.Contains("Microsoft.WindowsAppRuntime.2-2.0.1.0-win-arm64.msix", workflow); + Assert.Contains("-WindowsAppRuntimeUri $x64RuntimeUri", workflow); + Assert.Contains("-WindowsAppRuntimeUri $arm64RuntimeUri", workflow); + Assert.DoesNotContain("OpenClawCompanion-*-win-x64.msix", workflow); + Assert.DoesNotContain("OpenClawCompanion-*-win-arm64.msix", workflow); + } +} diff --git a/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs b/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs index 3e3af0685..e07ae2e00 100644 --- a/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs +++ b/tests/OpenClaw.Tray.Tests/FluentIconCatalogTests.cs @@ -205,6 +205,16 @@ public void BuildTrayMenuPopup_RoutesAboutAction() Assert.Contains("case \"about\":", ReadAppXaml()); } + [Fact] + public void BuildTrayMenuPopup_RendersCheckForUpdatesAction() + { + var src = ReadStateBuilder(); + Assert.Contains("LocalizationHelper.GetString(\"Menu_CheckForUpdates\")", src); + Assert.Contains("FluentIconCatalog.Build(FluentIconCatalog.Refresh)", src); + Assert.Contains("\"checkupdates\"", src); + Assert.Contains("case \"checkupdates\":", ReadAppXaml()); + } + [Fact] public void BuildTrayMenuPopup_BatchesUpdates() { @@ -241,6 +251,8 @@ public void BuildTrayMenuPopup_TopLevelActions_AllHaveExplicitHandlers() "reconnect", // brand-header button (when disconnected) "permissions", // permissions row "setup", // setup/reconfigure row + "quicksend", // quicksend row + "checkupdates", // updates row "companion", // footer "about", // footer "exit", // footer diff --git a/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs b/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs new file mode 100644 index 000000000..335c82e48 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/MsixManifestAssertionTests.cs @@ -0,0 +1,464 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace OpenClaw.Tray.Tests; + +/// +/// Structural assertions on the MSIX Package.appxmanifest files for the +/// tray and the CommandPalette extension. These pin contracts that govern what +/// the signed MSIX is allowed to claim about itself: capabilities, identity, +/// publisher and startup-task TaskId. Manifest drift breaks signing (publisher +/// mismatch), breaks privacy expectations (extra capabilities silently slipping +/// in), or breaks the in-app StartupTask wiring (TaskId drift). +/// +/// CI patches Identity Name / Publisher / Version into both manifests before +/// build. The tests here cover the repo-source values plus the values CI is +/// expected to inject; we read the repo files directly because running tests +/// against the patched build output would require packaging tooling that the +/// unit-test target deliberately does not depend on. +/// +public sealed class MsixManifestAssertionTests +{ + private const string AppxFoundationNs = "http://schemas.microsoft.com/appx/manifest/foundation/windows10"; + private const string AppxUapNs = "http://schemas.microsoft.com/appx/manifest/uap/windows10"; + private const string AppxUap5Ns = "http://schemas.microsoft.com/appx/manifest/uap/windows10/5"; + private const string AppxUap13Ns = "http://schemas.microsoft.com/appx/manifest/uap/windows10/13"; + private const string AppxUap17Ns = "http://schemas.microsoft.com/appx/manifest/uap/windows10/17"; + private const string AppxUap3Ns = "http://schemas.microsoft.com/appx/manifest/uap/windows10/3"; + private const string AppxRescapNs = "http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"; + + private static string GetRepositoryRoot() + { + var envRepoRoot = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (!string.IsNullOrWhiteSpace(envRepoRoot) && Directory.Exists(envRepoRoot)) + return envRepoRoot; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if ((Directory.Exists(Path.Combine(directory.FullName, ".git")) || + File.Exists(Path.Combine(directory.FullName, ".git"))) && + File.Exists(Path.Combine(directory.FullName, "README.md"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new InvalidOperationException( + "Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path."); + } + + private static XDocument LoadManifest(params string[] relativePathSegments) + { + var path = Path.Combine(new[] { GetRepositoryRoot() }.Concat(relativePathSegments).ToArray()); + return XDocument.Load(path); + } + + // ---- Tray package ------------------------------------------------------ + + private static XDocument LoadTrayManifest() => + LoadManifest("src", "OpenClaw.Tray.WinUI", "Package.appxmanifest"); + + private static XElement GetApplication(XDocument doc, string id) => + doc.Descendants(XName.Get("Application", AppxFoundationNs)) + .Single(e => (string?)e.Attribute("Id") == id); + + private static string GetPropertyGroup(string project, string condition) + { + var marker = $""; + var start = project.IndexOf(marker, StringComparison.Ordinal); + Assert.True(start >= 0, $"PropertyGroup with condition {condition} not found."); + var end = project.IndexOf("", start, StringComparison.Ordinal); + Assert.True(end >= 0, $"PropertyGroup with condition {condition} is not closed."); + return project[start..(end + "".Length)]; + } + + [Fact] + public void Tray_CapabilitySet_IsExactlyTheAuditedList() + { + // Privacy / security review pin: adding a capability silently bypasses the + // capability-audit review and may also block sideload trust if the user + // rejects the new prompt. If you need a new capability, update both this + // test AND docs/SETUP.md in the same change so the privacy story stays + // truthful. + var doc = LoadTrayManifest(); + var caps = doc.Descendants(XName.Get("Capabilities", AppxFoundationNs)).Single(); + + var capabilityNames = caps.Elements(XName.Get("Capability", AppxFoundationNs)) + .Select(e => (string?)e.Attribute("Name")) + .Where(n => n != null) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + var deviceCapabilityNames = caps.Elements(XName.Get("DeviceCapability", AppxFoundationNs)) + .Select(e => (string?)e.Attribute("Name")) + .Where(n => n != null) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + var rescapNames = caps.Elements(XName.Get("Capability", AppxRescapNs)) + .Select(e => (string?)e.Attribute("Name")) + .Where(n => n != null) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(new[] { "internetClient" }, capabilityNames); + Assert.Equal(new[] { "location", "microphone", "webcam" }, deviceCapabilityNames); + Assert.Equal(new[] { "runFullTrust" }, rescapNames); + } + + [Fact] + public void Tray_DeclaresOpenclawProtocolExtension() + { + var doc = LoadTrayManifest(); + var protocol = doc.Descendants(XName.Get("Protocol", AppxUapNs)).SingleOrDefault(); + Assert.NotNull(protocol); + Assert.Equal("openclaw", (string?)protocol!.Attribute("Name")); + } + + private const string AppxComNs = "http://schemas.microsoft.com/appx/manifest/com/windows10"; + private const string AppxDesktopNs = "http://schemas.microsoft.com/appx/manifest/desktop/windows10"; + + [Fact] + public void Tray_DeclaresStartupTaskExtensionMatchingAutoStartManager() + { + // The TaskId here MUST match AutoStartManager.StartupTaskId. If you rename + // either, rename both — Windows StartupTask lookup is case-sensitive and + // silently returns DisabledByPolicy on mismatch (no exception), which would + // make the Settings toggle appear stuck off. + var doc = LoadTrayManifest(); + var startupTask = doc.Descendants(XName.Get("StartupTask", AppxUap5Ns)).SingleOrDefault(); + Assert.NotNull(startupTask); + Assert.Equal("OpenClawCompanionStartup", (string?)startupTask!.Attribute("TaskId")); + Assert.Equal("false", (string?)startupTask.Attribute("Enabled")); + + var autoStartManager = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Services", "AutoStartManager.cs")); + var app = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + var settingsPage = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Pages", "SettingsPage.xaml.cs")); + + Assert.Contains("Task SetAutoStartAsync", autoStartManager); + Assert.DoesNotContain("_ = SetAutoStartPackagedAsync", autoStartManager); + Assert.Contains("await AutoStartManager.SetAutoStartAsync", app); + Assert.Contains("var autoStartApplied = await AutoStartManager.SetAutoStartAsync", app); + Assert.Contains("AutoStartManager.SetAutoStart", settingsPage); + } + + [Fact] + public void Tray_DeclaresEmbeddedAppInstallerAndDeferredInUseUpdates() + { + var doc = LoadTrayManifest(); + + var ignorableNamespaces = (string?)doc.Root!.Attribute("IgnorableNamespaces") ?? string.Empty; + Assert.Contains("uap13", ignorableNamespaces.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + Assert.Contains("uap17", ignorableNamespaces.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + var appInstaller = doc.Descendants(XName.Get("AppInstaller", AppxUap13Ns)).SingleOrDefault(); + Assert.NotNull(appInstaller); + Assert.Equal("openclaw.appinstaller", (string?)appInstaller!.Attribute("File")); + + var updateWhileInUse = doc.Descendants(XName.Get("UpdateWhileInUse", AppxUap17Ns)).SingleOrDefault(); + Assert.NotNull(updateWhileInUse); + Assert.Equal("defer", updateWhileInUse!.Value); + + var project = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.csproj")); + Assert.Contains("openclaw.appinstaller", project); + Assert.Contains("ValidateEmbeddedAppInstaller", project); + Assert.Contains("MSIX packaging requires src\\OpenClaw.Tray.WinUI\\openclaw.appinstaller", project); + } + + [Fact] + public void Tray_MsixBuildUsesWindowsAppSdkFrameworkPackage() + { + var project = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.csproj")); + var unpackagedGroup = GetPropertyGroup(project, "'$(PackageMsix)' != 'true'"); + var packagedGroup = GetPropertyGroup(project, "'$(PackageMsix)' == 'true'"); + + Assert.Contains("None", unpackagedGroup); + Assert.Contains("true", unpackagedGroup); + Assert.Contains("MSIX", packagedGroup); + Assert.Contains("false", packagedGroup); + Assert.Contains("false", packagedGroup); + Assert.Contains("", project); + } + + [Fact] + public void Tray_PackagesSetupEngineUiForFirstRunSetup() + { + var project = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.csproj")); + var ci = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + ".github", "workflows", "ci.yml")); + var app = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + var setupProgram = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.SetupEngine.UI", "Program.cs")); + var setupProject = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.SetupEngine.UI", "OpenClaw.SetupEngine.UI.csproj")); + var doc = LoadTrayManifest(); + + Assert.DoesNotContain(doc.Descendants(XName.Get("Application", AppxFoundationNs)), + e => string.Equals((string?)e.Attribute("Id"), "SetupEngine", StringComparison.Ordinal)); + Assert.DoesNotContain(doc.Descendants(XName.Get("Extension", AppxDesktopNs)), + e => (string?)e.Attribute("Category") == "windows.fullTrustProcess"); + Assert.Contains("Content Include=\"SetupEngine\\**\\*\"", project); + Assert.Contains("PRIResource=\"false\"", project); + Assert.Contains("", project); + Assert.Contains("src/OpenClaw.Tray.WinUI/SetupEngine", ci); + Assert.Contains("Start-Process robocopy.exe", ci); + Assert.Contains("'*.xbf', 'OpenClaw.SetupEngine.UI.pri'", ci); + Assert.Contains("Inject SetupEngine XAML Resources", ci); + Assert.Contains("scripts/inject-setupengine-xaml-resources.ps1", ci); + Assert.Contains("dotnet publish src/OpenClaw.SetupEngine.UI", ci); + Assert.Contains("--self-contained", ci); + Assert.Contains("-p:PackageMsix=false", ci); + Assert.Contains("-p:WindowsAppSDKSelfContained=false", ci); + Assert.Contains("-p:WindowsAppSdkDeploymentManagerInitialize=false", ci); + Assert.Contains("-p:WindowsAppSdkBootstrapInitialize=false", ci); + Assert.Contains("Path.Combine(AppContext.BaseDirectory, \"SetupEngine\", exeName)", app); + Assert.Contains("Launched packaged SetupEngine.UI for setup", app); + Assert.Contains("new System.Diagnostics.ProcessStartInfo(setupExePath)", app); + Assert.Contains("UseShellExecute = false", app); + Assert.Contains("WorkingDirectory = Path.GetDirectoryName(setupExePath)", app); + Assert.Contains("ComWrappersSupport.InitializeComWrappers", setupProgram); + Assert.Contains("Application.Start", setupProgram); + Assert.DoesNotContain("setup-engine-startup.log", setupProgram); + Assert.DoesNotContain("RunWithXamlFactoryRetry", setupProgram); + Assert.DoesNotContain("Program.ApplicationStart.xamlFactoryUnavailable.retry", setupProgram); + Assert.Contains("WindowsAppRuntime_EnsureIsLoaded", setupProgram); + Assert.DoesNotContain("Bootstrap.Initialize", setupProgram); + Assert.DoesNotContain("Program.WindowsAppSdkBootstrap", setupProgram); + Assert.DoesNotContain("FreshPackageMinimumAge", setupProgram); + Assert.Contains("None", setupProject); + Assert.Contains("true", setupProject); + Assert.Contains("app.manifest", setupProject); + Assert.DoesNotContain("", setupProject); + Assert.DoesNotContain("OpenClaw.SetupEngine.UI.Program", setupProject); + Assert.Contains("CopyXamlResourcesToPublishDirectory", setupProject); + Assert.Contains("$(OutputPath)**\\*.xbf;$(OutputPath)*.pri", setupProject); + Assert.DoesNotContain("MSIX", setupProject); + Assert.DoesNotContain("false", setupProject); + } + + [Fact] + public void Tray_DeclaresToastNotificationActivationExtension() + { + // Without windows.toastNotificationActivation, MSIX packaged apps do NOT + // appear in Settings > Notifications until they fire a toast under package + // identity (and even then it can be delayed by several minutes). The pair + // of + registers the activator CLSID and + // makes the entry appear immediately on install. The two CLSIDs MUST + // match each other; this test pins both halves of the contract. + var doc = LoadTrayManifest(); + + var comClass = doc.Descendants(XName.Get("Class", AppxComNs)).SingleOrDefault(); + Assert.NotNull(comClass); + var comClassId = (string?)comClass!.Attribute("Id"); + var exeServer = doc.Descendants(XName.Get("ExeServer", AppxComNs)).SingleOrDefault(); + Assert.NotNull(exeServer); + + var toastActivation = doc.Descendants(XName.Get("ToastNotificationActivation", AppxDesktopNs)).SingleOrDefault(); + Assert.NotNull(toastActivation); + var toastClsid = (string?)toastActivation!.Attribute("ToastActivatorCLSID"); + + Assert.False(string.IsNullOrEmpty(comClassId), "COM class Id missing from manifest"); + Assert.False(string.IsNullOrEmpty(toastClsid), "ToastActivatorCLSID missing from manifest"); + Assert.Equal(comClassId, toastClsid); + Assert.Equal("-ToastActivator", (string?)exeServer!.Attribute("Arguments")); + + // Cold toast activator launches must continue through normal startup so + // toast clicks are not silent no-ops. The single-instance guard handles + // the already-running case. + var appXamlCs = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + Assert.Contains("new Mutex(true, mutexName, out bool createdNew)", appXamlCs); + Assert.DoesNotContain("Environment.Exit(0);", appXamlCs); + } + + [Fact] + public void Tray_SeedsNotificationSettingsWithSuppressedPackagedToast() + { + // Windows Settings > Notifications does not reliably show a packaged + // desktop app merely because the manifest declares a toast activator. + // The app must exercise the packaged toast channel once. This source + // contract pins a suppressed registration toast so first launch seeds + // Settings without showing a user-visible notification. + var appXamlCs = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); + var service = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Services", "NotificationSettingsRegistrationService.cs")); + + Assert.Contains("NotificationSettingsRegistrationService.EnsureRegistered();", appXamlCs); + Assert.Contains("ToastNotificationManagerCompat.CreateToastNotifier", service); + Assert.Contains("SuppressPopup = true", service); + Assert.Contains("RegistrationToastTag", service); + Assert.Contains("RegistrationToastGroup", service); + Assert.Contains("ToastNotificationManagerCompat.History.Remove", service); + } + + [Fact] + public void Tray_UsesGeneratedWinUiEntrypointWithoutStartupRetry() + { + var project = File.ReadAllText(Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.csproj")); + var programPath = Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Program.cs"); + + Assert.False(File.Exists(programPath), "Tray should use the generated WinUI entry point, not a startup retry wrapper."); + Assert.DoesNotContain("", project); + Assert.DoesNotContain("OpenClawTray.Program", project); + } + + [Fact] + public void Tray_AppListEntry_IsExplicitForInstallerLaunch() + { + var doc = LoadTrayManifest(); + var visualElements = GetApplication(doc, "App").Element(XName.Get("VisualElements", AppxUapNs)); + + Assert.NotNull(visualElements); + Assert.Equal("default", (string?)visualElements!.Attribute("AppListEntry")); + + Assert.Single(doc.Descendants(XName.Get("DefaultTile", AppxUapNs))); + } + + [Fact] + public void Tray_TileBackground_UsesTransparentAdaptiveColor() + { + var doc = LoadTrayManifest(); + var visualElements = GetApplication(doc, "App").Element(XName.Get("VisualElements", AppxUapNs)); + + Assert.NotNull(visualElements); + Assert.Equal("transparent", (string?)visualElements!.Attribute("BackgroundColor")); + } + + [Fact] + public void Tray_AppListIcon_HasMinimumUnplatedTargetSizes() + { + // These unplated assets keep Taskbar, Search, and Start using the icon + // without an extra contrast backplate. Windows Settings privacy pages + // still render their own system-accent tile behind packaged app icons. + var assetsDir = Path.Combine(GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Assets"); + + foreach (var size in new[] { 16, 20, 24, 32, 44, 48, 256 }) + { + var fileName = $"Square44x44Logo.targetsize-{size}_altform-unplated.png"; + Assert.True(File.Exists(Path.Combine(assetsDir, fileName)), + $"Missing unplated icon variant '{fileName}'. Taskbar, Search, and Start may fall back to a plated icon."); + } + } + + [Fact] + public void Tray_TargetDeviceFamily_IsDesktopOnly_OnSupportedFloor() + { + var doc = LoadTrayManifest(); + var families = doc.Descendants(XName.Get("TargetDeviceFamily", AppxFoundationNs)) + .Select(e => ((string?)e.Attribute("Name"), (string?)e.Attribute("MinVersion"))) + .ToArray(); + + Assert.Single(families); + Assert.Equal(("Windows.Desktop", "10.0.19041.0"), families[0]); + } + + [Fact] + public void Tray_Identity_PublisherStartsWithExpectedSubject() + { + // Publisher in the manifest must match the Azure Trusted Signing cert + // subject EXACTLY at build time. CI does not patch Publisher (only Name / + // Version), so any drift here ships as the published value. + var doc = LoadTrayManifest(); + var identity = doc.Descendants(XName.Get("Identity", AppxFoundationNs)).Single(); + var publisher = (string?)identity.Attribute("Publisher"); + Assert.NotNull(publisher); + Assert.StartsWith("CN=Scott Hanselman", publisher!); + } + + [Fact] + public void Tray_Identity_VersionIsFourPart() + { + // MSIX requires X.Y.Z.0 (4-part). CI re-patches this from the tag during + // release, but the repo-source value must already be a valid 4-part so + // local Release builds and ad-hoc msbuild invocations don't fail. + var doc = LoadTrayManifest(); + var version = (string?)doc.Descendants(XName.Get("Identity", AppxFoundationNs)).Single().Attribute("Version"); + Assert.NotNull(version); + var parts = version!.Split('.'); + Assert.Equal(4, parts.Length); + Assert.All(parts, p => Assert.True(int.TryParse(p, out _), $"Version segment '{p}' is not an integer")); + Assert.Equal("0", parts[3]); + } + + // ---- CommandPalette package ------------------------------------------- + + private static XDocument LoadCmdPalManifest() => + LoadManifest("src", "OpenClaw.CommandPalette", "Package.appxmanifest"); + + [Fact] + public void CmdPal_Identity_DoesNotShipMicrosoftPlaceholder() + { + // The VS extension template ships with Publisher=CN=Microsoft Corporation + // and PublisherDisplayName="A Lone Developer". Both are recipes for an + // unsigned-publisher install warning on user machines. CI patches them at + // build time; the repo-source values must already be safe defaults. + var doc = LoadCmdPalManifest(); + var identity = doc.Descendants(XName.Get("Identity", AppxFoundationNs)).Single(); + var publisher = (string?)identity.Attribute("Publisher"); + Assert.NotNull(publisher); + Assert.DoesNotContain("Microsoft Corporation", publisher!); + + var publisherDisplay = (string?)doc.Descendants(XName.Get("PublisherDisplayName", AppxFoundationNs)).Single(); + Assert.NotEqual("A Lone Developer", publisherDisplay); + } + + [Fact] + public void CmdPal_Identity_NameIsNamespacedUnderTrayPackage() + { + // Both packages ship under the same publisher; namespacing the cmdpal + // identity under the tray identity keeps the two visibly related in + // Get-AppxPackage output and prevents accidental name collisions with + // unrelated extensions in the user's package store. + var doc = LoadCmdPalManifest(); + var identityName = (string?)doc.Descendants(XName.Get("Identity", AppxFoundationNs)).Single().Attribute("Name"); + Assert.NotNull(identityName); + Assert.StartsWith("OpenClaw.Companion", identityName!); + } + + [Fact] + public void CmdPal_Identity_PublisherMatchesTrayPublisher() + { + // The same Azure Trusted Signing cert signs both packages; the manifests + // must declare the same Publisher subject or signing fails with an opaque + // "publisher mismatch" error. + var tray = LoadTrayManifest(); + var cmdpal = LoadCmdPalManifest(); + var trayPublisher = (string?)tray.Descendants(XName.Get("Identity", AppxFoundationNs)).Single().Attribute("Publisher"); + var cmdpalPublisher = (string?)cmdpal.Descendants(XName.Get("Identity", AppxFoundationNs)).Single().Attribute("Publisher"); + Assert.Equal(trayPublisher, cmdpalPublisher); + } + + [Fact] + public void CmdPal_DeclaresCommandPaletteAppExtension() + { + var doc = LoadCmdPalManifest(); + var appExt = doc.Descendants(XName.Get("AppExtension", AppxUap3Ns)).SingleOrDefault(); + Assert.NotNull(appExt); + Assert.Equal("com.microsoft.commandpalette", (string?)appExt!.Attribute("Name")); + } + + [Fact] + public void CmdPal_TargetDeviceFamily_IsDesktopOnly() + { + // The repo template included Windows.Universal which is meaningless for a + // Win32 cmdpal extension and forces unnecessary universal-app validation + // during signing. + var doc = LoadCmdPalManifest(); + var families = doc.Descendants(XName.Get("TargetDeviceFamily", AppxFoundationNs)) + .Select(e => (string?)e.Attribute("Name")) + .ToArray(); + Assert.Equal(new[] { "Windows.Desktop" }, families); + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 4f6fdd2f2..25acc7f7b 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -38,6 +38,7 @@ + diff --git a/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs b/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs new file mode 100644 index 000000000..168dd0264 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/OrphanPurgerContractTests.cs @@ -0,0 +1,151 @@ +using System; +using System.IO; + +namespace OpenClaw.Tray.Tests; + +/// +/// Pin the contract of the orphan-purger CLI without taking a compile-time +/// dependency on the WinNode CLI assembly. Real WSL / registry / file-system +/// probing is integration-test territory; the assertions here lock down the +/// public surface (orphan-kind constants, prefix, exit-code policy) so the +/// recovery CLI flag stays stable for the support recipe in +/// docs/uninstall-msix.md. +/// +/// Source-text assertions follow the same pattern as the historical +/// InstallerIssAssertionTests (now removed with Inno sunset) — Tray.Tests +/// is net10.0 and cannot transitively load the CLI's internal types, so we +/// pin the contract by reading the source. +/// +public sealed class OrphanPurgerContractTests +{ + private static string GetRepositoryRoot() + { + var envRepoRoot = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (!string.IsNullOrWhiteSpace(envRepoRoot) && Directory.Exists(envRepoRoot)) + return envRepoRoot; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if ((Directory.Exists(Path.Combine(directory.FullName, ".git")) || + File.Exists(Path.Combine(directory.FullName, ".git"))) && + File.Exists(Path.Combine(directory.FullName, "README.md"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new InvalidOperationException( + "Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path."); + } + + private static string LoadOrphanPurgerSource() => + File.ReadAllText(Path.Combine( + GetRepositoryRoot(), "src", "OpenClaw.WinNode.Cli", "OrphanPurger.cs")); + + [Fact] + public void OrphanWslDistroPrefix_IsTheOpenclawPrefix() + { + // Retained for backward compat with support/docs that mention the + // historical openclaw- prefix, even though destructive matching now + // uses an exact allow-list. + Assert.Contains("OrphanWslDistroPrefix = \"openclaw-\"", LoadOrphanPurgerSource()); + } + + [Fact] + public void WslDistroDetection_IsCaseInsensitive_Exact_AndCatchesKnownOwnedDistros() + { + // Regression: during MSIX-E2E manual test prep we found Mike's box + // had an OpenClawGateway (PascalCase, no dash) distro installed by + // the historical local-gateway flow. The original "openclaw-" + // case-sensitive prefix would silently miss it, meaning a user who + // ran --purge-wsl-orphans would be told "no orphans" while a 2.6 GB + // .vhdx orphan was still on disk. Pin the case-insensitive exact-name + // strategy so future refactors cannot drift back to destructive + // substring or prefix matching. + var src = LoadOrphanPurgerSource(); + Assert.Contains("OpenClawOwnedWslDistroNames", src); + Assert.Contains("LegacyOpenClawGatewayDistroName = \"OpenClawGateway\"", src); + Assert.Contains("\"openclaw-local\"", src); + Assert.Contains("\"openclaw-staging\"", src); + Assert.Contains("distroName.Equals(owned, StringComparison.OrdinalIgnoreCase)", src); + Assert.Contains("StringComparison.OrdinalIgnoreCase", src); + Assert.DoesNotContain("line.Contains(pattern", src); + Assert.DoesNotContain("StartsWith(OrphanWslDistroPrefix", src); + } + + [Fact] + public void DestructivePurge_IsBlockedWhenCompanionIsInstalledOrRunning() + { + var src = LoadOrphanPurgerSource(); + var program = File.ReadAllText(Path.Combine( + GetRepositoryRoot(), "src", "OpenClaw.WinNode.Cli", "Program.cs")); + + Assert.Contains("TryGetLiveInstallBlockReason", src); + Assert.Contains("IsTrayMutexPresent", src); + Assert.Contains("TryGetCompanionPackageInstalledForCurrentUser", src); + Assert.Contains("CompanionPackageNames", src); + Assert.Contains("OpenClaw.Companion.Alpha", src); + Assert.Contains("ForceEvenIfInstalledFlag = \"--force-even-if-installed\"", src); + Assert.Contains("forceEvenIfInstalled", program); + } + + [Fact] + public void WslUnregister_UsesArgumentListForDistroNames() + { + var src = LoadOrphanPurgerSource(); + Assert.Contains("psi.ArgumentList.Add(\"--unregister\")", src); + Assert.Contains("psi.ArgumentList.Add(distroName)", src); + Assert.DoesNotContain("$\"--unregister {distroName}\"", src); + } + + [Fact] + public void UriSchemeDetection_CoversBothCaseVariants() + { + // Same source as the WSL bug: the registry holds both + // HKCU\Software\Classes\openclaw AND HKCU\Software\Classes\OpenClaw + // simultaneously on some boxes; we have to enumerate both. + var src = LoadOrphanPurgerSource(); + Assert.Contains(@"Software\Classes\openclaw", src); + Assert.Contains(@"Software\Classes\OpenClaw", src); + Assert.Contains("OrphanUriSchemeKeys", src); + } + + [Theory] + [InlineData("\"wsl-distro\"", "WSL distro orphans")] + [InlineData("\"appdata-folder\"", "%APPDATA% orphans")] + [InlineData("\"localappdata-folder\"", "%LOCALAPPDATA% orphans")] + [InlineData("\"registry-uri-scheme\"", "openclaw:// URI scheme registration")] + [InlineData("\"registry-run-key\"", "HKCU Run autostart entry")] + public void OrphanKinds_AreAllReported(string kindLiteral, string reason) + { + // Every kind we promise to detect in docs/uninstall-msix.md must show + // up as a Kind on an OrphanItem somewhere in the source. If you remove + // one, update the doc table in the same change. + var src = LoadOrphanPurgerSource(); + Assert.True(src.Contains(kindLiteral), + $"Missing orphan kind {kindLiteral} (covers: {reason})"); + } + + [Fact] + public void ExitCodePolicy_IsDocumented() + { + // The exit-code mapping is the contract scripts and support docs key + // off. The wording is set in source comments; if the meanings change, + // re-check docs/uninstall-msix.md AND scripts/test-msix-install.ps1. + var src = LoadOrphanPurgerSource(); + Assert.Contains("if (failed.Count > 0) return 2;", src); + Assert.Contains("if (!confirmDestructive && orphans.Count > 0) return 1;", src); + Assert.Contains("return 0;", src); + } + + [Fact] + public void DryRunIsTheDefault() + { + // Pin: --purge-wsl-orphans without --confirm-destructive must NOT + // delete anything. Inverting this would surprise a support user who + // ran the diagnostic to "see what's there". + var src = LoadOrphanPurgerSource(); + Assert.Contains("if (confirmDestructive)", src); + } +} + diff --git a/tests/OpenClaw.Tray.Tests/SingleInstanceLaunchGuardTests.cs b/tests/OpenClaw.Tray.Tests/SingleInstanceLaunchGuardTests.cs new file mode 100644 index 000000000..4ea633c5f --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/SingleInstanceLaunchGuardTests.cs @@ -0,0 +1,145 @@ +using OpenClawTray.Services; +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace OpenClaw.Tray.Tests; + +public sealed class SingleInstanceLaunchGuardTests +{ + [Fact] + public void BuildMutexName_UsesStableDataDirSuffix() + { + var first = SingleInstanceLaunchGuard.BuildMutexName(@"C:\temp\openclaw-test"); + var second = SingleInstanceLaunchGuard.BuildMutexName(@"C:\temp\openclaw-test"); + + Assert.Equal(first, second); + Assert.StartsWith($"{SingleInstanceLaunchGuard.DefaultMutexName}-", first, StringComparison.Ordinal); + } + + [Fact] + public void Acquire_ReturnsAcquired_WhenMutexIsFree() + { + var result = AcquireUnique(retryWhenBusy: false); + + try + { + Assert.Equal(SingleInstanceLaunchGuard.AcquisitionStatus.Acquired, result.Status); + Assert.NotNull(result.Mutex); + Assert.Equal(0, result.Attempts); + } + finally + { + Release(result); + } + } + + [Fact] + public void Acquire_ReturnsAlreadyRunning_AndDoesNotKeepMutexAlive_WhenRetryIsDisabled() + { + var name = UniqueMutexName(); + var owner = new Mutex(true, name, out var createdNew); + Assert.True(createdNew); + + var busy = SingleInstanceLaunchGuard.Acquire( + name, + retryWhenBusy: false, + retryTimeout: TimeSpan.Zero, + retryDelay: TimeSpan.FromMilliseconds(1)); + + Assert.Equal(SingleInstanceLaunchGuard.AcquisitionStatus.AlreadyRunning, busy.Status); + Assert.Null(busy.Mutex); + + owner.ReleaseMutex(); + owner.Dispose(); + var afterRelease = SingleInstanceLaunchGuard.Acquire( + name, + retryWhenBusy: false, + retryTimeout: TimeSpan.Zero, + retryDelay: TimeSpan.FromMilliseconds(1)); + + try + { + Assert.Equal(SingleInstanceLaunchGuard.AcquisitionStatus.Acquired, afterRelease.Status); + Assert.NotNull(afterRelease.Mutex); + } + finally + { + Release(afterRelease); + } + } + + [Fact] + public async Task Acquire_RetriesUntilMutexOwnerExits() + { + var name = UniqueMutexName(); + using var ownerReady = new ManualResetEventSlim(false); + var owner = Task.Run(() => + { + using var mutex = new Mutex(true, name, out var createdNew); + Assert.True(createdNew); + ownerReady.Set(); + Thread.Sleep(75); + mutex.ReleaseMutex(); + }); + + Assert.True(ownerReady.Wait(TimeSpan.FromSeconds(1))); + + var result = SingleInstanceLaunchGuard.Acquire( + name, + retryWhenBusy: true, + retryTimeout: TimeSpan.FromSeconds(2), + retryDelay: TimeSpan.FromMilliseconds(10)); + + try + { + Assert.Equal(SingleInstanceLaunchGuard.AcquisitionStatus.AcquiredAfterWait, result.Status); + Assert.NotNull(result.Mutex); + Assert.True(result.Attempts > 0); + } + finally + { + Release(result); + await owner; + } + } + + [Fact] + public void Acquire_TimesOut_WhenMutexRemainsBusy() + { + var name = UniqueMutexName(); + using var owner = new Mutex(true, name, out var createdNew); + Assert.True(createdNew); + + var result = SingleInstanceLaunchGuard.Acquire( + name, + retryWhenBusy: true, + retryTimeout: TimeSpan.FromMilliseconds(50), + retryDelay: TimeSpan.FromMilliseconds(5)); + + Assert.Equal(SingleInstanceLaunchGuard.AcquisitionStatus.TimedOut, result.Status); + Assert.Null(result.Mutex); + Assert.True(result.Attempts > 0); + owner.ReleaseMutex(); + } + + private static SingleInstanceLaunchGuard.AcquisitionResult AcquireUnique(bool retryWhenBusy) + => SingleInstanceLaunchGuard.Acquire( + UniqueMutexName(), + retryWhenBusy, + retryTimeout: TimeSpan.FromMilliseconds(50), + retryDelay: TimeSpan.FromMilliseconds(5)); + + private static string UniqueMutexName() + => $"{SingleInstanceLaunchGuard.DefaultMutexName}.Tests.{Guid.NewGuid():N}"; + + private static void Release(SingleInstanceLaunchGuard.AcquisitionResult result) + { + if (result.Mutex is null) + return; + + result.Mutex.ReleaseMutex(); + result.Mutex.Dispose(); + } +} diff --git a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs index 6598f84c4..bb89da4c9 100644 --- a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs +++ b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs @@ -156,6 +156,7 @@ public void CommandPalette_HasTrayUtilityEntryPoints() Assert.Contains("Run Health Check", source); Assert.Contains(@"openclaw://check-updates", source); Assert.Contains("Check for Updates", source); + Assert.Contains("MSIX AppInstaller update feed", source); Assert.Contains(@"openclaw://logs", source); Assert.Contains("Open Log File", source); } @@ -202,6 +203,7 @@ public void CommandPalette_HasSupportDebugEntryPoints() Assert.Contains("Open Diagnostics Folder", source); Assert.Contains(@"openclaw://check-updates", source); Assert.Contains("Check for Updates", source); + Assert.Contains("MSIX AppInstaller update feed", source); Assert.Contains(@"openclaw://support-context", source); Assert.Contains("Copy Support Context", source); Assert.Contains(@"openclaw://debug-bundle", source);