diff --git a/.github/workflows/appinstaller-feed-pr.yml b/.github/workflows/appinstaller-feed-pr.yml new file mode 100644 index 000000000..82bcd9d6e --- /dev/null +++ b/.github/workflows/appinstaller-feed-pr.yml @@ -0,0 +1,166 @@ +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: main + 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=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, 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 "OpenClaw.Companion_${version}_x64.msix" + $arm64Asset = Get-RequiredAsset -Pattern "OpenClaw.Companion_${version}_arm64.msix" + $x64Uri = Get-ReleaseAssetUri -AssetName $x64Asset.name + $arm64Uri = Get-ReleaseAssetUri -AssetName $arm64Asset.name + + $rawBase = "https://raw.githubusercontent.com/$repo/main/installer/appinstaller" + $x64FeedPath = Join-Path $feedDir 'openclaw-x64.appinstaller' + $arm64FeedPath = Join-Path $feedDir 'openclaw-arm64.appinstaller' + + .\scripts\render-appinstaller.ps1 ` + -Version $version ` + -Publisher $publisher ` + -IdentityName $identityName ` + -ProcessorArchitecture x64 ` + -MsixUri $x64Uri ` + -AppInstallerUri "$rawBase/openclaw-x64.appinstaller" ` + -OutputPath $x64FeedPath + + .\scripts\render-appinstaller.ps1 ` + -Version $version ` + -Publisher $publisher ` + -IdentityName $identityName ` + -ProcessorArchitecture arm64 ` + -MsixUri $arm64Uri ` + -AppInstallerUri "$rawBase/openclaw-arm64.appinstaller" ` + -OutputPath $arm64FeedPath + + .\scripts\validate-appinstaller-hosting.ps1 ` + -AppInstallerPath $x64FeedPath ` + -MsixUri $x64Uri ` + -AllowGitHubContentTypes + .\scripts\validate-appinstaller-hosting.ps1 ` + -AppInstallerPath $arm64FeedPath ` + -MsixUri $arm64Uri ` + -AllowGitHubContentTypes + + $branch = "automation/appinstaller-feed-$($tag -replace '[^A-Za-z0-9._-]', '-')" + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git checkout -B $branch + git add $feedDir + + $changes = git status --short -- $feedDir + if ([string]::IsNullOrWhiteSpace($changes)) { + Write-Host "Stable AppInstaller feed already points at $tag; no PR needed." + exit 0 + } + + git commit -m "chore(msix): update AppInstaller feed for $tag" ` + -m "Advance the stable Windows AppInstaller feed to the signed MSIX assets from $tag." ` + -m "Merging this PR advances the auto-update source for installed MSIX clients." + git push --force-with-lease origin $branch + + $bodyPath = Join-Path $env:RUNNER_TEMP 'appinstaller-feed-pr.md' + $body = @" +Updates the stable Windows AppInstaller feed files for ``$tag``. + +Merging this PR advances installed MSIX clients that poll the stable feed: + +- x64 feed: ``$rawBase/openclaw-x64.appinstaller`` +- ARM64 feed: ``$rawBase/openclaw-arm64.appinstaller`` +- x64 MSIX: ``$x64Uri`` +- ARM64 MSIX: ``$arm64Uri`` + +Validation performed: + +- Rendered both feed files from ``scripts/render-appinstaller.ps1`` +- Parsed local AppInstaller XML before publishing +- Validated GitHub release MSIX headers with candidate GitHub content-type compatibility enabled +- Blocked pre-release/alpha feed updates until channel policy is decided + +Note: the MSIX is built with ``WindowsAppSDKSelfContained=true``, so the feed +intentionally omits any ```` block — Windows installs the runtime +bundled inside the .msix and never fetches a separate framework package. +"@ + Set-Content -Path $bodyPath -Value $body -Encoding UTF8 + + $existingPr = gh pr list --repo $repo --base main --head $branch --json number --jq '.[0].number' + if ([string]::IsNullOrWhiteSpace($existingPr)) { + gh pr create ` + --repo $repo ` + --base main ` + --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 ff4ce3087..eef1d98dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,7 +107,12 @@ jobs: uses: gittools/actions/gitversion/execute@v4 - name: Verify tag version output - if: startsWith(github.ref, 'refs/tags/v') + # TEMP: skip the strict tag/SemVer match for msixtest signing-pipeline + # rehearsal tags. GitVersion ignores the tag's prerelease label when it + # does not match the branch's auto-derived label, so on a feature branch + # `v0.6.4-msixtest.1` resolves to `0.6.4-.`. Revert + # the contains-check below before merging this branch to main. + if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, 'msixtest') shell: pwsh run: | $expected = "${{ github.ref_name }}" -replace '^v', '' @@ -354,79 +359,11 @@ jobs: TestResults/E2E/ if-no-files-found: warn - build: - needs: [test, e2etests] - runs-on: ${{ matrix.rid == 'win-arm64' && 'windows-11-arm' || 'windows-latest' }} - strategy: - matrix: - rid: [win-x64, win-arm64] - - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Setup .NET 10 - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 10.0.x - - - name: Cache NuGet packages - continue-on-error: true - uses: actions/cache@v5 - with: - path: ~/.nuget/packages - key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/Directory.Packages.props') }} - restore-keys: nuget-${{ runner.os }}- - - - name: Restore WinUI Tray App - run: dotnet restore src/OpenClaw.Tray.WinUI -r ${{ matrix.rid }} - - - name: Build WinUI Tray App (Release) - run: dotnet build src/OpenClaw.Tray.WinUI --no-restore -c Release -r ${{ matrix.rid }} - - - name: Publish WinUI Tray App - run: dotnet publish src/OpenClaw.Tray.WinUI -c Release -r ${{ matrix.rid }} --self-contained --no-restore -o publish - - - name: Verify x64 Native Runtime Payload - if: matrix.rid == 'win-x64' - shell: pwsh - run: .\scripts\Test-ReleaseNativeDependencies.ps1 -PayloadPath publish -RequireAppLocalVCRuntime - - - name: Verify ARM64 Native Runtime Payload - if: matrix.rid == 'win-arm64' - shell: pwsh - # -SkipNativeLoadProbe: an ARM64 runner CAN LoadLibrary ARM64 DLLs, but - # libsodium pulls in a long native dependency chain that may not all be - # in the payload here (WindowsAppSDK pieces, etc.). The probe is for x64 - # parity; signature + presence is what we actually care about. - run: .\scripts\Test-ReleaseNativeDependencies.ps1 -PayloadPath publish -RequireAppLocalVCRuntime -SkipNativeLoadProbe - - - name: Verify GitVersion assembly metadata - shell: pwsh - run: | - $expected = "${{ needs.test.outputs.semVer }}" - $assemblyPath = Resolve-Path "publish\OpenClaw.Tray.WinUI.dll" - $metadata = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($assemblyPath) - if ([string]::IsNullOrWhiteSpace($metadata.ProductVersion)) { - throw "OpenClaw.Tray.WinUI.dll is missing ProductVersion metadata." - } - $actual = $metadata.ProductVersion -replace '\+.*$', '' - if ($actual -ne $expected) { - throw "ProductVersion '$actual' did not match GitVersion SemVer '$expected'." - } - - - name: Upload Tray Artifact - uses: actions/upload-artifact@v7 - with: - name: openclaw-tray-${{ matrix.rid }} - path: publish/ - build-msix: - needs: [test, e2etests] - if: false # Paused for alpha.4; ship Inno setup and portable ZIP artifacts only. + # TEMP: needs:[test, e2etests] removed for faster iteration on this PR. + # Restore before merging — release job depends on all four. + needs: [] runs-on: ${{ matrix.rid == 'win-arm64' && 'windows-11-arm' || 'windows-latest' }} - continue-on-error: true strategy: fail-fast: false matrix: @@ -466,37 +403,58 @@ jobs: - name: Setup MSBuild uses: microsoft/setup-msbuild@v3 + - name: Install GitVersion + uses: gittools/actions/gitversion/setup@v4 + with: + versionSpec: '6.4.x' + + - name: Determine Version + id: gitversion + uses: gittools/actions/gitversion/execute@v4 + - name: Restore run: dotnet restore src/OpenClaw.Tray.WinUI -r ${{ matrix.rid }} - - name: Patch MSIX manifest metadata + - name: Determine release channel + id: channel shell: pwsh + # Map the tag pattern to one of Stable / Alpha / Dev. The csproj's + # SyncAppxManifestVersionTarget consumes -p:ReleaseChannel=... to pick + # Identity Name, DisplayName, and whether to embed an AppInstaller. + # + # TEMP rehearsal (revert-msixtest-as-alpha): msixtest tags map to Alpha + # so we can exercise the full Alpha-channel install + silent-update + # flow on this branch with throwaway tags. After merging to main, + # msixtest tags should fall through to Dev (their original purpose: + # signing-pipeline-only validation without auto-update). run: | - $version = "${{ needs.test.outputs.majorMinorPatch }}.0" - $isAlpha = "${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, '-') }}" -eq "true" - $identityName = if ($isAlpha) { "OpenClaw.Companion.Alpha" } else { "OpenClaw.Companion" } - $displayName = if ($isAlpha) { "OpenClaw Companion Alpha" } else { "OpenClaw Companion" } - $manifest = "src/OpenClaw.Tray.WinUI/Package.appxmanifest" - [xml]$xml = Get-Content $manifest - $xml.Package.Identity.Name = $identityName - $xml.Package.Identity.Version = $version - $xml.Package.Properties.DisplayName = $displayName - $xml.Package.Applications.Application.VisualElements.DisplayName = $displayName - $xml.Save((Resolve-Path $manifest)) - Write-Host "Patched MSIX manifest to identity $identityName, display name '$displayName', version $version" + $tag = "${{ github.ref_name }}" + $isTaggedRelease = "${{ startsWith(github.ref, 'refs/tags/v') }}" -eq "true" + if ($isTaggedRelease -and $tag -match '^v\d+\.\d+\.\d+$') { + $channel = 'Stable' + } elseif ($isTaggedRelease -and $tag -match '^v\d+\.\d+\.\d+-(alpha|msixtest)(\.\d+)?$') { + $channel = 'Alpha' + } else { + $channel = 'Dev' + } + Write-Host "Tag '$tag' -> ReleaseChannel '$channel'" + "channel=$channel" | Out-File -FilePath $env:GITHUB_OUTPUT -Append - name: Build MSIX Package run: > - msbuild src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj - /p:Configuration=Release - /p:RuntimeIdentifier=${{ matrix.rid }} - /p:Platform=${{ matrix.platform }} - /p:PackageMsix=true - /p:GenerateAppxPackageOnBuild=true - /p:AppxPackageSigningEnabled=false - /p:AppxBundle=Never - /p:UapAppxPackageBuildMode=SideloadOnly - /p:AppxPackageDir=AppPackages\ + dotnet publish src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj + -c Release + -r ${{ matrix.rid }} + --self-contained + -p:Platform=${{ matrix.platform }} + -p:PackageMsix=true + -p:GenerateAppxPackageOnBuild=true + -p:AppxPackageSigningEnabled=false + -p:AppxBundle=Never + -p:UapAppxPackageBuildMode=SideloadOnly + -p:AppxPackageDir=AppPackages\ + -p:ReleaseChannel=${{ steps.channel.outputs.channel }} + -p:ReleaseTag=${{ github.ref_name }} - name: Find MSIX Package id: find-msix @@ -511,6 +469,35 @@ jobs: echo "msix_path=$($msix.FullName)" >> $env:GITHUB_OUTPUT echo "msix_name=$($msix.Name)" >> $env:GITHUB_OUTPUT + - name: Verify MSIX Package Contents + shell: pwsh + # Extract the .msix (it's a zip) and validate the actual payload that + # ships to users: GitVersion metadata on the tray .dll and the libsodium + # / VC++ runtime dependencies required by NSec.Cryptography. + run: | + $msixPath = "${{ steps.find-msix.outputs.msix_path }}" + $extractDir = Join-Path $env:RUNNER_TEMP "msix-contents-${{ matrix.rid }}" + if (Test-Path $extractDir) { Remove-Item -Recurse -Force $extractDir } + Expand-Archive -Path $msixPath -DestinationPath $extractDir -Force + + $assemblyPath = Join-Path $extractDir "OpenClaw.Tray.WinUI.dll" + if (-not (Test-Path $assemblyPath)) { + throw "OpenClaw.Tray.WinUI.dll missing from MSIX payload at $assemblyPath" + } + $expected = "${{ steps.gitversion.outputs.semVer }}" + $metadata = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($assemblyPath) + if ([string]::IsNullOrWhiteSpace($metadata.ProductVersion)) { + throw "OpenClaw.Tray.WinUI.dll inside MSIX is missing ProductVersion metadata." + } + $actual = $metadata.ProductVersion -replace '\+.*$', '' + if ($actual -ne $expected) { + throw "MSIX-internal ProductVersion '$actual' did not match GitVersion SemVer '$expected'." + } + + $skipProbeArgs = @{} + if ("${{ matrix.rid }}" -eq "win-arm64") { $skipProbeArgs["SkipNativeLoadProbe"] = $true } + & .\scripts\Test-ReleaseNativeDependencies.ps1 -PayloadPath $extractDir -RequireAppLocalVCRuntime @skipProbeArgs + - name: Upload MSIX Artifact uses: actions/upload-artifact@v7 with: @@ -518,8 +505,8 @@ jobs: path: ${{ steps.find-msix.outputs.msix_path }} release: - needs: [repo-hygiene, test, e2etests, build] - if: startsWith(github.ref, 'refs/tags/v') && needs.repo-hygiene.result == 'success' && needs.test.result == 'success' && needs.e2etests.result == 'success' && needs.build.result == 'success' && !cancelled() + needs: [repo-hygiene, test, e2etests, build-msix] + if: startsWith(github.ref, 'refs/tags/v') && needs.repo-hygiene.result == 'success' && needs.test.result == 'success' && needs.e2etests.result == 'success' && needs.build-msix.result == 'success' && !cancelled() runs-on: windows-latest environment: release-signing permissions: @@ -530,17 +517,17 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Download win-x64 tray artifact + - name: Download win-x64 MSIX artifact uses: actions/download-artifact@v8 with: - name: openclaw-tray-win-x64 - path: artifacts/tray-win-x64 + name: openclaw-msix-win-x64 + path: artifacts/msix-win-x64 - - name: Download win-arm64 tray artifact + - name: Download win-arm64 MSIX artifact uses: actions/download-artifact@v8 with: - name: openclaw-tray-win-arm64 - path: artifacts/tray-win-arm64 + name: openclaw-msix-win-arm64 + path: artifacts/msix-win-arm64 - name: Disable NuGet source mapping for signing shell: pwsh @@ -557,164 +544,111 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - name: Stage x64 OpenClaw Executables for Signing - shell: pwsh - run: | - New-Item -ItemType Directory -Path signing-input-x64 -Force | Out-Null - New-Item -ItemType HardLink -Path signing-input-x64\OpenClaw.Tray.WinUI.exe -Target artifacts\tray-win-x64\OpenClaw.Tray.WinUI.exe | Out-Null - - - name: Stage ARM64 OpenClaw Executables for Signing - shell: pwsh - run: | - New-Item -ItemType Directory -Path signing-input-arm64 -Force | Out-Null - New-Item -ItemType HardLink -Path signing-input-arm64\OpenClaw.Tray.WinUI.exe -Target artifacts\tray-win-arm64\OpenClaw.Tray.WinUI.exe | Out-Null - - - name: Sign x64 OpenClaw Executables + - name: Sign x64 Release MSIX Package uses: azure/artifact-signing-action@v2 with: endpoint: https://eus.codesigning.azure.net/ signing-account-name: openclaw certificate-profile-name: openclaw - files-folder: signing-input-x64 - files-folder-filter: exe + files-folder: artifacts/msix-win-x64 + files-folder-filter: msix files-folder-depth: 1 file-digest: SHA256 timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 - - name: Sign ARM64 OpenClaw Executables + - name: Sign ARM64 Release MSIX Package uses: azure/artifact-signing-action@v2 with: endpoint: https://eus.codesigning.azure.net/ signing-account-name: openclaw certificate-profile-name: openclaw - files-folder: signing-input-arm64 - files-folder-filter: exe + files-folder: artifacts/msix-win-arm64 + files-folder-filter: msix files-folder-depth: 1 file-digest: SHA256 timestamp-rfc3161: http://timestamp.acs.microsoft.com timestamp-digest: SHA256 - - name: Verify x64 Release Executable Signing Policy - shell: pwsh - run: .\scripts\Test-ReleaseExecutableSignatures.ps1 -PayloadPath artifacts/tray-win-x64 -RequireSignedOpenClaw - - - name: Verify ARM64 Release Executable Signing Policy - shell: pwsh - run: .\scripts\Test-ReleaseExecutableSignatures.ps1 -PayloadPath artifacts/tray-win-arm64 -RequireSignedOpenClaw - - - name: Verify x64 Release Native Dependencies - shell: pwsh - run: .\scripts\Test-ReleaseNativeDependencies.ps1 -PayloadPath artifacts/tray-win-x64 -RequireAppLocalVCRuntime - - - name: Verify ARM64 Release Native Dependencies - shell: pwsh - # -SkipNativeLoadProbe: this release job runs on the x64 windows-latest - # runner and cannot LoadLibrary an ARM64 DLL. The signature and presence - # checks still run. - run: .\scripts\Test-ReleaseNativeDependencies.ps1 -PayloadPath artifacts/tray-win-arm64 -RequireAppLocalVCRuntime -SkipNativeLoadProbe - - - name: Download Visual C++ Runtime Redistributables - shell: pwsh - run: | - $redists = @( - @{ Uri = "https://aka.ms/vc14/vc_redist.x64.exe"; Path = "vc_redist.x64.exe" }, - @{ Uri = "https://aka.ms/vc14/vc_redist.arm64.exe"; Path = "vc_redist.arm64.exe" } - ) - - foreach ($redist in $redists) { - Invoke-WebRequest -Uri $redist.Uri -OutFile $redist.Path - $signature = Get-AuthenticodeSignature -LiteralPath $redist.Path - if ($signature.Status -ne "Valid") { - throw "$($redist.Path) Authenticode signature was $($signature.Status)." - } - $subject = if ($signature.SignerCertificate) { $signature.SignerCertificate.Subject } else { "" } - if ($subject -notmatch "O=Microsoft Corporation") { - throw "$($redist.Path) signer was not Microsoft Corporation: $subject" - } - } - - # Create ZIP files for Updatum auto-update (asset name must contain the RID). - # We ship both x64 and arm64 portables now that the build job produces a - # libsodium-compatible app-local VC runtime for both architectures (the - # arm64 leg sources its loose Microsoft.VC*.CRT DLLs from the VS install - # on the windows-11-arm runner; see src/Directory.Build.targets). - - name: Create x64 Release ZIP - run: | - Compress-Archive -Path artifacts/tray-win-x64/* -DestinationPath OpenClawTray-${{ needs.test.outputs.semVer }}-win-x64.zip - - - name: Create arm64 Release ZIP - run: | - Compress-Archive -Path artifacts/tray-win-arm64/* -DestinationPath OpenClawTray-${{ needs.test.outputs.semVer }}-win-arm64.zip - - # Inno Setup installer for x64 - - name: Install Inno Setup - run: choco install innosetup -y + # TEMP: GitHub Release creation is constrained while we validate the + # signing + AppInstaller auto-update pipeline. + # - For msixtest rehearsal tags (vX.Y.Z-msixtest.N): publish a real + # prerelease GitHub Release so AppInstaller can fetch the signed .msix + # from a stable HTTPS URL. AppInstaller cannot read GitHub workflow + # artifacts (they require auth). + # - For any other tag: do NOT publish a Release; just upload as workflow + # artifacts. We do not want to publish a production Release from this + # feature branch by mistake. + # Restore the unconditional "Create Release" behavior before merging. + - name: Upload Signed x64 MSIX (TEMP) + uses: actions/upload-artifact@v7 + with: + name: openclaw-msix-signed-win-x64 + path: artifacts/msix-win-x64/*.msix + if-no-files-found: error + overwrite: true - - name: Build x64 Installer - run: | - # Prepare x64 files - mkdir publish-x64 - copy artifacts/tray-win-x64/* publish-x64/ -Recurse - .\scripts\Test-ReleaseNativeDependencies.ps1 -PayloadPath publish-x64 -RequireAppLocalVCRuntime -RequireInstallerVCRedist -InstallerVCRedistPath vc_redist.x64.exe - # Build installer - & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DMyAppVersion=${{ needs.test.outputs.majorMinorPatch }} /DMyAppArch=x64 /Dpublish=publish-x64 /DvcRedist=vc_redist.x64.exe installer.iss - - - name: Build arm64 Installer - run: | - # Prepare arm64 files - mkdir publish-arm64 - copy artifacts/tray-win-arm64/* publish-arm64/ -Recurse - # -RequireAppLocalVCRuntime: arm64 payload now ships VC runtime DLLs from the build job. - # -SkipNativeLoadProbe: this verifier runs on the x64 release runner and cannot - # LoadLibrary an arm64 DLL. - .\scripts\Test-ReleaseNativeDependencies.ps1 -PayloadPath publish-arm64 -RequireAppLocalVCRuntime -RequireInstallerVCRedist -InstallerVCRedistPath vc_redist.arm64.exe -SkipNativeLoadProbe - # Build installer - & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DMyAppVersion=${{ needs.test.outputs.majorMinorPatch }} /DMyAppArch=arm64 /Dpublish=publish-arm64 /DvcRedist=vc_redist.arm64.exe installer.iss - - - name: Sign Installers - uses: azure/artifact-signing-action@v2 + - name: Upload Signed ARM64 MSIX (TEMP) + uses: actions/upload-artifact@v7 with: - endpoint: https://eus.codesigning.azure.net/ - signing-account-name: openclaw - certificate-profile-name: openclaw - files-folder: Output - files-folder-filter: exe - file-digest: SHA256 - timestamp-rfc3161: http://timestamp.acs.microsoft.com - timestamp-digest: SHA256 + name: openclaw-msix-signed-win-arm64 + path: artifacts/msix-win-arm64/*.msix + if-no-files-found: error + overwrite: true - - name: Create Release + - name: Create Prerelease (TEMP - msixtest tags only) + if: ${{ contains(github.ref_name, 'msixtest') }} uses: softprops/action-gh-release@v3 with: - generate_release_notes: true + prerelease: true + make_latest: false files: | - Output/OpenClawCompanion-Setup-x64.exe - Output/OpenClawCompanion-Setup-arm64.exe - OpenClawTray-${{ needs.test.outputs.semVer }}-win-x64.zip - OpenClawTray-${{ needs.test.outputs.semVer }}-win-arm64.zip - prerelease: ${{ contains(github.ref_name, '-') }} - make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }} + artifacts/msix-win-x64/*.msix + artifacts/msix-win-arm64/*.msix body: | ## OpenClaw Windows Hub ${{ github.ref_name }} - ### Downloads - - **Installer (x64)**: `OpenClawCompanion-Setup-x64.exe` - Intel/AMD 64-bit - - **Installer (ARM64)**: `OpenClawCompanion-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` - - ### Features - - 🦞 System tray integration with gateway status - - 🔄 Auto-updates from GitHub Releases - - ✅ Code-signed with Azure Artifact Signing - - ### Requirements - - Windows 10 version 1903 or later - - [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) - - OpenClaw gateway running locally - - ### Quick Start - 1. Run the installer for your architecture - 2. Launch from Start Menu or system tray - 3. Right-click tray icon → Settings to configure + **THROWAWAY TEST RELEASE — DO NOT INSTALL.** + + This release exists only to validate the Azure Trusted Signing leg + of the release pipeline and the AppInstaller silent-update flow on + the `user/kmahone/msix` branch. It will be deleted along with its + tag once testing is complete. + + The signed `.msix` files attached below are addressed by the + temporary AppInstaller feed at + `installer/appinstaller/openclaw-msixtest-x64.appinstaller` on the + branch. + + # TEMP REVERT: re-enable this step (and remove the two TEMP blocks above) + # before merging. + # - name: Create Release + # uses: softprops/action-gh-release@v3 + # with: + # generate_release_notes: true + # prerelease: ${{ contains(github.ref_name, '-') }} + # make_latest: ${{ contains(github.ref_name, '-') && 'false' || 'true' }} + # files: | + # artifacts/msix-win-x64/*.msix + # artifacts/msix-win-arm64/*.msix + # body: | + # ## OpenClaw Windows Hub ${{ github.ref_name }} + # + # ### Install + # + # OpenClaw is distributed as an MSIX package via Windows AppInstaller. + # Use the install links in the [README](https://github.com/openclaw/openclaw-windows-node#install) + # for the recommended path — Windows handles silent auto-updates from there. + # + # The `.msix` files attached below are the underlying signed packages. + # Advanced users can install them directly with `Add-AppxPackage `. + # + # ### Features + # - 🦞 System tray integration with gateway status + # - ✅ Code-signed with Azure Artifact Signing + # - 🔄 Silent auto-updates via Windows AppInstaller + # + # ### Requirements + # - Windows 10 version 1903 or later + # - [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) + # - OpenClaw gateway running locally diff --git a/.gitignore b/.gitignore index 97d6a0cbc..06802f77d 100644 --- a/.gitignore +++ b/.gitignore @@ -361,3 +361,7 @@ visual-test-output/ msix-validation-evidence/ packaging-test-output/ uninstall-validation-output/ + +# Generated by RenderEmbeddedAppInstaller MSBuild target for Stable/Alpha MSIXs; +# committed sources live in installer/appinstaller/openclaw-update.{stable,alpha}.appinstaller. +src/OpenClaw.Tray.WinUI/openclaw-update.appinstaller diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f7f93754c..3f435b11c 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -169,23 +169,6 @@ dotnet publish src/OpenClaw.Tray.WinUI -c Release -r win-x64 --self-contained -o This creates a standalone executable with all dependencies bundled. -#### Local Inno Installer Iteration - -Use the local helper to build unsigned installer EXEs without waiting for CI: - -```powershell -# Fast x64 installer for Windows Sandbox smoke tests -.\scripts\build-inno-local.ps1 -Arch x64 -Fast - -# Recompile Inno only after changing installer.iss -.\scripts\build-inno-local.ps1 -Arch x64 -Fast -NoPublish - -# Build both release-shaped architectures locally -.\scripts\build-inno-local.ps1 -Arch All -``` - -`-Fast` uses ZIP/no-solid compression for quick local iteration. CI release builds keep the default LZMA solid compression and Azure signing. - ## Architecture Overview ### Native chat surface (FunctionalUI + OpenClaw.Chat) @@ -577,15 +560,11 @@ When a tag is pushed (e.g., `git tag v1.2.3 && git push origin v1.2.3`): - All artifacts built for x64 and ARM64 - Executables signed with Azure Trusted Signing certificate -2. **Create Installers:** - - Inno Setup creates Windows installers - - Separate installers for x64 and ARM64 - -3. **GitHub Release:** +2. **GitHub Release:** - Automatic release created with tag name - - Includes: - - Installers: `OpenClawCompanion-Setup-x64.exe`, `OpenClawCompanion-Setup-arm64.exe` - - Portable ZIPs: `OpenClawTray-{version}-win-x64.zip`, `OpenClawTray-{version}-win-arm64.zip` + - Includes portable ZIPs: `OpenClawTray-{version}-win-x64.zip`, `OpenClawTray-{version}-win-arm64.zip` + - MSIX installer artifacts will be added once the MSIX-primary distribution + pipeline lands (see the MSIX-primary publishing plan). - Release notes auto-generated from commits ### Monitoring CI diff --git a/README.md b/README.md index a2d4870d7..6da61fb2e 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,18 @@ This monorepo contains the Windows hub, shared client libraries, and CLI utiliti > > **Managed WSL gateway?** Local setup creates a locked-down app-owned `OpenClawGateway` distro. See [docs/WSL_GATEWAY_ADMIN.md](docs/WSL_GATEWAY_ADMIN.md) for editing `openclaw.json` as the `openclaw` user and using root for protected-file administration. -Direct downloads from the latest OpenClaw release: +Install via Windows AppInstaller (auto-updates from the stable feed): -- [OpenClawCompanion-Setup-x64.exe](https://github.com/openclaw/openclaw/releases/latest/download/OpenClawCompanion-Setup-x64.exe) -- [OpenClawCompanion-Setup-arm64.exe](https://github.com/openclaw/openclaw/releases/latest/download/OpenClawCompanion-Setup-arm64.exe) -- [OpenClawCompanion-SHA256SUMS.txt](https://github.com/openclaw/openclaw/releases/latest/download/OpenClawCompanion-SHA256SUMS.txt) +- **Install (x64)** — [openclaw-x64.appinstaller](https://raw.githubusercontent.com/openclaw/openclaw-windows-node/main/installer/appinstaller/openclaw-x64.appinstaller) +- **Install (ARM64)** — [openclaw-arm64.appinstaller](https://raw.githubusercontent.com/openclaw/openclaw-windows-node/main/installer/appinstaller/openclaw-arm64.appinstaller) + +Click the link for your machine architecture; Windows opens the App Installer +UI, prompts for consent, then installs the signed MSIX. Future updates are +delivered automatically by Windows via the same feed URL — no in-app "Check +for updates" button needed. + +> See [docs/SETUP.md](docs/SETUP.md) for step-by-step guidance and what to do +> if the install link opens as plain text in your browser. ### Prerequisites - Windows 10 (20H2+) or Windows 11 @@ -74,8 +81,12 @@ dotnet build src/OpenClaw.Tray.WinUI -r win-x64 -p:PackageMsix=true # x64 MSI ### Run Tray App +The tray always runs as a packaged WinUI app in development. `run-app-local.ps1` +uses Microsoft WinAppCLI (`winget install Microsoft.WinAppCLI`) to activate the +build output as a packaged loose layout — no `.msix` file is required. + ```powershell -# Build and launch the unpackaged WinUI tray app +# Build and launch the tray app (packaged loose layout via winapp) .\run-app-local.ps1 # If you already built, skip rebuild and launch the existing Debug output @@ -86,15 +97,8 @@ dotnet build src/OpenClaw.Tray.WinUI -r win-x64 -p:PackageMsix=true # x64 MSI # Alpha update testing from a Release build .\run-app-local.ps1 -Configuration Release -Isolated -UpdateChannel alpha - -# Optional: launch through WinAppCLI with Package.appxmanifest -.\run-app-local.ps1 -UseWinApp -NoBuild ``` -The default path starts the unpackaged executable directly. `-UseWinApp` requires -Microsoft WinAppCLI (`winget install Microsoft.WinAppCLI`) and is only needed when -you want manifest/MSIX-adjacent launch validation. - ### Run CLI WebSocket Validator Use the CLI to validate gateway connectivity and `chat.send` outside the tray UI. @@ -340,7 +344,6 @@ OpenClaw registers the `openclaw://` URL scheme for automation and integration: | `openclaw://dashboard/skills` | Open Skills dashboard page | | `openclaw://dashboard/cron` | Open Cron dashboard page | | `openclaw://healthcheck` | Run a manual health check | -| `openclaw://check-updates` | Run a manual update check | | `openclaw://logs` | Open the current tray log file | | `openclaw://log-folder` | Open the logs folder | | `openclaw://config` | Open the config folder | diff --git a/build.ps1 b/build.ps1 index 9426ebeda..fd54edec3 100644 --- a/build.ps1 +++ b/build.ps1 @@ -21,9 +21,44 @@ cannot read a repo owned by a different Windows account/group. The script will print the manual command instead. +.PARAMETER PackageMsix + In addition to the always-packaged loose-layout build, produce a .msix + package file in src/OpenClaw.Tray.WinUI/AppPackages/. Requires the + OpenClaw.Tray.WinUI project to be in the build set (Project=All, Tray, or + WinUI). Requires %LOCALAPPDATA%\OpenClawTray\dev-msix.pfx (run + scripts\setup-dev-msix-cert.ps1 once to create it); the build hard-fails + if the cert is missing because MSIX packages cannot be installed unsigned + on stock Windows (Add-AppxPackage -AllowUnsigned only works under very + specific developer-mode conditions that do not cover this package). + +.PARAMETER ReleaseChannel + Which MSIX release channel to build (only meaningful with -PackageMsix): + + Dev - Local builds. Identity OpenClaw.Companion.Dev, no embedded + AppInstaller, no auto-update. Default for -PackageMsix. + + Alpha - Tester builds. Identity OpenClaw.Companion.Alpha, embedded + AppInstaller pointing at the alpha polled feed on main. CI uses + this for tags matching vX.Y.Z-alpha.N. + + Stable - Production builds. Identity OpenClaw.Companion, embedded + AppInstaller pointing at the stable polled feed on main. CI uses + this for tags matching vX.Y.Z. + + Stable and Alpha require -ReleaseTag so the embedded AppInstaller's + MainPackage Uri can reference the matching GitHub release. + +.PARAMETER ReleaseTag + Git tag corresponding to this MSIX, e.g. v0.6.4 or v0.6.4-alpha.1. Required + when -ReleaseChannel is Stable or Alpha. Used by the embedded AppInstaller's + MainPackage Uri to reference the right GitHub release asset. + .EXAMPLE .\build.ps1 .\build.ps1 -Project WinUI -Configuration Release + .\build.ps1 -Project WinUI -PackageMsix + .\build.ps1 -Project WinUI -PackageMsix -ReleaseChannel Alpha -ReleaseTag v0.6.4-alpha.1 + .\build.ps1 -Project WinUI -PackageMsix -ReleaseChannel Stable -ReleaseTag v0.6.4 .\build.ps1 -CheckOnly #> @@ -36,7 +71,14 @@ param( [switch]$CheckOnly, - [switch]$NoTrustRepository + [switch]$NoTrustRepository, + + [switch]$PackageMsix, + + [ValidateSet("Dev", "Alpha", "Stable")] + [string]$ReleaseChannel = "Dev", + + [string]$ReleaseTag ) $ErrorActionPreference = "Stop" @@ -297,7 +339,7 @@ function Invoke-DotNetCaptured($arguments) { } } -function Build-Project($name, $path, $useRid = $false) { +function Build-Project($name, $path, $useRid = $false, $publishMsix = $false) { Write-Host "`nBuilding $name..." -ForegroundColor White if (-not (Test-Path $path)) { @@ -305,10 +347,29 @@ function Build-Project($name, $path, $useRid = $false) { return $false } - $dotnetArgs = @("build", $path, "-c", $Configuration) - # WinUI requires runtime identifier for self-contained WebView2 support - if ($useRid) { - $dotnetArgs += @("-r", $rid) + if ($publishMsix) { + # MSIX file production: dotnet publish so the self-contained layout MSIX + # tooling packages matches what end-users install. -p:PackageMsix=true + # turns on GenerateAppxPackageOnBuild in the csproj. The csproj's + # SyncAppxManifestVersionTarget reads $(ReleaseChannel) to pick the + # Identity Name / DisplayName / embedded-AppInstaller behavior; default + # Dev produces an OpenClaw.Companion.Dev MSIX with no auto-update. + $dotnetArgs = @( + "publish", $path, + "-c", $Configuration, + "-r", $rid, + "--self-contained", + "-p:PackageMsix=true", + "-p:ReleaseChannel=$ReleaseChannel" + ) + if ($ReleaseTag) { + $dotnetArgs += "-p:ReleaseTag=$ReleaseTag" + } + } elseif ($useRid) { + # WinUI requires runtime identifier for self-contained WebView2 support. + $dotnetArgs = @("build", $path, "-c", $Configuration, "-r", $rid) + } else { + $dotnetArgs = @("build", $path, "-c", $Configuration) } $result = Invoke-DotNetCaptured $dotnetArgs $exitCode = $LASTEXITCODE @@ -369,11 +430,48 @@ if ($Project -ne "Shared" -and $Project -ne "All" -and $toBuild -notcontains "Sh $toBuild = @("Shared") + $toBuild } +# -PackageMsix preflight: must include WinUI/Tray and dev signing cert must +# exist. Unsigned MSIX packages cannot be installed on stock Windows, so a +# missing cert is a hard fail rather than a warning. +if ($PackageMsix) { + $winUITargetIncluded = ($toBuild -contains "WinUI") -or ($toBuild -contains "Tray") + if (-not $winUITargetIncluded) { + Write-Error "-PackageMsix requires -Project All, Tray, or WinUI (current: $Project)" + exit 1 + } + + # Stable and Alpha embed an AppInstaller that references a specific GitHub + # release asset — without a tag we can't compute that URI. + if ($ReleaseChannel -in @("Stable", "Alpha") -and -not $ReleaseTag) { + Write-Error "-ReleaseChannel $ReleaseChannel requires -ReleaseTag (e.g. v0.6.4-alpha.1 or v0.6.4)" + exit 1 + } + if ($ReleaseChannel -eq "Dev" -and $ReleaseTag) { + Write-Warning "-ReleaseTag '$ReleaseTag' is ignored for Dev channel (Dev MSIXs do not embed an AppInstaller)" + } + Write-Info "MSIX channel: $ReleaseChannel$(if ($ReleaseTag) { " (release tag: $ReleaseTag)" })" + + $devPfx = Join-Path $env:LOCALAPPDATA "OpenClawTray\dev-msix.pfx" + if (Test-Path $devPfx) { + Write-Success "Dev MSIX signing cert found: $devPfx" + } else { + Write-Error "Dev MSIX signing cert not found at $devPfx" + Write-Host "" + Write-Host "To create the dev signing cert, run (elevated):" -ForegroundColor Cyan + Write-Host " .\scripts\setup-dev-msix-cert.ps1" -ForegroundColor White + exit 1 + } +} elseif ($ReleaseChannel -ne "Dev" -or $ReleaseTag) { + Write-Warning "-ReleaseChannel/-ReleaseTag are only meaningful with -PackageMsix; ignoring." +} + for ($i = 0; $i -lt $toBuild.Count; $i++) { $proj = $toBuild[$i] if ($projects.ContainsKey($proj)) { $projInfo = $projects[$proj] - $buildResults[$proj] = Build-Project $proj $projInfo.Path $projInfo.UseRid + $isWinUI = ($proj -eq "WinUI" -or $proj -eq "Tray") + $shouldPackageMsix = $PackageMsix -and $isWinUI + $buildResults[$proj] = Build-Project $proj $projInfo.Path $projInfo.UseRid $shouldPackageMsix if ($proj -eq "Shared" -and -not $buildResults[$proj] -and $i -lt ($toBuild.Count - 1)) { Write-Warning "Skipping remaining projects because Shared failed." break @@ -409,11 +507,27 @@ if ($failCount -eq 0) { $winUIManifestPath = ".\$winUIProjectDirectory\Package.appxmanifest" Write-Host " WinUI: .\run-app-local.ps1 -NoBuild" -ForegroundColor White Write-Host " Isolated: .\run-app-local.ps1 -NoBuild -Isolated" -ForegroundColor White - Write-Host " WinApp: .\run-app-local.ps1 -NoBuild -UseWinApp" -ForegroundColor White - Write-Host " Direct launch is default. -UseWinApp runs: winapp run `"$winUIOutputDirectory`" --manifest `"$winUIManifestPath`" --executable `"OpenClaw.Tray.WinUI.exe`" --debug-output" -ForegroundColor DarkGray + Write-Host " Runs: winapp run `"$winUIOutputDirectory`" --manifest `"$winUIManifestPath`" --executable `"OpenClaw.Tray.WinUI.exe`" --debug-output" -ForegroundColor DarkGray } else { Write-Warning "Unable to determine WinUI target framework from $winUIProjectPath" } + + if ($PackageMsix) { + $appPackagesDir = Join-Path $winUIProjectDirectory "AppPackages" + $producedMsix = $null + if (Test-Path $appPackagesDir) { + $producedMsix = Get-ChildItem -Path $appPackagesDir -Recurse -Filter "*.msix" -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | Select-Object -First 1 + } + + Write-Host "`nMSIX:" -ForegroundColor Cyan + if ($producedMsix) { + Write-Host " Path: $($producedMsix.FullName)" -ForegroundColor White + Write-Host " Install: Add-AppxPackage -Path `"$($producedMsix.FullName)`"" -ForegroundColor White + } else { + Write-Warning "Could not locate produced .msix under $appPackagesDir" + } + } } } else { Write-Host "❌ $failCount build(s) failed" -ForegroundColor Red diff --git a/docs/RELEASING.md b/docs/RELEASING.md index ee587e170..56a652fa1 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -73,18 +73,9 @@ git tag -a vX.Y.Z-alpha.N -m "OpenClaw Windows Hub vX.Y.Z-alpha.N" git push origin vX.Y.Z-alpha.N ``` -For the current alpha flow, ship only: - -- Inno setup installers: - - `OpenClawCompanion-Setup-x64.exe` - - `OpenClawCompanion-Setup-arm64.exe` -- Portable ZIP payloads for Updatum: - - `OpenClawTray--win-x64.zip` - - `OpenClawTray--win-arm64.zip` - -MSIX artifacts are intentionally paused for alpha while we focus on the Inno -installer path and signed portable update payloads. Re-enable MSIX only when we -explicitly want packaged camera/microphone consent validation again. +For the current alpha flow, no binary artifacts are attached to the GitHub +release (the MSIX-primary distribution pipeline lands in a follow-up phase on +the `user/kmahone/msix` branch). The tag itself is the published artifact. ## Executable signing policy @@ -226,7 +217,7 @@ Only tag when `HEAD == origin/main`. - Do not add csproj `` release fallbacks; product versions come from GitVersion/tag history. - Release versions come from the tag (`vX.Y.Z` or `vX.Y.Z-alpha.N`). -- Untagged `master` builds are prerelease builds. After `vX.Y.Z-alpha.N`, an +- Untagged `main` builds are prerelease builds. After `vX.Y.Z-alpha.N`, an untagged commit may resolve to the next alpha prerelease, for example `X.Y.Z-alpha.(N+1)`. - CI computes GitVersion outputs for artifact naming, while product builds use diff --git a/docs/SETUP.md b/docs/SETUP.md index f804f6501..aeabbdb6f 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -15,21 +15,25 @@ You do **not** need a pre-existing local OpenClaw gateway before installing. On ### 1. Download the Installer -Download the latest stable installer from the canonical OpenClaw release assets: + -| File | Architecture | -|------|-------------| -| [OpenClawCompanion-Setup-x64.exe](https://github.com/openclaw/openclaw/releases/latest/download/OpenClawCompanion-Setup-x64.exe) | Intel / AMD (most PCs) | -| [OpenClawCompanion-Setup-arm64.exe](https://github.com/openclaw/openclaw/releases/latest/download/OpenClawCompanion-Setup-arm64.exe) | ARM64 (Surface Pro X, Snapdragon laptops) | -| [OpenClawCompanion-SHA256SUMS.txt](https://github.com/openclaw/openclaw/releases/latest/download/OpenClawCompanion-SHA256SUMS.txt) | SHA-256 checksums | - -If you're unsure, use the **x64** installer. +> **MSIX installer downloads coming soon.** The Inno `.exe` installer has been +> retired and the MSIX-primary distribution pipeline is in progress. Build +> locally from source in the meantime — see [DEVELOPMENT.md](../DEVELOPMENT.md). ### 2. Run the Installer -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). +Once the MSIX download is published, double-click the downloaded `.msix` +package to install it via the Windows App Installer. SmartScreen may show a +prompt the first time — click **More info → Run anyway** (this is normal for +newly published code-signed apps that haven't yet accumulated reputation). -The installer runs without requiring administrator privileges. +MSIX installs do not require administrator privileges. ### 3. Choose Optional Components @@ -104,7 +108,6 @@ OpenClaw Companion responds to `openclaw://` deep links, which can be invoked fr | `openclaw://activity` | Open the Activity page | | `openclaw://history` | Open the Activity page filtered to notification history | | `openclaw://healthcheck` | Run a manual health check | -| `openclaw://check-updates` | Run a manual update check | | `openclaw://logs` | Open the current tray log file | | `openclaw://log-folder` | Open the logs folder | | `openclaw://config` | Open the config folder | diff --git a/docs/SETUP_ENGINE_REDESIGN.md b/docs/SETUP_ENGINE_REDESIGN.md index 747cdf43d..47ca2e1ad 100644 --- a/docs/SETUP_ENGINE_REDESIGN.md +++ b/docs/SETUP_ENGINE_REDESIGN.md @@ -12,6 +12,48 @@ The bundled `default-config.json` ships with the tray executable and provides se --- +## Hazards + +### Don't split `OpenClaw.SetupEngine.UI` into its own process + +`OpenClaw.SetupEngine.UI` is intentionally a class library that the tray +references and hosts in-process. The tray's `App.xaml.cs` constructs +`SetupWindow` directly and reuses the tray's single-instance mutex, deep-link +surface, and post-setup restart sequence. + +There is an MSBuild ship-guard in +`src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj` that fails the build if +`OpenClaw.SetupEngine.UI.exe` appears in the tray's `bin/` or `publish/` +output. (Before MSIX became primary, the same guard lived in `installer.iss` +as a `#error` preprocessor check.) Do not weaken or remove it without +deliberately re-evaluating the single-process design below. + +If you ever do split SetupEngine into a standalone executable inside the MSIX: + +- You'll need to copy XAML/resource files into SetupEngine.UI's own publish + directory (its own `CopyXamlResourcesToPublishDirectory` MSBuild target) so + the standalone `.exe` can find them — `OpenClaw.Tray.WinUI` no longer owns + the resource pipeline for SetupEngine.UI in that world. +- Standalone WinUI 3 executables need to bootstrap the Windows App Runtime + themselves before any framework call. That means a `WindowsAppRuntime_EnsureIsLoaded` + P/Invoke in `SetupEngine.UI/Program.cs` (or equivalent C# bootstrapper). + Inside the tray today this happens automatically via the MSIX + `Dependencies > Package Name="Microsoft.WindowsAppRuntime.*"` element. +- The single-instance mutex (`App.xaml.cs:355-405`) and the + `--post-setup-restart`/`--wait-for-pid` handoff would need to coordinate + across two processes instead of being intra-process state. +- The `openclaw://` deep-link surface, the tray flyout, and the StartupTask + registration all live in the tray today. Splitting setup out means each of + those needs an explicit cross-process protocol for what setup needs to + announce back to the tray. + +PR #468 contains a worked example of the bootstrapper diffs needed (search +its tree for `OpenClaw.SetupEngine.UI.csproj` `CopyXamlResourcesToPublishDirectory` +and `Program.cs` `WindowsAppRuntime_EnsureIsLoaded`) — use it as a starting +point if you ever revisit this decision. + +--- + ## Architecture ``` diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 931261a18..eb86a8dba 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -22,12 +22,12 @@ Tagged releases must resolve to the exact tag SemVer: - `vX.Y.Z` -> `X.Y.Z` - `vX.Y.Z-alpha.N` -> `X.Y.Z-alpha.N` -Untagged `master` checkouts are still prerelease builds. After an alpha tag, +Untagged `main` checkouts are still prerelease builds. After an alpha tag, GitVersion advances to the next alpha prerelease until another tag pins the -version. For example, after `v0.6.0-alpha.5`, an untagged commit on `master` +version. For example, after `v0.6.0-alpha.5`, an untagged commit on `main` may resolve to `0.6.0-alpha.6`. -`GitVersion.yml` intentionally gives the `master`/`main` branch the `alpha` +`GitVersion.yml` intentionally gives the `main`/`master` branch the `alpha` label so alpha tags are treated as exact version sources. Do not remove that label unless the release train stops using alpha tags. @@ -80,9 +80,6 @@ For example: .\scripts\Get-OpenClawVersion.ps1 -Variable MajorMinorPatch ``` -`scripts\build-inno-local.ps1` uses that helper for Inno's `AppVersion` when -`-Version` is not explicitly supplied. - ## Guardrails - Do not add `` release literals to product `.csproj` files. @@ -95,5 +92,4 @@ For example: ## References - [Microsoft Docs: Assembly Versioning](https://learn.microsoft.com/en-us/dotnet/standard/assembly/versioning) -- [Updatum Library](https://github.com/sn4k3/Updatum) - [GitVersion Documentation](https://gitversion.net/) diff --git a/installer.iss b/installer.iss deleted file mode 100644 index d4dbd9aaf..000000000 --- a/installer.iss +++ /dev/null @@ -1,296 +0,0 @@ -; OpenClaw Companion Inno Setup Script (WinUI version) -#define MyAppName "OpenClaw Companion" -#define MyAppPublisher "Scott Hanselman" -#define MyAppURL "https://github.com/openclaw/openclaw-windows-node" -#define MyAppExeName "OpenClaw.Tray.WinUI.exe" - -; MyAppArch should be passed via /DMyAppArch=x64 or /DMyAppArch=arm64 -#ifndef MyAppArch - #define MyAppArch "x64" -#endif - -#ifndef MyCompression - #define MyCompression "lzma" -#endif - -#ifndef MySolidCompression - #define MySolidCompression "yes" -#endif - -[Setup] -; Inno requires "{{" to emit a literal opening brace in AppId. -; Do not add a second closing brace here; that creates a malformed uninstall registry key. -AppId={{M0LTB0T-TRAY-4PP1-D3N7} -AppName={#MyAppName} -AppVersion={#MyAppVersion} -AppPublisher={#MyAppPublisher} -AppPublisherURL={#MyAppURL} -AppSupportURL=https://github.com/openclaw/openclaw-windows-node/issues -AppUpdatesURL=https://github.com/openclaw/openclaw-windows-node/releases -DefaultDirName={localappdata}\OpenClawTray -DefaultGroupName={#MyAppName} -DisableProgramGroupPage=yes -OutputBaseFilename=OpenClawCompanion-Setup-{#MyAppArch} -Compression={#MyCompression} -SolidCompression={#MySolidCompression} -WizardStyle=modern -PrivilegesRequired=lowest -SetupIconFile=src\OpenClaw.Tray.WinUI\Assets\openclaw.ico -UninstallDisplayIcon={app}\{#MyAppExeName} -; Round 2 (Scott #5): block install/uninstall while the tray is running. -; Mutex name matches App.xaml.cs (`new Mutex(true, "OpenClawTray", …)`). -; Tray and Inno run in the same user session, so the bare name resolves -; against Local\OpenClawTray — no Global\ prefix needed. -AppMutex=OpenClawTray -#if MyAppArch == "arm64" -ArchitecturesInstallIn64BitMode=arm64 -ArchitecturesAllowed=arm64 -#else -ArchitecturesInstallIn64BitMode=x64 -ArchitecturesAllowed=x64 -#endif - -[Languages] -Name: "english"; MessagesFile: "compiler:Default.isl" - -; publish folder should be passed via /Dpublish=publish-x64 or /Dpublish=publish-arm64 -#ifndef publish - #define publish "publish" -#endif - -#if !FileExists(publish + "\OpenClaw.Tray.WinUI.exe") - #error Tray payload missing. Publish OpenClaw.Tray.WinUI before compiling the installer. -#endif - -#if FileExists(publish + "\SetupEngine\OpenClaw.SetupEngine.UI.exe") - #error SetupEngine.UI.exe should not be shipped. Setup UI is hosted by OpenClaw.Tray.WinUI.exe. -#endif - -; vcRedist should point at the architecture-matching Visual C++ Runtime -; redistributable in CI release builds. -#ifndef vcRedist - #define vcRedist "" -#endif - -[Tasks] -Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked -Name: "startupicon"; Description: "Start OpenClaw Companion when Windows starts"; GroupDescription: "Startup:"; Flags: unchecked - -[Files] -; WinUI Tray app - include all files (WinUI needs DLLs, not single-file) -Source: "{#publish}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs -; WSL gateway uninstall helper copied to {tmp} by [Code] during uninstall. -Source: "scripts\Uninstall-LocalGateway.ps1"; DestDir: "{app}"; Flags: ignoreversion -#if vcRedist != "" -Source: "{#vcRedist}"; DestDir: "{tmp}"; DestName: "vc_redist.exe"; Flags: deleteafterinstall; AfterInstall: InstallVCRuntime -#endif - -[Registry] -Root: HKCU; Subkey: "Software\Classes\openclaw"; ValueType: string; ValueName: ""; ValueData: "URL:OpenClaw Protocol"; Flags: uninsdeletekey -Root: HKCU; Subkey: "Software\Classes\openclaw"; ValueType: string; ValueName: "URL Protocol"; ValueData: "" -Root: HKCU; Subkey: "Software\Classes\openclaw\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"",0" -Root: HKCU; Subkey: "Software\Classes\openclaw\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1""" - -[Icons] -Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" -Name: "{group}\OpenClaw Gateway Setup"; Filename: "{app}\{#MyAppExeName}"; Parameters: "openclaw://setup"; IconFilename: "{app}\{#MyAppExeName}" -Name: "{group}\OpenClaw Companion Settings"; Filename: "{app}\{#MyAppExeName}"; Parameters: "openclaw://commandcenter"; IconFilename: "{app}\{#MyAppExeName}" -Name: "{group}\OpenClaw Chat"; Filename: "{app}\{#MyAppExeName}"; Parameters: "openclaw://chat"; IconFilename: "{app}\{#MyAppExeName}" -Name: "{group}\Check for Updates"; Filename: "{app}\{#MyAppExeName}"; Parameters: "openclaw://check-updates"; IconFilename: "{app}\{#MyAppExeName}" -Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" -Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon -Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: startupicon - -[Run] -Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent; Check: ShouldLaunchTray - -[Code] -var - VCRuntimeInstallSucceeded: Boolean; - LocalGatewayCleanupChoiceInitialized: Boolean; - LocalGatewayCleanupRequested: Boolean; - LocalGatewayCleanupSucceeded: Boolean; - -#if vcRedist != "" -procedure InstallVCRuntime; -var - ResultCode: Integer; - Started: Boolean; -begin - VCRuntimeInstallSucceeded := False; - Log('Running bundled Visual C++ Runtime redistributable.'); - Started := - Exec( - ExpandConstant('{tmp}\vc_redist.exe'), - '/install /quiet /norestart', - '', - SW_HIDE, - ewWaitUntilTerminated, - ResultCode); - - if not Started then - begin - Log('Failed to start Visual C++ Runtime redistributable. System error: ' + IntToStr(ResultCode) + '.'); - Exit; - end; - - VCRuntimeInstallSucceeded := (ResultCode = 0) or (ResultCode = 3010) or (ResultCode = 1641); - if VCRuntimeInstallSucceeded then - Log('Visual C++ Runtime redistributable exited with success code ' + IntToStr(ResultCode) + '.') - else - Log('Visual C++ Runtime redistributable failed with exit code ' + IntToStr(ResultCode) + '.'); -end; -#endif - -function ShouldLaunchTray: Boolean; -begin -#if vcRedist != "" - Result := VCRuntimeInstallSucceeded; - if not Result then - Log('Skipping post-install tray launch because Visual C++ Runtime installation did not succeed.'); -#else - Result := True; -#endif -end; - -procedure EnsureLocalGatewayCleanupChoice; -begin - if LocalGatewayCleanupChoiceInitialized then - Exit; - - LocalGatewayCleanupChoiceInitialized := True; - - if UninstallSilent() then - begin - LocalGatewayCleanupRequested := True; - Log('Silent uninstall: local gateway cleanup will run automatically.'); - end - else - begin - LocalGatewayCleanupRequested := - MsgBox( - 'Do you also want to remove the OpenClaw local WSL gateway?' + #13#10#13#10 + - 'Choose Yes to unregister the OpenClawGateway WSL distro and remove generated local gateway state.' + #13#10 + - 'Choose No to leave the local gateway and generated local state on this computer.', - mbConfirmation, - MB_YESNO) = IDYES; - - if LocalGatewayCleanupRequested then - Log('User chose to remove the local WSL gateway.') - else - Log('User chose to preserve the local WSL gateway and generated state.'); - end; -end; - -function RunLocalGatewayCleanupOnce(var ResultCode: Integer): Boolean; -var - SourceScriptPath: string; - TempScriptPath: string; - Params: string; -begin - SourceScriptPath := ExpandConstant('{app}\Uninstall-LocalGateway.ps1'); - TempScriptPath := ExpandConstant('{tmp}\Uninstall-LocalGateway.ps1'); - - if not FileExists(SourceScriptPath) then - begin - ResultCode := 2; - Log('Local gateway cleanup script is missing: ' + SourceScriptPath); - Result := False; - Exit; - end; - - if FileExists(TempScriptPath) then - DeleteFile(TempScriptPath); - - if not CopyFile(SourceScriptPath, TempScriptPath, False) then - begin - ResultCode := 3; - Log('Failed to copy local gateway cleanup script to: ' + TempScriptPath); - Result := False; - Exit; - end; - - Params := - '-NoProfile -ExecutionPolicy Bypass -File ' + AddQuotes(TempScriptPath) + - ' -AppRoot ' + AddQuotes(ExpandConstant('{app}')); - - Log('Running local gateway cleanup script from {tmp}.'); - Result := - Exec( - ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'), - Params, - '', - SW_HIDE, - ewWaitUntilTerminated, - ResultCode); - - if Result then - Log('Local gateway cleanup script exited with code ' + IntToStr(ResultCode) + '.') - else - Log('Failed to start local gateway cleanup script. System error: ' + IntToStr(ResultCode) + '.'); -end; - -procedure RunLocalGatewayCleanup; -var - ResultCode: Integer; - Retry: Boolean; - Started: Boolean; -begin - if not LocalGatewayCleanupRequested then - Exit; - - LocalGatewayCleanupSucceeded := False; - - repeat - Retry := False; - UninstallProgressForm.StatusLabel.Caption := 'Removing local WSL gateway...'; - Started := RunLocalGatewayCleanupOnce(ResultCode); - - if Started and (ResultCode = 0) then - begin - LocalGatewayCleanupSucceeded := True; - Log('Local gateway cleanup completed successfully.'); - Exit; - end; - - if UninstallSilent() then - begin - Log('Local gateway cleanup failed during silent uninstall; continuing without deleting generated state.'); - Exit; - end; - - Retry := - MsgBox( - 'OpenClaw could not remove the local WSL gateway.' + #13#10#13#10 + - 'Exit code: ' + IntToStr(ResultCode) + #13#10#13#10 + - 'Select Retry to try again, or Cancel to continue uninstalling OpenClaw and leave local gateway state on disk.', - mbError, - MB_RETRYCANCEL) = IDRETRY; - until not Retry; - - Log('User continued uninstall after local gateway cleanup failed; generated state will be preserved.'); -end; - -procedure DeleteGeneratedAppState; -begin - if not LocalGatewayCleanupSucceeded then - Exit; - - if DelTree(ExpandConstant('{app}'), True, True, True) then - Log('Deleted generated app state from {app}.') - else - Log('Generated app state in {app} could not be fully deleted; continuing uninstall.'); -end; - -procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); -begin - if CurUninstallStep = usUninstall then - begin - EnsureLocalGatewayCleanupChoice; - RunLocalGatewayCleanup; - end - else if CurUninstallStep = usPostUninstall then - begin - DeleteGeneratedAppState; - end; -end; diff --git a/installer/appinstaller/README.md b/installer/appinstaller/README.md new file mode 100644 index 000000000..4577886ed --- /dev/null +++ b/installer/appinstaller/README.md @@ -0,0 +1,44 @@ +# 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/main/installer/appinstaller/openclaw-x64.appinstaller` +- `https://raw.githubusercontent.com/openclaw/openclaw-windows-node/main/installer/appinstaller/openclaw-arm64.appinstaller` + +The checked-in feed files are bootstrap placeholders at version `0.0.0.0` so +the raw URLs exist before the first signed MSIX embeds them. End users never +install these placeholders directly — Windows AppInstaller checks them in the +background after the user installs from a real release. + +## Release flow + +Release builds do **not** push these files directly to `main`. After a +successful stable release tag: + +1. `.github/workflows/appinstaller-feed-pr.yml` is triggered (manually via + workflow_dispatch with the release tag). +2. The workflow renders the per-architecture feed files from the matching + signed `.msix` release assets via `scripts/render-appinstaller.ps1`. +3. The rendered files are validated via `scripts/validate-appinstaller-hosting.ps1` + against the hosted GitHub release assets. +4. A pull request is opened against `main` with the regenerated XML. +5. Merging the PR is the human gate that advances installed clients to the + new version. + +Git history is the audit trail for which release each feed file points at. + +## Pre-release / alpha channel + +Alpha/pre-release feed updates are blocked until maintainers choose a channel +strategy. Do not hand-edit the stable feed files to point at alpha packages — +auto-updating all stable users to a pre-release build is a one-way trip. + +## Self-contained WindowsAppSDK + +OpenClaw Companion is built with `WindowsAppSDKSelfContained=true`, so the +WindowsAppRuntime is packaged inside each `.msix`. The feed files therefore +emit no `` block — Windows does not need to download a separate +framework package at install time. diff --git a/installer/appinstaller/openclaw-alpha-arm64.appinstaller b/installer/appinstaller/openclaw-alpha-arm64.appinstaller new file mode 100644 index 000000000..76a1bcbdb --- /dev/null +++ b/installer/appinstaller/openclaw-alpha-arm64.appinstaller @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/installer/appinstaller/openclaw-alpha-x64.appinstaller b/installer/appinstaller/openclaw-alpha-x64.appinstaller new file mode 100644 index 000000000..c8533c42e --- /dev/null +++ b/installer/appinstaller/openclaw-alpha-x64.appinstaller @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/installer/appinstaller/openclaw-arm64.appinstaller b/installer/appinstaller/openclaw-arm64.appinstaller new file mode 100644 index 000000000..8d0b4cee9 --- /dev/null +++ b/installer/appinstaller/openclaw-arm64.appinstaller @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/installer/appinstaller/openclaw-update.alpha.appinstaller b/installer/appinstaller/openclaw-update.alpha.appinstaller new file mode 100644 index 000000000..a63f136fc --- /dev/null +++ b/installer/appinstaller/openclaw-update.alpha.appinstaller @@ -0,0 +1,31 @@ + + + + + + + + + + + diff --git a/installer/appinstaller/openclaw-update.stable.appinstaller b/installer/appinstaller/openclaw-update.stable.appinstaller new file mode 100644 index 000000000..bc73c6d5a --- /dev/null +++ b/installer/appinstaller/openclaw-update.stable.appinstaller @@ -0,0 +1,42 @@ + + + + + + + + + + + diff --git a/installer/appinstaller/openclaw-x64.appinstaller b/installer/appinstaller/openclaw-x64.appinstaller new file mode 100644 index 000000000..7db9205fe --- /dev/null +++ b/installer/appinstaller/openclaw-x64.appinstaller @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/installer/openclaw-companion.appinstaller.template b/installer/openclaw-companion.appinstaller.template new file mode 100644 index 000000000..315239f12 --- /dev/null +++ b/installer/openclaw-companion.appinstaller.template @@ -0,0 +1,48 @@ + + + + + + + + + + diff --git a/openclaw-windows-node.slnx b/openclaw-windows-node.slnx index 0fb4c6ecd..6e821a514 100644 --- a/openclaw-windows-node.slnx +++ b/openclaw-windows-node.slnx @@ -26,6 +26,7 @@ + diff --git a/run-app-local.ps1 b/run-app-local.ps1 index abbffbc92..5d048d1d8 100644 --- a/run-app-local.ps1 +++ b/run-app-local.ps1 @@ -3,11 +3,10 @@ Builds and launches the WinUI tray app for local development. .DESCRIPTION - Builds the tray app, then launches the unpackaged WinUI executable directly - for the common local-development path. - - Use -UseWinApp when you specifically want Microsoft WinAppCLI (`winapp run`) - to launch with Package.appxmanifest for packaged/MSIX-adjacent validation. + Builds the tray app, then launches it as a packaged WinUI app via + Microsoft WinAppCLI (`winapp run`) using Package.appxmanifest from the + build output. The tray always runs with MSIX package identity in dev (no + .msix file required); direct unpackaged launch is no longer supported. Use -Isolated (or -DataDir) to run multiple worktrees side-by-side without sharing settings, logs, run markers, device identities, or mutex names. @@ -36,16 +35,8 @@ Set OPENCLAW_UPDATE_CHANNEL for this launch. Use alpha for prerelease update testing after building a lower-version Release baseline. -.PARAMETER UseWinApp - Launch through Microsoft WinAppCLI (`winapp run`) with Package.appxmanifest - instead of directly starting the unpackaged executable. - .PARAMETER NoDebugOutput - With -UseWinApp, launch without winapp --debug-output. - -.PARAMETER Wait - Wait for the launched process to exit. Direct launches return immediately by - default after printing the PID. + Launch winapp without --debug-output. .PARAMETER DryRun Print the launch command and environment without starting the app. @@ -81,12 +72,8 @@ param( [ValidateSet("stable", "alpha", "prerelease")] [string]$UpdateChannel, - [switch]$UseWinApp, - [switch]$NoDebugOutput, - [switch]$Wait, - [switch]$DryRun ) @@ -158,15 +145,12 @@ if (-not (Test-Path $exePath)) { throw "Tray executable not found: $exePath. Run without -NoBuild first." } -$winapp = $null -if ($UseWinApp) { - $winapp = Get-Command winapp -ErrorAction SilentlyContinue - if (-not $winapp) { - throw "winapp CLI was not found. Install Microsoft WinAppCLI (winget install Microsoft.WinAppCLI) or run /winui-setup." - } +$winapp = Get-Command winapp -ErrorAction SilentlyContinue +if (-not $winapp) { + throw "winapp CLI was not found. Install Microsoft WinAppCLI (winget install Microsoft.WinAppCLI) or run /winui-setup." } -if ($UseWinApp -and -not (Test-Path $manifestPath)) { +if (-not (Test-Path $manifestPath)) { throw "Manifest not found: $manifestPath." } @@ -194,11 +178,9 @@ try { $env:OPENCLAW_UPDATE_CHANNEL = $UpdateChannel } - if ($UseWinApp) { - $winappArgs = @("run", $outputDir, "--manifest", $manifestPath, "--executable", "OpenClaw.Tray.WinUI.exe") - if (-not $NoDebugOutput) { - $winappArgs += "--debug-output" - } + $winappArgs = @("run", $outputDir, "--manifest", $manifestPath, "--executable", "OpenClaw.Tray.WinUI.exe") + if (-not $NoDebugOutput) { + $winappArgs += "--debug-output" } Write-Host "Launching OpenClaw Tray" -ForegroundColor Cyan @@ -206,35 +188,22 @@ try { Write-Host " Configuration: $Configuration" Write-Host " Runtime: $runtimeIdentifier" Write-Host " Output: $outputDir" - Write-Host " Mode: $(if ($UseWinApp) { 'WinAppCLI manifest activation' } else { 'Direct unpackaged executable' })" + Write-Host " Mode: WinAppCLI manifest activation (packaged loose layout)" if ($env:OPENCLAW_TRAY_DATA_DIR) { Write-Host " Data dir: $env:OPENCLAW_TRAY_DATA_DIR" } if ($env:OPENCLAW_UPDATE_CHANNEL) { Write-Host " Update channel: $env:OPENCLAW_UPDATE_CHANNEL" } - if ($UseWinApp) { - Write-Host " Launcher: $($winapp.Source)" - Write-Host " Command: winapp $($winappArgs -join ' ')" - } else { - Write-Host " Launcher: $exePath" - } + Write-Host " Launcher: $($winapp.Source)" + Write-Host " Command: winapp $($winappArgs -join ' ')" if ($DryRun) { return } - if ($UseWinApp) { - & $winapp.Source @winappArgs - $exitCode = $LASTEXITCODE - } elseif ($Wait) { - $process = Start-Process -FilePath $exePath -WorkingDirectory $outputDir -Wait -PassThru - $exitCode = $process.ExitCode - } else { - $process = Start-Process -FilePath $exePath -WorkingDirectory $outputDir -PassThru - Write-Host "Started OpenClaw Tray (PID: $($process.Id))" -ForegroundColor Green - $exitCode = 0 - } + & $winapp.Source @winappArgs + $exitCode = $LASTEXITCODE } finally { $env:OPENCLAW_TRAY_DATA_DIR = $previousDataDir $env:OPENCLAW_UPDATE_CHANNEL = $previousUpdateChannel diff --git a/scripts/Test-ReleaseNativeDependencies.ps1 b/scripts/Test-ReleaseNativeDependencies.ps1 index 5661ce230..eb5fb98c6 100644 --- a/scripts/Test-ReleaseNativeDependencies.ps1 +++ b/scripts/Test-ReleaseNativeDependencies.ps1 @@ -15,10 +15,6 @@ param( [switch]$RequireAppLocalVCRuntime, - [switch]$RequireInstallerVCRedist, - - [string]$InstallerVCRedistPath, - [switch]$SkipNativeLoadProbe ) @@ -330,20 +326,6 @@ if ($shouldProbeNativeLoad) { Add-TtsNativeStackProbeErrors } -if ($RequireInstallerVCRedist) { - $redist = if ([string]::IsNullOrWhiteSpace($InstallerVCRedistPath)) { - Join-Path $payloadRoot "vc_redist.x64.exe" - } else { - $InstallerVCRedistPath - } - - if (-not (Test-Path -LiteralPath $redist)) { - $errors.Add("Missing bundled Visual C++ Runtime redistributable at $redist.") - } elseif ($runningOnWindows) { - Add-MicrosoftSignatureErrors -File (Get-Item -LiteralPath $redist) - } -} - if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Error $_ } exit 1 diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 deleted file mode 100644 index f8eefbf81..000000000 --- a/scripts/Uninstall-LocalGateway.ps1 +++ /dev/null @@ -1,625 +0,0 @@ -<# -.SYNOPSIS - Removes the OpenClaw local WSL gateway during app uninstall. - -.DESCRIPTION - This helper is launched by the Inno uninstaller after the user chooses to - remove the local gateway. It deliberately calls WSL directly instead of - launching OpenClaw binaries from the install directory, so the app payload is - not kept loaded while Inno removes installed files. -#> - -[CmdletBinding()] -param( - [string]$AppRoot = $PSScriptRoot -) - -$ErrorActionPreference = 'Stop' - -$DistroName = 'OpenClawGateway' -$resultPath = Join-Path $AppRoot 'uninstall-gateway-result.json' -$errorPath = Join-Path $AppRoot 'uninstall-gateway-error.log' -$wslLogPath = Join-Path $AppRoot 'uninstall-gateway-wsl.log' -$cleanupWarnings = New-Object 'System.Collections.Generic.List[string]' - -function Ensure-AppRoot { - if (-not [string]::IsNullOrWhiteSpace($AppRoot) -and -not (Test-Path -LiteralPath $AppRoot)) { - New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null - } -} - -function Write-GatewayLog { - param([string]$Message) - - try { - Ensure-AppRoot - "[$(Get-Date -Format 'o')] $Message" | Out-File -LiteralPath $wslLogPath -Encoding UTF8 -Append -Force - } catch { - Write-Verbose "Failed to write gateway uninstall log: $($_.Exception.Message)" - } -} - -function Add-CleanupWarning { - param([string]$Message) - - $script:cleanupWarnings.Add($Message) - Write-GatewayLog "Windows artifact cleanup warning: $Message" -} - -function Write-GatewayResult { - param( - [bool]$Succeeded, - [int]$ExitCode, - [string]$Message, - [object]$Details = $null - ) - - try { - Ensure-AppRoot - [ordered]@{ - timestamp = (Get-Date).ToString('o') - succeeded = $Succeeded - exitCode = $ExitCode - message = $Message - details = $Details - } | ConvertTo-Json -Depth 5 | Out-File -LiteralPath $resultPath -Encoding UTF8 -Force - } catch { - $fallback = "[$(Get-Date -Format 'o')] Failed to write gateway uninstall result: $($_.Exception.Message)" - try { $fallback | Out-File -LiteralPath $errorPath -Encoding UTF8 -Force } catch {} - } -} - -function Resolve-AppDataDir { - if ($env:OPENCLAW_TRAY_DATA_DIR) { - return $env:OPENCLAW_TRAY_DATA_DIR - } - - return Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::ApplicationData)) 'OpenClawTray' - } - - function Resolve-LocalDataDir { - if ($env:OPENCLAW_TRAY_LOCALAPPDATA_DIR) { - return Join-Path $env:OPENCLAW_TRAY_LOCALAPPDATA_DIR 'OpenClawTray' - } - - if ($env:OPENCLAW_TRAY_LOCAL_DATA_DIR) { - return $env:OPENCLAW_TRAY_LOCAL_DATA_DIR - } - - return Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData)) 'OpenClawTray' - } - - function Get-JsonPropertyValue { - param( - [object]$Object, - [string]$Name - ) - - if ($null -eq $Object) { - return $null - } - - $property = $Object.PSObject.Properties[$Name] - if ($null -eq $property) { - return $null - } - - return $property.Value - } - - function Read-JsonFile { - param([string]$Path) - - if (-not (Test-Path -LiteralPath $Path)) { - return $null - } - - try { - return Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop - } catch { - Add-CleanupWarning "Failed to read JSON file '$Path': $($_.Exception.Message)" - return $null - } - } - - function Write-JsonFileAtomic { - param( - [string]$Path, - [object]$Value - ) - - $directory = Split-Path -Parent $Path - if (-not [string]::IsNullOrWhiteSpace($directory) -and -not (Test-Path -LiteralPath $directory)) { - New-Item -ItemType Directory -Path $directory -Force | Out-Null - } - - $tempPath = Join-Path $directory ('.' + (Split-Path -Leaf $Path) + '.' + [Guid]::NewGuid().ToString('N') + '.tmp') - try { - $Value | ConvertTo-Json -Depth 50 | Out-File -LiteralPath $tempPath -Encoding UTF8 -Force - Move-Item -LiteralPath $tempPath -Destination $Path -Force - } catch { - Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue - throw - } - } - - function Test-LocalGatewayUrl { - param([string]$Url) - - if ([string]::IsNullOrWhiteSpace($Url)) { - return $false - } - - try { - $uri = [Uri]$Url - $host = $uri.Host.ToLowerInvariant() - return $host -eq 'localhost' -or $host -eq '127.0.0.1' -or $host -eq '::1' -or $host -eq '[::1]' - } catch { - return $false - } - } - - function Test-SetupManagedLocalRecord { - param([object]$Record) - - $isLocal = [bool](Get-JsonPropertyValue $Record 'isLocal') - $sshTunnel = Get-JsonPropertyValue $Record 'sshTunnel' - if (-not $isLocal -or $null -ne $sshTunnel) { - return $false - } - - $setupManagedDistroName = [string](Get-JsonPropertyValue $Record 'setupManagedDistroName') - if ([string]::Equals($setupManagedDistroName, $DistroName, [StringComparison]::Ordinal)) { - return $true - } - - if (-not [string]::IsNullOrWhiteSpace($setupManagedDistroName)) { - return $false - } - - $friendlyName = [string](Get-JsonPropertyValue $Record 'friendlyName') - $url = [string](Get-JsonPropertyValue $Record 'url') - return [string]::Equals($friendlyName, "Local ($DistroName)", [StringComparison]::Ordinal) -and (Test-LocalGatewayUrl $url) - } - - function Test-ExternalGatewayRecord { - param([object]$Record) - - $isLocal = [bool](Get-JsonPropertyValue $Record 'isLocal') - $sshTunnel = Get-JsonPropertyValue $Record 'sshTunnel' - $url = [string](Get-JsonPropertyValue $Record 'url') - return (-not $isLocal) -and -not ($null -eq $sshTunnel -and (Test-LocalGatewayUrl $url)) - } - - function Remove-FileIfExists { - param( - [string]$Path, - [string]$Label - ) - - try { - if (Test-Path -LiteralPath $Path -PathType Leaf) { - Remove-Item -LiteralPath $Path -Force -ErrorAction Stop - Write-GatewayLog "Deleted $Label." - } else { - Write-GatewayLog "$Label already absent." - } - } catch { - Add-CleanupWarning "Failed to delete $Label '$Path': $($_.Exception.Message)" - } - } - - function Remove-DirectoryIfExists { - param( - [string]$Path, - [string]$Label - ) - - try { - if (Test-Path -LiteralPath $Path -PathType Container) { - Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop - Write-GatewayLog "Deleted $Label directory." - } - } catch { - Add-CleanupWarning "Failed to delete $Label directory '$Path': $($_.Exception.Message)" - } - } - - function Remove-AutostartRegistryValue { - $runKey = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' - try { - $value = Get-ItemProperty -LiteralPath $runKey -Name 'OpenClawTray' -ErrorAction SilentlyContinue - if ($null -ne $value) { - Remove-ItemProperty -LiteralPath $runKey -Name 'OpenClawTray' -ErrorAction Stop - Write-GatewayLog 'Removed OpenClawTray autostart registry value.' - } else { - Write-GatewayLog 'OpenClawTray autostart registry value already absent.' - } - } catch { - Add-CleanupWarning "Failed to remove OpenClawTray autostart registry value: $($_.Exception.Message)" - } - } - - function Remove-SetupManagedGatewayRecords { - param([string]$DataDir) - - $gatewaysPath = Join-Path $DataDir 'gateways.json' - $registry = Read-JsonFile $gatewaysPath - if ($null -eq $registry) { - return [pscustomobject]@{ - RemainingCount = 0 - HasExternalGateways = $false - } - } - - $gatewayProperty = $registry.PSObject.Properties['gateways'] - $records = @() - if ($null -ne $gatewayProperty -and $null -ne $gatewayProperty.Value) { - $records = @($gatewayProperty.Value) - } - - $remaining = New-Object System.Collections.ArrayList - $removed = New-Object System.Collections.ArrayList - foreach ($record in $records) { - if (Test-SetupManagedLocalRecord $record) { - [void]$removed.Add($record) - } else { - [void]$remaining.Add($record) - } - } - - foreach ($record in $removed) { - $id = [string](Get-JsonPropertyValue $record 'id') - if ([string]::IsNullOrWhiteSpace($id)) { - continue - } - - $identityDir = Join-Path (Join-Path $DataDir 'gateways') $id - try { - if (Test-Path -LiteralPath $identityDir -PathType Container) { - Remove-Item -LiteralPath $identityDir -Recurse -Force -ErrorAction Stop - Write-GatewayLog "Deleted identity directory for local gateway record $id." - } - } catch { - Add-CleanupWarning "Failed to delete identity directory '$identityDir': $($_.Exception.Message)" - } - } - - if ($removed.Count -gt 0) { - try { - $registry.gateways = @($remaining.ToArray()) - $activeId = [string](Get-JsonPropertyValue $registry 'activeId') - if ($removed | Where-Object { [string](Get-JsonPropertyValue $_ 'id') -eq $activeId }) { - $registry.activeId = $null - } - - Write-JsonFileAtomic -Path $gatewaysPath -Value $registry - Write-GatewayLog "Removed $($removed.Count) setup-managed local gateway record(s)." - } catch { - Add-CleanupWarning "Failed to update gateways.json: $($_.Exception.Message)" - } - } else { - Write-GatewayLog 'No setup-managed local gateway records found.' - } - - $hasExternalGateways = $false - foreach ($record in @($remaining.ToArray())) { - if (Test-ExternalGatewayRecord $record) { - $hasExternalGateways = $true - break - } - } - - return [pscustomobject]@{ - RemainingCount = $remaining.Count - HasExternalGateways = $hasExternalGateways - } - } - - function Clear-RootDeviceTokenForRole { - param( - [string]$DataDir, - [string]$Role - ) - - $keyPath = Join-Path $DataDir 'device-key-ed25519.json' - $keyData = Read-JsonFile $keyPath - if ($null -eq $keyData) { - Write-GatewayLog "Root device identity file absent or unreadable for $Role token cleanup." - return - } - - $tokenPropertyName = if ($Role -eq 'node') { 'NodeDeviceToken' } else { 'DeviceToken' } - $scopesPropertyName = if ($Role -eq 'node') { 'NodeDeviceTokenScopes' } else { 'DeviceTokenScopes' } - $tokenProperty = $keyData.PSObject.Properties[$tokenPropertyName] - - if ($null -eq $tokenProperty -or [string]::IsNullOrEmpty([string]$tokenProperty.Value)) { - Write-GatewayLog "Root $Role device token already absent." - return - } - - try { - $tokenProperty.Value = $null - $scopesProperty = $keyData.PSObject.Properties[$scopesPropertyName] - if ($null -ne $scopesProperty) { - $scopesProperty.Value = $null - } - - Write-JsonFileAtomic -Path $keyPath -Value $keyData - Write-GatewayLog "Cleared root $Role device token." - } catch { - Add-CleanupWarning "Failed to clear root $Role device token: $($_.Exception.Message)" - } - } - - function Reset-OnboardingSettings { - param( - [string]$DataDir, - [bool]$PreserveNodeSettings - ) - - $settingsPath = Join-Path $DataDir 'settings.json' - $settings = Read-JsonFile $settingsPath - if ($null -eq $settings) { - Write-GatewayLog 'settings.json absent or unreadable; onboarding settings not reset.' - return - } - - $changed = $false - if ($settings.PSObject.Properties['GatewayUrl']) { - $settings.PSObject.Properties.Remove('GatewayUrl') - $changed = $true - } - - if (-not $PreserveNodeSettings -and $settings.PSObject.Properties['EnableNodeMode']) { - $settings.EnableNodeMode = $false - $changed = $true - } - - if (-not $PreserveNodeSettings -and $settings.PSObject.Properties['AutoStart']) { - $settings.AutoStart = $false - $changed = $true - } - - if (-not $changed) { - Write-GatewayLog 'No onboarding settings needed reset.' - return - } - - try { - Write-JsonFileAtomic -Path $settingsPath -Value $settings - Write-GatewayLog 'Reset onboarding settings.' - } catch { - Add-CleanupWarning "Failed to reset onboarding settings: $($_.Exception.Message)" - } - } - - function Remove-KeepaliveMarker { - param([string]$LocalDataDir) - - $markerDir = Join-Path $LocalDataDir 'wsl-keepalive' - $markerPath = Join-Path $markerDir "$DistroName.json" - Remove-FileIfExists -Path $markerPath -Label 'keepalive marker' - - try { - if ((Test-Path -LiteralPath $markerDir -PathType Container) -and -not (Get-ChildItem -LiteralPath $markerDir -Force -ErrorAction Stop | Select-Object -First 1)) { - Remove-Item -LiteralPath $markerDir -Force -ErrorAction Stop - Write-GatewayLog 'Deleted empty wsl-keepalive directory.' - } - } catch { - Add-CleanupWarning "Failed to remove empty wsl-keepalive directory '$markerDir': $($_.Exception.Message)" - } - } - - function Remove-WindowsGatewayArtifacts { - $dataDir = Resolve-AppDataDir - $localDataDir = Resolve-LocalDataDir - - Write-GatewayLog "Cleaning Windows-side local gateway artifacts. AppData='$dataDir'; LocalData='$localDataDir'." - - Remove-AutostartRegistryValue - Remove-FileIfExists -Path (Join-Path $dataDir 'setup-state.json') -Label 'legacy setup-state.json' - Remove-FileIfExists -Path (Join-Path $localDataDir 'setup-state.json') -Label 'setup-state.json' - Remove-FileIfExists -Path (Join-Path $localDataDir 'run.marker') -Label 'run.marker' - Remove-FileIfExists -Path (Join-Path $dataDir 'exec-policy.json') -Label 'exec-policy.json' - Remove-KeepaliveMarker -LocalDataDir $localDataDir - - $registryCleanup = Remove-SetupManagedGatewayRecords -DataDir $dataDir - if ($registryCleanup.HasExternalGateways) { - Write-GatewayLog 'External gateway records remain; preserving root device tokens.' - } else { - Clear-RootDeviceTokenForRole -DataDir $dataDir -Role 'operator' - Clear-RootDeviceTokenForRole -DataDir $dataDir -Role 'node' - } - - Reset-OnboardingSettings -DataDir $dataDir -PreserveNodeSettings:($registryCleanup.RemainingCount -gt 0) - Remove-DirectoryIfExists -Path (Join-Path $dataDir 'Logs') -Label 'AppData Logs' - Remove-DirectoryIfExists -Path (Join-Path $localDataDir 'Logs') -Label 'LocalAppData Logs' - } - - function Complete-GatewayCleanup { - param([string]$Message) - - Remove-WindowsGatewayArtifacts - Write-GatewayResult ` - -Succeeded $true ` - -ExitCode 0 ` - -Message $Message ` - -Details ([ordered]@{ artifactWarnings = @($script:cleanupWarnings) }) - Write-Host "OpenClaw local WSL gateway removed successfully." - exit 0 -} - -function Get-WslExePath { - $candidates = @( - (Join-Path $env:WINDIR 'Sysnative\wsl.exe'), - (Join-Path $env:WINDIR 'System32\wsl.exe') - ) - - foreach ($candidate in $candidates) { - if (Test-Path -LiteralPath $candidate) { - return $candidate - } - } - - $command = Get-Command wsl.exe -ErrorAction SilentlyContinue - if ($command) { - return $command.Source - } - - return $null -} - -function Format-Arguments { - param([string[]]$Arguments) - - return ($Arguments | ForEach-Object { - if ($_ -match '\s') { - '"' + ($_ -replace '"', '\"') + '"' - } else { - $_ - } - }) -join ' ' -} - -function Invoke-Wsl { - param([string[]]$Arguments) - - $stdoutPath = [System.IO.Path]::GetTempFileName() - $stderrPath = [System.IO.Path]::GetTempFileName() - - try { - $process = Start-Process ` - -FilePath $script:WslPath ` - -ArgumentList $Arguments ` - -WindowStyle Hidden ` - -Wait ` - -PassThru ` - -RedirectStandardOutput $stdoutPath ` - -RedirectStandardError $stderrPath - - $stdout = if (Test-Path -LiteralPath $stdoutPath) { Get-Content -LiteralPath $stdoutPath -Raw -ErrorAction SilentlyContinue } else { '' } - $stderr = if (Test-Path -LiteralPath $stderrPath) { Get-Content -LiteralPath $stderrPath -Raw -ErrorAction SilentlyContinue } else { '' } - $output = (($stdout, $stderr) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join [Environment]::NewLine - - Write-GatewayLog ("wsl.exe {0} exited {1}.{2}{3}" -f (Format-Arguments $Arguments), $process.ExitCode, [Environment]::NewLine, $output) - - return [pscustomobject]@{ - ExitCode = [int]$process.ExitCode - Output = $output - } - } finally { - Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue - } -} - -function Test-DistroNotFound { - param([string]$Output) - - if ([string]::IsNullOrWhiteSpace($Output)) { - return $false - } - - return $Output -match 'WSL_E_DISTRO_NOT_FOUND' -or - $Output -match 'There is no distribution with the supplied name' -or - $Output -match 'The specified distribution.*(could not be found|not found)' -or - $Output -match 'distribution.*not.*found' -} - -function Test-DistroListed { - param([string]$Output) - - if ([string]::IsNullOrWhiteSpace($Output)) { - return $false - } - - $distros = ($Output -replace "`0", '') -split '\r?\n' | ForEach-Object { $_.Trim() } - return $distros -contains $DistroName -} - -function Remove-GatewayDirectory { - $gatewayDirectory = Join-Path $AppRoot 'wsl\OpenClawGateway' - - if (-not (Test-Path -LiteralPath $gatewayDirectory)) { - Write-GatewayLog "Gateway directory does not exist: $gatewayDirectory" - return - } - - $gatewayItem = Get-Item -LiteralPath $gatewayDirectory -Force -ErrorAction Stop - if (($gatewayItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { - throw "Refusing to recursively delete reparse point '$gatewayDirectory'." - } - - $lastError = $null - for ($attempt = 1; $attempt -le 6; $attempt++) { - try { - Remove-Item -LiteralPath $gatewayDirectory -Recurse -Force -ErrorAction Stop - if (-not (Test-Path -LiteralPath $gatewayDirectory)) { - Write-GatewayLog "Removed gateway directory: $gatewayDirectory" - return - } - } catch { - $lastError = $_.Exception.Message - Write-GatewayLog "Attempt $attempt failed to remove gateway directory '$gatewayDirectory': $lastError" - } - - Start-Sleep -Seconds 1 - } - - throw "Failed to remove gateway directory '$gatewayDirectory': $lastError" -} - -try { - Ensure-AppRoot - Write-GatewayLog "Starting local gateway cleanup for $DistroName." - - $script:WslPath = Get-WslExePath - if (-not $script:WslPath) { - Write-GatewayLog 'wsl.exe was not found; removing stale gateway directory if present.' - Remove-GatewayDirectory - Complete-GatewayCleanup -Message 'wsl.exe was not found; no registered WSL gateway can be removed.' - } - - $listResult = Invoke-Wsl -Arguments @('--list', '--quiet') - if ($listResult.ExitCode -eq 0 -and -not (Test-DistroListed $listResult.Output)) { - Write-GatewayLog "WSL distro '$DistroName' is not registered; removing stale gateway directory if present." - Remove-GatewayDirectory - Complete-GatewayCleanup -Message "Local WSL gateway '$DistroName' was already unregistered." - } - - $terminateResult = Invoke-Wsl -Arguments @('--terminate', $DistroName) - if ($terminateResult.ExitCode -ne 0) { - Write-GatewayLog "Ignoring terminate exit code $($terminateResult.ExitCode); unregister handles stopped or missing distros." - } - - $shutdownResult = Invoke-Wsl -Arguments @('--shutdown') - if ($shutdownResult.ExitCode -ne 0) { - Write-GatewayLog "Ignoring shutdown exit code $($shutdownResult.ExitCode); unregister will still be attempted." - } - Start-Sleep -Seconds 2 - - $unregisterResult = Invoke-Wsl -Arguments @('--unregister', $DistroName) - if ($unregisterResult.ExitCode -ne 0 -and -not (Test-DistroNotFound $unregisterResult.Output)) { - Write-GatewayResult ` - -Succeeded $false ` - -ExitCode $unregisterResult.ExitCode ` - -Message "Failed to unregister WSL distro '$DistroName'." ` - -Details $unregisterResult.Output - exit $unregisterResult.ExitCode - } - - if ($unregisterResult.ExitCode -ne 0) { - Write-GatewayLog "Treating missing distro '$DistroName' as already removed." - } - - Remove-GatewayDirectory - - Complete-GatewayCleanup -Message "Local WSL gateway '$DistroName' removed." -} catch { - $message = $_.Exception.Message - Write-GatewayLog "Local gateway cleanup failed: $message" - try { "[$(Get-Date -Format 'o')] $message" | Out-File -LiteralPath $errorPath -Encoding UTF8 -Force } catch {} - Write-GatewayResult -Succeeded $false -ExitCode 1 -Message $message - Write-Warning $message - exit 1 -} diff --git a/scripts/build-inno-local.ps1 b/scripts/build-inno-local.ps1 deleted file mode 100644 index d882fd0bd..000000000 --- a/scripts/build-inno-local.ps1 +++ /dev/null @@ -1,198 +0,0 @@ -<# -.SYNOPSIS - Build local OpenClaw Companion Inno installers for quick validation. - -.DESCRIPTION - Publishes the tray app into a production-style layout, then runs ISCC to - create local unsigned installers. - - Use -NoPublish after changing only installer.iss or docs/tests; it reuses - the existing publish-local-* payloads and only recompiles Inno. - -.EXAMPLE - .\scripts\build-inno-local.ps1 -Arch x64 -Fast - .\scripts\build-inno-local.ps1 -Arch All - .\scripts\build-inno-local.ps1 -Arch x64 -NoPublish -Fast -#> - -[CmdletBinding()] -param( - [ValidateSet("x64", "arm64", "All")] - [string]$Arch = "x64", - - [ValidateSet("Debug", "Release")] - [string]$Configuration = "Release", - - [string]$Version, - - [switch]$NoPublish, - - [switch]$Fast, - - [switch]$InstallInno -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") -Set-Location $repoRoot - -function Write-Step { - param([string]$Message) - Write-Host "`n=== $Message ===" -ForegroundColor Cyan -} - -function Resolve-InnoCompiler { - $candidates = @( - "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe", - "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", - "$env:ProgramFiles\Inno Setup 6\ISCC.exe" - ) - - foreach ($candidate in $candidates) { - if ($candidate -and (Test-Path -LiteralPath $candidate)) { - return (Resolve-Path -LiteralPath $candidate).Path - } - } - - $command = Get-Command ISCC.exe -ErrorAction SilentlyContinue - if ($command) { - return $command.Source - } - - if ($InstallInno) { - Write-Step "Installing Inno Setup with winget" - winget install --id JRSoftware.InnoSetup -e --accept-source-agreements --accept-package-agreements --disable-interactivity - if ($LASTEXITCODE -ne 0) { - throw "winget failed to install Inno Setup." - } - return Resolve-InnoCompiler - } - - throw "Inno Setup compiler (ISCC.exe) was not found. Install it, or rerun with -InstallInno." -} - -function Get-RidForArch { - param([string]$Architecture) - if ($Architecture -eq "arm64") { - return "win-arm64" - } - return "win-x64" -} - -function Publish-ArchitecturePayload { - param( - [string]$Architecture, - [string]$RuntimeIdentifier, - [string]$PublishVersion - ) - - $publishDir = Join-Path $repoRoot "publish-local-$Architecture" - - Write-Step "Publishing $Architecture payload" - Remove-Item -LiteralPath $publishDir -Recurse -Force -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Path $publishDir | Out-Null - - $trayPublishArgs = @( - ".\src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj", - "-c", $Configuration, - "-r", $RuntimeIdentifier, - "--self-contained", - "-o", $publishDir, - "-v:minimal" - ) - if ($PublishVersion) { - $trayPublishArgs += "-p:Version=$PublishVersion" - } - - dotnet publish @trayPublishArgs - if ($LASTEXITCODE -ne 0) { - throw "Tray publish failed for $Architecture." - } -} - -function Assert-PayloadReady { - param([string]$Architecture) - - $publishDir = Join-Path $repoRoot "publish-local-$Architecture" - $trayExe = Join-Path $publishDir "OpenClaw.Tray.WinUI.exe" - - if (-not (Test-Path -LiteralPath $trayExe)) { - throw "Missing tray payload at $trayExe. Rerun without -NoPublish." - } - - $setupExe = Get-ChildItem -LiteralPath $publishDir -Recurse -File -Filter "OpenClaw.SetupEngine.UI.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($setupExe) { - throw "SetupEngine.UI.exe should not be present in the installer payload: $($setupExe.FullName)" - } - - return $publishDir -} - -function Invoke-InnoCompiler { - param( - [string]$InnoCompiler, - [string]$Architecture, - [string]$PublishDir, - [string]$InstallerVersion - ) - - Write-Step "Compiling $Architecture installer" - - $args = @( - "/DMyAppVersion=$InstallerVersion", - "/DMyAppArch=$Architecture", - "/Dpublish=$PublishDir" - ) - - if ($Fast) { - $args += "/DMyCompression=zip" - $args += "/DMySolidCompression=no" - } - - $args += ".\installer.iss" - - & $InnoCompiler @args - if ($LASTEXITCODE -ne 0) { - throw "ISCC failed for $Architecture." - } -} - -$versionWasProvided = $PSBoundParameters.ContainsKey("Version") - -if (-not $Version) { - $versionScript = Join-Path $PSScriptRoot "Get-OpenClawVersion.ps1" - $Version = & $versionScript -Variable SemVer -} - -if (-not $Version) { - throw "Could not determine a version. Pass -Version explicitly." -} - -$iscc = Resolve-InnoCompiler -$architectures = if ($Arch -eq "All") { @("x64", "arm64") } else { @($Arch) } - -Write-Step "Using ISCC: $iscc" -Write-Host "Version: $Version" -Write-Host "Configuration: $Configuration" -Write-Host "Fast compression: $($Fast.IsPresent)" -Write-Host "No publish: $($NoPublish.IsPresent)" - -foreach ($architecture in $architectures) { - $rid = Get-RidForArch $architecture - if (-not $NoPublish) { - $publishVersion = if ($versionWasProvided) { $Version } else { $null } - Publish-ArchitecturePayload -Architecture $architecture -RuntimeIdentifier $rid -PublishVersion $publishVersion - } - - $payload = Assert-PayloadReady $architecture - Invoke-InnoCompiler -InnoCompiler $iscc -Architecture $architecture -PublishDir $payload -InstallerVersion $Version -} - -Write-Step "Built installers" -Get-ChildItem -Path (Join-Path $repoRoot "Output\OpenClawCompanion-Setup-*.exe") | - Sort-Object Name | - ForEach-Object { - "{0}`t{1:N2} MB`t{2}" -f $_.FullName, ($_.Length / 1MB), $_.LastWriteTime - } diff --git a/scripts/render-appinstaller.ps1 b/scripts/render-appinstaller.ps1 new file mode 100644 index 000000000..5c3117dd4 --- /dev/null +++ b/scripts/render-appinstaller.ps1 @@ -0,0 +1,176 @@ +<# +.SYNOPSIS + Renders installer/openclaw-companion.appinstaller.template into a release-ready + AppInstaller XML by substituting the {{TOKEN}} placeholders. + +.DESCRIPTION + Used by .github/workflows/appinstaller-feed-pr.yml after a stable release tag + to regenerate installer/appinstaller/openclaw-{x64,arm64}.appinstaller from + the signed MSIX release assets. Also runnable locally to validate template + renders before tagging a release. + + The rendered AppInstaller XML must validate against the AppInstaller schema + (http://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. + + The OpenClaw Companion MSIX is built with WindowsAppSDKSelfContained=true, + so the rendered AppInstaller intentionally has NO block. + Windows does not need to fetch a separate WindowsAppRuntime package. + +.PARAMETER Version + 4-part version string (e.g. "0.5.3.0"). Must match the MSIX . + Windows AppInstaller's update detector compares versions as 4-part values, so + a 1-3 part value produces "no update available" forever even though it parses. + +.PARAMETER Publisher + Publisher subject from the MSIX manifest. Must match the signing cert + Subject DN exactly. Example: + "CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, 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. + +.PARAMETER MsixUri + Absolute https:// URL of the matching architecture .msix release asset. + +.PARAMETER AppInstallerUri + Absolute https:// URL of THIS rendered .appinstaller file on the stable + channel (e.g. + https://raw.githubusercontent.com/openclaw/openclaw-windows-node/main/installer/appinstaller/openclaw-x64.appinstaller). + Embedded inside the AppInstaller so Windows AppInstaller knows where to poll + for future updates. + +.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=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US' ` + -IdentityName OpenClaw.Companion ` + -ProcessorArchitecture x64 ` + -MsixUri https://github.com/openclaw/openclaw-windows-node/releases/download/v0.5.3/OpenClawCompanion-0.5.3-win-x64.msix ` + -AppInstallerUri https://raw.githubusercontent.com/openclaw/openclaw-windows-node/main/installer/appinstaller/openclaw-x64.appinstaller ` + -OutputPath installer/appinstaller/openclaw-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] $AppInstallerUri, + [Parameter(Mandatory)] [string] $OutputPath, + [switch] $AllowHttpForLocalTest +) + +$ErrorActionPreference = 'Stop' + +$parts = $Version.Split('.') +if ($parts.Length -ne 4) { + throw "Version must be 4-part (X.Y.Z.W). Got: '$Version'" +} +foreach ($p in $parts) { + $parsed = 0 + if (-not [int]::TryParse($p, [ref]$parsed)) { + throw "Version segment '$p' is not an integer." + } +} + +if ([string]::IsNullOrWhiteSpace($IdentityName)) { + throw "IdentityName must not be empty." +} + +foreach ($pair in @( + @{ Name = 'MsixUri'; Value = $MsixUri }, + @{ 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 with +# literal commas/quotes or a URI with percent-encoded characters. +$rendered = $template +$rendered = $rendered.Replace('{{VERSION}}', $Version) +$rendered = $rendered.Replace('{{PUBLISHER}}', $Publisher) +$rendered = $rendered.Replace('{{IDENTITY_NAME}}', $IdentityName) +$rendered = $rendered.Replace('{{PROCESSOR_ARCHITECTURE}}', $ProcessorArchitecture) +$rendered = $rendered.Replace('{{MSIX_URI}}', $MsixUri) +$rendered = $rendered.Replace('{{APPINSTALLER_URI}}', $AppInstallerUri) +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'." +} + +# We do not emit a block (WindowsAppSDKSelfContained=true), so +# guard against accidentally re-introducing one in the template. +if ($null -ne $xml.AppInstaller.Dependencies) { + throw "Rendered XML must not contain a block (MSIX is self-contained)." +} + +$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 " AppInstaller URI: $AppInstallerUri" diff --git a/scripts/setup-dev-msix-cert.ps1 b/scripts/setup-dev-msix-cert.ps1 new file mode 100644 index 000000000..c07cf653d --- /dev/null +++ b/scripts/setup-dev-msix-cert.ps1 @@ -0,0 +1,186 @@ +<# +.SYNOPSIS + Provisions a self-signed certificate and PFX for locally building and + installing a signed OpenClaw Companion MSIX. + +.DESCRIPTION + Reads the Publisher subject from src\OpenClaw.Tray.WinUI\Package.appxmanifest, + creates a code-signing certificate with that exact subject (if one doesn't + already exist), adds the public cert to LocalMachine\TrustedPeople so AppX + deployment will accept packages signed with it, and exports the cert + + private key to %LOCALAPPDATA%\OpenClawTray\dev-msix.pfx. + + The OpenClaw.Tray.WinUI project auto-detects the PFX at that well-known + path: when present, the MSIX build signs with it; when absent, the MSIX + is unsigned. So after running this script once, a normal build/publish + of the tray produces a signed .msix that installs with plain + Add-AppxPackage -- no -AllowUnsigned, no env-var plumbing. + + The script is idempotent: re-running reuses the existing cert and just + re-exports the PFX. Pass -Force to discard and recreate. Requires an + elevated PowerShell because writing to LocalMachine\TrustedPeople + requires admin rights. + +.PARAMETER Force + Delete any existing matching cert (both stores) and PFX, then create fresh. + +.PARAMETER SkipTrust + Create the cert and PFX but skip the LocalMachine\TrustedPeople step. + Useful when running non-elevated to inspect what will be created. A + package signed with this cert will not be installable until the public + cert is separately imported into LocalMachine\TrustedPeople. + +.EXAMPLE + # In an elevated PowerShell: + .\scripts\setup-dev-msix-cert.ps1 + +.EXAMPLE + .\scripts\setup-dev-msix-cert.ps1 -Force +#> +[CmdletBinding()] +param( + [switch]$Force, + [switch]$SkipTrust +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +$manifestPath = Join-Path $repoRoot 'src\OpenClaw.Tray.WinUI\Package.appxmanifest' +$pfxDir = Join-Path $env:LOCALAPPDATA 'OpenClawTray' +$pfxPath = Join-Path $pfxDir 'dev-msix.pfx' +# Password is also hardcoded in OpenClaw.Tray.WinUI.csproj's +# PackageCertificatePassword. If you change one, change both. +$pfxPassword = 'openclaw-dev' + +function Write-Step([string]$Message) { + Write-Host "`n=== $Message ===" -ForegroundColor Cyan +} + +if (-not (Test-Path $manifestPath)) { + throw "Manifest not found at $manifestPath" +} + +# Pull Publisher from the manifest so this stays in sync if Publisher ever changes. +[xml]$manifest = Get-Content -LiteralPath $manifestPath +$ns = New-Object System.Xml.XmlNamespaceManager $manifest.NameTable +$ns.AddNamespace('p', 'http://schemas.microsoft.com/appx/manifest/foundation/windows10') +$identity = $manifest.SelectSingleNode('/p:Package/p:Identity', $ns) +if (-not $identity) { throw "Could not find in $manifestPath" } +$subject = $identity.Publisher +if ([string]::IsNullOrWhiteSpace($subject)) { throw "Identity/@Publisher is empty in $manifestPath" } + +Write-Step 'Manifest Publisher' +Write-Host $subject + +# Admin check is only needed when we're going to write to LocalMachine. +$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $isAdmin -and -not $SkipTrust) { + throw "This script must run as Administrator to add the certificate to LocalMachine\TrustedPeople. " + + "Re-run from an elevated PowerShell, or pass -SkipTrust to only create the cert in CurrentUser\My." +} + +# Match on Subject + Code Signing EKU (1.3.6.1.5.5.7.3.3). +$codeSigningOid = '1.3.6.1.5.5.7.3.3' +$existing = Get-ChildItem Cert:\CurrentUser\My | Where-Object { + $_.Subject -eq $subject -and + ($_.EnhancedKeyUsageList | ForEach-Object { $_.ObjectId }) -contains $codeSigningOid +} + +if ($existing -and $Force) { + Write-Step 'Removing existing certificate(s) (-Force)' + foreach ($c in $existing) { + Write-Host "Removing CurrentUser\My\$($c.Thumbprint)" + Remove-Item "Cert:\CurrentUser\My\$($c.Thumbprint)" -Force + $trusted = Get-ChildItem Cert:\LocalMachine\TrustedPeople -ErrorAction SilentlyContinue | + Where-Object { $_.Thumbprint -eq $c.Thumbprint } + if ($trusted) { + Write-Host "Removing LocalMachine\TrustedPeople\$($c.Thumbprint)" + Remove-Item "Cert:\LocalMachine\TrustedPeople\$($c.Thumbprint)" -Force + } + } + if (Test-Path $pfxPath) { + Write-Host "Removing $pfxPath" + Remove-Item $pfxPath -Force + } + $existing = $null +} + +if ($existing) { + $cert = $existing | Sort-Object NotAfter -Descending | Select-Object -First 1 + Write-Step 'Reusing existing certificate' +} else { + Write-Step 'Creating new self-signed certificate' + # Explicit TextExtension entries (Code Signing EKU + empty Basic Constraints) + # guard against quirks in older Windows PowerShell where -Type CodeSigningCert + # alone has occasionally produced certs AppX deployment rejects. + $cert = New-SelfSignedCertificate ` + -Type CodeSigningCert ` + -Subject $subject ` + -KeyUsage DigitalSignature ` + -KeyAlgorithm RSA ` + -KeyLength 2048 ` + -HashAlgorithm SHA256 ` + -NotAfter (Get-Date).AddYears(3) ` + -FriendlyName 'OpenClaw Dev MSIX Signing' ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}') +} + +Write-Host "Thumbprint: $($cert.Thumbprint)" +Write-Host "NotAfter: $($cert.NotAfter)" + +if (-not $SkipTrust) { + $alreadyTrusted = Get-ChildItem Cert:\LocalMachine\TrustedPeople -ErrorAction SilentlyContinue | + Where-Object { $_.Thumbprint -eq $cert.Thumbprint } + if ($alreadyTrusted) { + Write-Step 'LocalMachine\TrustedPeople already contains this cert' + } else { + Write-Step 'Trusting certificate in LocalMachine\TrustedPeople' + # Public-only (.cer) import. The private key lives in CurrentUser\My + # (and the exported PFX); TrustedPeople only needs the public cert + # for AppX to validate package signatures. + $tempCer = Join-Path $env:TEMP "openclaw-dev-msix-$($cert.Thumbprint).cer" + try { + Export-Certificate -Cert $cert -FilePath $tempCer | Out-Null + Import-Certificate -FilePath $tempCer -CertStoreLocation 'Cert:\LocalMachine\TrustedPeople' | Out-Null + } finally { + if (Test-Path $tempCer) { Remove-Item $tempCer -Force } + } + } +} + +Write-Step "Exporting PFX to $pfxPath" +if (-not (Test-Path $pfxDir)) { + New-Item -ItemType Directory -Path $pfxDir -Force | Out-Null +} +$securePwd = ConvertTo-SecureString -String $pfxPassword -Force -AsPlainText +# Always re-export so the PFX matches the currently-active cert. Export-PfxCertificate +# overwrites without prompting. +Export-PfxCertificate -Cert $cert -FilePath $pfxPath -Password $securePwd -Force | Out-Null +Write-Host "PFX written. The OpenClaw.Tray.WinUI MSBuild project auto-detects this file." + +Write-Step 'Next steps' +Write-Host @" +Build the MSIX (PackageMsix is already true in the project): + + dotnet publish src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj -c Debug -r win-x64 --nologo + +Install: + + Add-AppxPackage -Path .\src\OpenClaw.Tray.WinUI\AppPackages\<...>\<...>.msix + +Disable dev signing (revert to unsigned output) without removing the cert: + + Remove-Item '$pfxPath' + +Fully clean up dev signing artifacts: + + .\scripts\setup-dev-msix-cert.ps1 -Force # then delete the PFX, or: + Remove-Item '$pfxPath' -Force + Get-ChildItem Cert:\CurrentUser\My\$($cert.Thumbprint) | Remove-Item + Get-ChildItem Cert:\LocalMachine\TrustedPeople\$($cert.Thumbprint) | Remove-Item +"@ + diff --git a/scripts/test-appinstaller-update.ps1 b/scripts/test-appinstaller-update.ps1 new file mode 100644 index 000000000..6d43d0425 --- /dev/null +++ b/scripts/test-appinstaller-update.ps1 @@ -0,0 +1,175 @@ +<# +.SYNOPSIS + Simulates a non-Store .appinstaller upgrade by hosting two signed 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 before a release + tag goes out; if it fails, the same failure will reach every user who + installs from the stable architecture-specific AppInstaller URL. + + Steps: + 1. Launch a tiny HTTP server (HttpListener) on localhost:$Port that serves + the two MSIX files plus a rendered .appinstaller pointing at vN+1. + 2. Render an "old" .appinstaller pointing at vN and install it (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 — same call the OS performs in the AutomaticBackgroundTask. + 5. Assert Get-AppxPackage reports the new Version. + 6. Tear down. + + Requires signed MSIX packages — Add-AppxPackage / AddPackageByAppInstaller + refuse unsigned packages outside a developer-mode loopback. Use the dev + signing cert from scripts/setup-dev-msix-cert.ps1 to produce vN and vN+1. + +.PARAMETER MsixVnPath + Path to the "older" signed .msix (used as the seed install). + +.PARAMETER MsixVn1Path + Path to the "newer" signed .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=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US', + [int] $Port = 8765 +) + +$ErrorActionPreference = 'Stop' + +foreach ($p in @($MsixVnPath, $MsixVn1Path)) { + if (-not (Test-Path $p)) { throw "MSIX not found: $p" } +} + +$tmp = Join-Path ([System.IO.Path]::GetTempPath()) "openclaw-appinstaller-test-$(Get-Random)" +New-Item -ItemType Directory -Force -Path $tmp | Out-Null + +try { + Copy-Item $MsixVnPath (Join-Path $tmp 'vN.msix') + Copy-Item $MsixVn1Path (Join-Path $tmp 'vNplus1.msix') + + $baseUri = "http://127.0.0.1:$Port" + $repoRoot = Split-Path -Parent $PSScriptRoot + + function Render-AppInstaller { + param([string]$Version, [string]$MsixFileName, [string]$OutputPath) + & "$repoRoot\scripts\render-appinstaller.ps1" ` + -Version $Version ` + -Publisher $Publisher ` + -ProcessorArchitecture x64 ` + -MsixUri "$baseUri/$MsixFileName" ` + -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 would 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 upgrade via PackageManager (same call the OS uses). + 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/validate-appinstaller-hosting.ps1 b/scripts/validate-appinstaller-hosting.ps1 new file mode 100644 index 000000000..ae508036e --- /dev/null +++ b/scripts/validate-appinstaller-hosting.ps1 @@ -0,0 +1,221 @@ +<# +.SYNOPSIS + Validates a hosted AppInstaller XML and its referenced MSIX URL before + promoting a release. + +.DESCRIPTION + Windows AppInstaller is strict about hosted metadata and package assets. + This script checks the stable .appinstaller URL (or a local file), parses + its MainPackage URI when -MsixUri is not provided, then validates the MSIX + endpoint for content-type, content-length, and range-request support. + + Intended for release operators before promoting + installer/appinstaller/openclaw-{x64,arm64}.appinstaller to the stable + feed location, and for the appinstaller-feed-pr workflow. + +.PARAMETER AppInstallerUri + Stable hosted .appinstaller URL, e.g. + https://raw.githubusercontent.com/openclaw/openclaw-windows-node/main/installer/appinstaller/openclaw-x64.appinstaller. + +.PARAMETER MsixUri + Optional MSIX URL. When omitted, the script fetches AppInstallerUri (or + reads AppInstallerPath) and uses the MainPackage Uri attribute. + +.PARAMETER AppInstallerPath + Optional local .appinstaller file to parse instead of fetching AppInstallerUri. + Used by the feed-update PR workflow before the rendered file is merged to + main at the stable raw GitHub location. + +.PARAMETER AllowGitHubContentTypes + Compatibility switch for GitHub-hosted release assets. GitHub release + downloads serve MSIX files as application/octet-stream and raw .appinstaller + files as text/plain. This switch keeps strict validation as the default + while allowing the GitHub-hosted production flow. + +.EXAMPLE + ./scripts/validate-appinstaller-hosting.ps1 ` + -AppInstallerUri https://raw.githubusercontent.com/openclaw/openclaw-windows-node/main/installer/appinstaller/openclaw-x64.appinstaller ` + -AllowGitHubContentTypes +#> + +[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." + } + $parsed = 0L + if (-not [long]::TryParse($contentLength, [ref]$parsed)) { + 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..1947d4d7c 100644 --- a/scripts/validate-msix-storage-paths.ps1 +++ b/scripts/validate-msix-storage-paths.ps1 @@ -38,8 +38,9 @@ - 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. + - Pre-MSIX cleanup paths (now removed: scripts/Uninstall-LocalGateway.ps1 + + installer.iss) targeted real paths unconditionally; an MSIX-flavored + cleanup path will need the same behavior. - Recovery: scripts/validate-wsl-gateway-uninstall.ps1 -Scenario Full -ConfirmDestructiveClean is still relevant for orphaned state. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 30fd2cd1a..20e91bd8b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -10,6 +10,18 @@ all + + + true diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 515b25e14..bd5972415 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -44,6 +44,21 @@ + + + + + + + - - + + - - + + + Text="No $(OpenClawVCRuntimeArch) VC++ Runtime DLLs were found under '$(_OpenClawVCRedistVersionRoot)\$(OpenClawVCRuntimeArch)\Microsoft.VC*.CRT\'. Install the C++ Redistributable Update Visual Studio component." /> @@ -98,6 +113,29 @@ SkipUnchangedFiles="true" /> + + + + + %(Filename)%(Extension) + PreserveNewest + + + + diff --git a/src/OpenClaw.SetupEngine.UI/LogFileLauncher.cs b/src/OpenClaw.SetupEngine.UI/LogFileLauncher.cs index 3713f0f3d..342581e2f 100644 --- a/src/OpenClaw.SetupEngine.UI/LogFileLauncher.cs +++ b/src/OpenClaw.SetupEngine.UI/LogFileLauncher.cs @@ -95,7 +95,7 @@ private static bool TryStrip(string path, string prefix, out string rest) } catch { - // Unpackaged process — no virtualization in play. + // Process is unpackaged or the package identity API is unavailable; no virtualization in play. return null; } } diff --git a/src/OpenClaw.Shared/SettingsData.cs b/src/OpenClaw.Shared/SettingsData.cs index 7fb8ac72b..ea26a61af 100644 --- a/src/OpenClaw.Shared/SettingsData.cs +++ b/src/OpenClaw.Shared/SettingsData.cs @@ -94,7 +94,6 @@ public record class SettingsData public bool? McpOnlyMode { get; set; } public string? PreferredGatewayId { get; set; } public bool HasSeenActivityStreamTip { get; set; } = false; - public string? SkippedUpdateTag { get; set; } public bool NotifyChatResponses { get; set; } = true; public bool PreferStructuredCategories { get; set; } = true; /// diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 37ddf70a4..630796190 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -25,7 +25,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Updatum; using WinUIEx; using SetupCompletedEventArgs = OpenClaw.SetupEngine.UI.SetupCompletedEventArgs; using SetupWindow = OpenClaw.SetupEngine.UI.SetupWindow; @@ -34,12 +33,6 @@ namespace OpenClawTray; public partial class App : Application, OpenClawTray.Services.IAppCommands { - internal static readonly UpdatumManager AppUpdater = new("openclaw", "openclaw-windows-node") - { - FetchOnlyLatestRelease = true, - InstallUpdateSingleFileExecutableName = "OpenClaw.Tray.WinUI", - }; - private TrayIcon? _trayIcon; private GatewayConnectionManager? _connectionManager; private GatewayRegistry? _gatewayRegistry; @@ -102,8 +95,7 @@ public void EnsureSshTunnelStarted() _settings.SshTunnelHost, _settings.SshTunnelRemotePort, _settings.SshTunnelLocalPort, - includeBrowserProxyForward, - _settings.SshTunnelSshPort); + includeBrowserProxyForward); } /// @@ -138,7 +130,6 @@ public IntPtr GetHubWindowHandle() private Microsoft.UI.Dispatching.DispatcherQueue? _dispatcherQueue; private AppState? _appState; internal AppState? AppState => _appState; - private UpdateCoordinator? _updateCoordinator; private GatewayService? _gatewayService; private CancellationTokenSource? _deepLinkCts; private bool _isExiting; @@ -226,12 +217,6 @@ public App() Logger.Warn($"[App] Ignoring invalid OPENCLAW_LANGUAGE value: {langOverride}"); } - // Wire the GatewayHostAccess localization indirection to LocalizationHelper. - // The classifier defaults to identity (returns the resource key as-is) for unit-test - // contexts that lack a WinUI runtime; in-app we point it at the real resource lookup. - GatewayHostAccessLocalization.GetString = LocalizationHelper.GetString; - GatewayHostAccessLocalization.Format = (key, args) => LocalizationHelper.Format(key, args); - InitializeComponent(); s_runMarker.Check(); @@ -274,14 +259,12 @@ private static void WaitForRestartSourceIfRequested(string[] args) if (!process.HasExited) process.WaitForExit(TimeSpan.FromSeconds(60)); } - // slopwatch-ignore: SW003 Cleanup is best-effort; failure cannot improve caller state and the original outcome is preserved. catch (ArgumentException) { // The source process already exited. } catch (Exception ex) { - // slopwatch-ignore: SW003 Diagnostic logging fallback is best-effort and logging failure must not cascade. try { Logger.Warn($"Post-setup restart wait for PID {pid} failed: {ex.Message}"); } catch { } } } @@ -384,11 +367,6 @@ private async Task OnLaunchedAsync(LaunchActivatedEventArgs args) // two test runs against the same data dir would otherwise pick different // mutex names — and `Math.Abs(int.MinValue)` overflows. Use a stable // SHA-256 prefix instead. - // NOTE: The bare "OpenClawTray" mutex name is also referenced by - // installer.iss `AppMutex=` for install/uninstall race coordination - // (round 2, Scott #5). The suffixed test-isolation variant is - // intentionally not covered by AppMutex — production installs only - // ever use the unsuffixed name. var mutexName = "OpenClawTray"; if (DataDirOverride is not null) { @@ -445,20 +423,6 @@ _dispatcherQueue is null // Central observable model + gateway event handler. _appState = new AppState(_dispatcherQueue); - _updateCoordinator = new UpdateCoordinator( - AppUpdater, - _appState, - _settings, - () => - { - XamlRoot? r = null; - if (_hubWindow != null && !_hubWindow.IsClosed) - r = (_hubWindow.Content as FrameworkElement)?.XamlRoot; - return r ?? (_keepAliveWindow?.Content as FrameworkElement)?.XamlRoot; - }, - refreshStatus: UpdateStatusDetailWindow, - exit: Exit); - _appState.UpdateInfo = UpdateCoordinator.BuildInitialInfo(); _gatewayService = new GatewayService(_appState, _dispatcherQueue!); _gatewayService.ConnectionStatusChanged += OnGatewayConnectionStatusChanged; _gatewayService.AuthenticationFailed += OnGatewayAuthenticationFailed; @@ -478,29 +442,15 @@ _dispatcherQueue is null // Register URI scheme on first run DeepLinkHandler.RegisterUriScheme(); - // Anchor the WinUI runtime so transient windows (UpdateDialog, - // setup wizard, etc.) don't terminate the process when closed. + // Anchor the WinUI runtime so transient windows (setup wizard, + // dialogs, etc.) don't terminate the process when closed. // WinUI 3 Desktop's default DispatcherShutdownMode is - // OnLastWindowClose — without this override, closing the - // UpdateDialog on the startup path (when it is the only window) - // would shut down the WinUI runtime mid-flight and kill the - // in-progress download/extraction. We still control shutdown + // OnLastWindowClose — without this override, closing a transient + // dialog on the startup path (when it is the only window) would + // shut down the WinUI runtime mid-flight. We control shutdown // explicitly via Application.Exit(). DispatcherShutdownMode = DispatcherShutdownMode.OnExplicitShutdown; - // Check for updates before launching. Skip in test instances — no UI dialogs, - // no network calls, no startup delay. - if (DataDirOverride is null && - Environment.GetEnvironmentVariable("OPENCLAW_SKIP_UPDATE_CHECK") != "1") - { - var shouldLaunch = await _updateCoordinator.CheckForUpdatesAsync(); - if (!shouldLaunch) - { - Exit(); - return; - } - } - // Register toast activation handler ToastNotificationManagerCompat.OnActivated += OnToastActivated; @@ -519,7 +469,7 @@ _dispatcherQueue is null ShowSurfaceImprovementsTipIfNeeded(); // Initialize connection manager before setup flow. - _gatewayRegistry = new GatewayRegistry(SettingsManager.SettingsDirectoryPath, logger: new AppLogger()); + _gatewayRegistry = new GatewayRegistry(SettingsManager.SettingsDirectoryPath); _gatewayRegistry.Load(); var credentialResolver = new CredentialResolver(DeviceIdentityFileReader.Instance); var clientFactory = new GatewayClientFactory(); @@ -631,8 +581,7 @@ _dispatcherQueue is null // hosts. Fire-and-forget on a background task so a slow LxssManager at // cold logon never delays InitializeGatewayClient. The keepalive itself // runs detached from the tray — see WslDistroKeepAlive in LocalGatewaySetup.cs. - var wslKeepAlive = new WslGatewayKeepAliveService(() => _settings, () => _gatewayRegistry); - _ = Task.Run(wslKeepAlive.TryEnsureAsync); + _ = Task.Run(TryEnsureLocalGatewayKeepAliveAsync); InitializeGatewayClient(); // Pre-warm chat window (WebView2 init takes 1-3s, do it now so left-click is instant) @@ -941,7 +890,6 @@ private void OnTrayMenuItemClicked(object? sender, string action) case "history": ShowHub("channels"); break; case "activity": ShowHub("channels"); break; case "healthcheck": _ = RunHealthCheckAsync(userInitiated: true); break; - case "checkupdates": _ = _updateCoordinator!.CheckForUpdatesUserInitiatedAsync(); break; case "settings": ShowSettings(); break; case "setup": _ = ShowOnboardingAsync(); break; case "autostart": ToggleAutoStart(); break; @@ -1427,8 +1375,7 @@ private void InitializeGatewayClient(bool useBootstrapHandoffAuth = false) _settings.SshTunnelLocalPort, _settings.NodeBrowserProxyEnabled && SshTunnelCommandLine.CanForwardBrowserProxyPort( - _settings.SshTunnelRemotePort, _settings.SshTunnelLocalPort), - _settings.SshTunnelSshPort) + _settings.SshTunnelRemotePort, _settings.SshTunnelLocalPort)) : null, }; _gatewayRegistry.AddOrUpdate(record); @@ -1615,7 +1562,6 @@ private void TryMigrateLegacyGatewaySettings(string gatewayUrl, IOpenClawLogger _settings.UseSshTunnel, _settings.SshTunnelUser, _settings.SshTunnelHost, - _settings.SshTunnelSshPort, _settings.SshTunnelRemotePort, _settings.SshTunnelLocalPort, SettingsManager.SettingsDirectoryPath, @@ -3336,7 +3282,6 @@ void IAppCommands.Disconnect() } void IAppCommands.ShowVoiceOverlay() => ShowHub("voice"); void IAppCommands.ShowChat() => ShowChatWindow(); - void IAppCommands.CheckForUpdates() => _ = _updateCoordinator!.CheckForUpdatesUserInitiatedAsync(); void IAppCommands.ShowOnboarding() => _ = ShowOnboardingAsync(); void IAppCommands.ShowConnectionStatus() => ShowConnectionStatusWindow(); void IAppCommands.NotifySettingsSaved() => OnSettingsSaved(this, EventArgs.Empty); @@ -3619,7 +3564,6 @@ private void HandleDeepLink(string uri) OpenSettings = ShowSettings, OpenSetup = () => _ = ShowOnboardingAsync(), RunHealthCheck = () => RunHealthCheckAsync(userInitiated: true), - CheckForUpdates = _updateCoordinator!.CheckForUpdatesUserInitiatedAsync, OpenLogFile = OpenLogFile, OpenLogFolder = OpenLogFolder, OpenConfigFolder = OpenConfigFolder, @@ -3898,8 +3842,7 @@ _settings.SshTunnelRemotePort is < 1 or > 65535 || _settings.SshTunnelHost, _settings.SshTunnelRemotePort, _settings.SshTunnelLocalPort, - includeBrowserProxy, - _settings.SshTunnelSshPort); + includeBrowserProxy); DiagnosticsJsonlService.Write("tunnel.ensure_started", new { status = _sshTunnelService.Status.ToString(), @@ -3956,8 +3899,7 @@ private async Task OnSshTunnelExitedAsync(int exitCode) _settings.SshTunnelHost, _settings.SshTunnelRemotePort, _settings.SshTunnelLocalPort, - restartBrowserProxy, - _settings.SshTunnelSshPort); + restartBrowserProxy); Logger.Info("SSH tunnel restarted successfully"); DiagnosticsJsonlService.Write("tunnel.restart_succeeded", new { 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/Dialogs/DownloadProgressDialog.cs b/src/OpenClaw.Tray.WinUI/Dialogs/DownloadProgressDialog.cs deleted file mode 100644 index c66b2e466..000000000 --- a/src/OpenClaw.Tray.WinUI/Dialogs/DownloadProgressDialog.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media; -using OpenClawTray.Helpers; -using Updatum; - -namespace OpenClawTray.Dialogs; - -public sealed class DownloadProgressDialog -{ - private Window? _window; - private readonly UpdatumManager? _updater; - - public DownloadProgressDialog(UpdatumManager updater) - { - _updater = updater; - } - - public void ShowAsync() - { - _window = new Window { Title = LocalizationHelper.GetString("WindowTitle_Downloading") }; - _window.SystemBackdrop = new MicaBackdrop(); - - var panel = new StackPanel { Padding = new Thickness(20) }; - var progressText = new TextBlock { Text = LocalizationHelper.GetString("Download_ProgressText"), Margin = new Thickness(0, 0, 0, 10) }; - var progressBar = new ProgressBar { IsIndeterminate = true }; - - panel.Children.Add(progressText); - panel.Children.Add(progressBar); - _window.Content = panel; - - // Size and center the window - _window.AppWindow.Resize(new global::Windows.Graphics.SizeInt32(400, 200)); - var displayArea = Microsoft.UI.Windowing.DisplayArea.Primary; - var centerX = (displayArea.WorkArea.Width - 400) / 2; - var centerY = (displayArea.WorkArea.Height - 200) / 2; - _window.AppWindow.Move(new global::Windows.Graphics.PointInt32(centerX, centerY)); - - _window.Activate(); - } - - public void Close() => _window?.Close(); -} diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs b/src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs deleted file mode 100644 index fb48e9f76..000000000 --- a/src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs +++ /dev/null @@ -1,122 +0,0 @@ -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media; -using OpenClaw.Shared; -using OpenClawTray.Helpers; -using OpenClawTray.Services; -using System; -using System.Threading.Tasks; -using WinUIEx; - -namespace OpenClawTray.Dialogs; - -public enum UpdateDialogResult -{ - Download, - Skip, - RemindLater -} - -/// -/// Dialog showing available update with release notes. -/// Built directly in a WindowEx (no ContentDialog/XamlRoot issues). -/// -public sealed class UpdateDialog : WindowEx -{ - private readonly TaskCompletionSource _tcs = new(); - private UpdateDialogResult _result = UpdateDialogResult.RemindLater; - - public UpdateDialog(string version, string changelog) - { - Title = LocalizationHelper.GetString("WindowTitle_Update"); - this.SetWindowSize(560, 420); - this.CenterOnScreen(); - this.SetIcon("Assets\\openclaw.ico"); - SystemBackdrop = new MicaBackdrop(); - - var root = new Grid - { - Padding = new Thickness(32), - RowSpacing = 16 - }; - root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); - root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); - - // Header - var header = new TextBlock - { - Text = string.Format(LocalizationHelper.GetString("Update_VersionAvailable"), version), - Style = (Style)Application.Current.Resources["SubtitleTextBlockStyle"] - }; - Grid.SetRow(header, 0); - root.Children.Add(header); - - // Content - var content = new StackPanel { Spacing = 12 }; - - var currentVersion = AppVersionInfo.Version; - content.Children.Add(new TextBlock - { - Text = string.Format(LocalizationHelper.GetString("Update_CurrentVersion"), currentVersion), - Foreground = (Brush)Application.Current.Resources["TextFillColorSecondaryBrush"] - }); - - content.Children.Add(new TextBlock - { - Text = LocalizationHelper.GetString("Update_WhatsNew"), - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold - }); - - content.Children.Add(new ScrollViewer - { - MaxHeight = 200, - Content = new TextBlock - { - Text = changelog, - TextWrapping = TextWrapping.Wrap - } - }); - - Grid.SetRow(content, 1); - root.Children.Add(content); - - // Buttons - var buttonPanel = new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - Spacing = 8 - }; - - var skipButton = new Button { Content = LocalizationHelper.GetString("Update_SkipButton") }; - skipButton.Click += (s, e) => { Logger.Info("[Update] User clicked 'Skip'"); _result = UpdateDialogResult.Skip; Close(); }; - buttonPanel.Children.Add(skipButton); - - var laterButton = new Button { Content = LocalizationHelper.GetString("Update_RemindLaterButton") }; - laterButton.Click += (s, e) => { Logger.Info("[Update] User clicked 'Remind Later'"); _result = UpdateDialogResult.RemindLater; Close(); }; - buttonPanel.Children.Add(laterButton); - - var downloadButton = new Button - { - Content = LocalizationHelper.GetString("Update_DownloadButton"), - Style = (Style)Application.Current.Resources["AccentButtonStyle"] - }; - downloadButton.Click += (s, e) => { Logger.Info("[Update] User clicked 'Download'"); _result = UpdateDialogResult.Download; Close(); }; - buttonPanel.Children.Add(downloadButton); - - Grid.SetRow(buttonPanel, 2); - root.Children.Add(buttonPanel); - - Content = root; - Closed += (s, e) => _tcs.TrySetResult(_result); - - Logger.Info($"[Update] Update dialog shown for version {version}"); - } - - public Task ShowAsync() - { - Activate(); - return _tcs.Task; - } -} diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index 64f70ccf6..2f8a61356 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -22,21 +22,49 @@ win-arm64 - - - None + + + MSIX true - app.manifest + + + Dev - - MSIX - true - false true Never SideloadOnly + + $(LOCALAPPDATA)\OpenClawTray\dev-msix.pfx + false + true + $(OpenClawDevMsixPfx) + openclaw-dev @@ -65,7 +93,6 @@ - @@ -77,6 +104,13 @@ + + @@ -93,11 +127,15 @@ - + this, the target would silently skip and the baseline version in the source + manifest would ship in the .msix package. --> + + + + + <_OpenClawIdentityName Condition="'$(ReleaseChannel)' == 'Stable'">OpenClaw.Companion + <_OpenClawIdentityName Condition="'$(ReleaseChannel)' == 'Alpha'">OpenClaw.Companion.Alpha + <_OpenClawIdentityName Condition="'$(ReleaseChannel)' == 'Dev'">OpenClaw.Companion.Dev + + <_OpenClawDisplayName Condition="'$(ReleaseChannel)' == 'Stable'">OpenClaw Companion + <_OpenClawDisplayName Condition="'$(ReleaseChannel)' == 'Alpha'">OpenClaw Companion Alpha + <_OpenClawDisplayName Condition="'$(ReleaseChannel)' == 'Dev'">OpenClaw Companion Dev + + <_OpenClawRemoveAutoUpdate Condition="'$(ReleaseChannel)' == 'Dev'">true + <_OpenClawRemoveAutoUpdate Condition="'$(_OpenClawRemoveAutoUpdate)' == ''">false + + + + <_AppxManifestPath>$(MSBuildThisFileDirectory)Package.appxmanifest <_StrippedVersion>$([System.Text.RegularExpressions.Regex]::Replace('$(Version)', '[-+].*$', '')) @@ -186,7 +311,81 @@ <_AppxManifestVersion Condition="'$(_VersionDotCount)' == '1'">$(_StrippedVersion).0.0 - + + + + + + + + + + <_OpenClawEmbedArch Condition="'$(RuntimeIdentifier)' == 'win-arm64'">arm64 + <_OpenClawEmbedArch Condition="'$(_OpenClawEmbedArch)' == '' And '$(Platform)' == 'ARM64'">arm64 + <_OpenClawEmbedArch Condition="'$(_OpenClawEmbedArch)' == ''">x64 + + <_OpenClawEmbedSource>$(OpenClawRepoRoot)installer\appinstaller\openclaw-update.$(ReleaseChannel.ToLowerInvariant()).appinstaller + <_OpenClawEmbedOutput>$(MSBuildThisFileDirectory)openclaw-update.appinstaller + + <_OpenClawFeedFilename Condition="'$(ReleaseChannel)' == 'Stable'">openclaw-$(_OpenClawEmbedArch).appinstaller + <_OpenClawFeedFilename Condition="'$(ReleaseChannel)' == 'Alpha'">openclaw-alpha-$(_OpenClawEmbedArch).appinstaller + + <_OpenClawFeedUri>https://raw.githubusercontent.com/openclaw/openclaw-windows-node/user%2Fkmahone%2Fmsix/installer/appinstaller/$(_OpenClawFeedFilename) + + <_OpenClawMainPackageUri>https://github.com/openclaw/openclaw-windows-node/releases/download/$(ReleaseTag)/OpenClaw.Tray.WinUI_$(_AppxManifestVersion)_$(_OpenClawEmbedArch).msix + + + + + + + + + <_OpenClawAppInstallerNamespaces><Namespace Prefix="ai" Uri="http://schemas.microsoft.com/appx/appinstaller/2018" /> + + + + + + + + + - - - - true - $(OutputPath)runtimes\win-arm64\native\WebView2Loader.dll - $(OutputPath)runtimes\win-x64\native\WebView2Loader.dll - - - + + + + + + + diff --git a/src/OpenClaw.Tray.WinUI/Package.appxmanifest b/src/OpenClaw.Tray.WinUI/Package.appxmanifest index af0301e44..28b733fe3 100644 --- a/src/OpenClaw.Tray.WinUI/Package.appxmanifest +++ b/src/OpenClaw.Tray.WinUI/Package.appxmanifest @@ -3,24 +3,47 @@ + IgnorableNamespaces="uap uap13 desktop com rescap"> - + + Version="0.6.7.0" /> - OpenClaw Companion + OpenClaw Companion Alpha OpenClaw Foundation Assets\StoreLogo.png + + + + @@ -36,7 +59,7 @@ Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$"> OpenClaw Protocol + + + + + + + + + + + + + diff --git a/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml index 58ac065d6..aab4cb02c 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml @@ -79,8 +79,6 @@ Click="OnOpenConfigClick"/> diff --git a/src/OpenClaw.Tray.WinUI/Properties/launchSettings.json b/src/OpenClaw.Tray.WinUI/Properties/launchSettings.json new file mode 100644 index 000000000..a540ef651 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "profiles": { + "OpenClaw.Tray.WinUI": { + "commandName": "MsixPackage", + "hotReloadEnabled": false, + "debugEngines": "managed,native" + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/DeepLinkHandler.cs b/src/OpenClaw.Tray.WinUI/Services/DeepLinkHandler.cs index 3eb3529f1..2c7b86199 100644 --- a/src/OpenClaw.Tray.WinUI/Services/DeepLinkHandler.cs +++ b/src/OpenClaw.Tray.WinUI/Services/DeepLinkHandler.cs @@ -109,16 +109,6 @@ public static void Handle(string uri, DeepLinkActions actions) } break; - case "updates": - case "update": - case "check-updates": - case "update-check": - if (actions.CheckForUpdates != null) - { - _ = RunDeepLinkActionAsync("update check", actions.CheckForUpdates); - } - break; - case "log": case "logs": case "log-file": @@ -282,7 +272,6 @@ public class DeepLinkActions public Action? OpenSettings { get; set; } public Action? OpenSetup { get; set; } public Func? RunHealthCheck { get; set; } - public Func? CheckForUpdates { get; set; } public Action? OpenLogFile { get; set; } public Action? OpenLogFolder { get; set; } public Action? OpenConfigFolder { get; set; } diff --git a/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs b/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs index 69a20985b..1f6817404 100644 --- a/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs +++ b/src/OpenClaw.Tray.WinUI/Services/IAppCommands.cs @@ -14,7 +14,6 @@ internal interface IAppCommands void Disconnect(); void ShowVoiceOverlay(); void ShowChat(); - void CheckForUpdates(); void ShowOnboarding(); void ShowConnectionStatus(); void NotifySettingsSaved(); diff --git a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs index 29cc89085..70c476487 100644 --- a/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs +++ b/src/OpenClaw.Tray.WinUI/Services/SettingsManager.cs @@ -134,7 +134,6 @@ public List A2UIImageHosts set => _data = _data with { A2UIImageHosts = value ?? new() }; } public bool HasSeenActivityStreamTip { get => _data.HasSeenActivityStreamTip; set => _data = _data with { HasSeenActivityStreamTip = value }; } - public string SkippedUpdateTag { get => _data.SkippedUpdateTag ?? ""; set => _data = _data with { SkippedUpdateTag = value }; } public string? PreferredGatewayId { get => _data.PreferredGatewayId; set => _data = _data with { PreferredGatewayId = value }; } // ── MXC sandbox ───────────────────────────────────────────────────── @@ -258,7 +257,6 @@ public void Load() EnableMcpServer = false, A2UIImageHosts = new(), HasSeenActivityStreamTip = false, - SkippedUpdateTag = "", PreferredGatewayId = null, SystemRunSandboxEnabled = true, SystemRunAllowOutbound = false, @@ -293,7 +291,6 @@ private static SettingsData NormalizeLoadedData(SettingsData loaded) TtsWindowsVoiceId = loaded.TtsWindowsVoiceId ?? defaults.TtsWindowsVoiceId, TtsPiperVoiceId = string.IsNullOrWhiteSpace(loaded.TtsPiperVoiceId) ? defaults.TtsPiperVoiceId : loaded.TtsPiperVoiceId, A2UIImageHosts = loaded.A2UIImageHosts is { Count: > 0 } hosts ? new List(hosts) : new(), - SkippedUpdateTag = loaded.SkippedUpdateTag ?? defaults.SkippedUpdateTag, PreferredGatewayId = loaded.PreferredGatewayId ?? defaults.PreferredGatewayId, UserRules = loaded.UserRules != null ? new List(loaded.UserRules) : new(), SandboxCustomFolders = CloneSandboxCustomFolders(loaded.SandboxCustomFolders), @@ -378,7 +375,6 @@ public SettingsData ToSettingsData() => _data with TtsWindowsVoiceId = string.IsNullOrWhiteSpace(TtsWindowsVoiceId) ? null : TtsWindowsVoiceId, TtsPiperVoiceId = TtsPiperVoiceId, A2UIImageHosts = A2UIImageHosts.Count == 0 ? null : new List(A2UIImageHosts), - SkippedUpdateTag = string.IsNullOrWhiteSpace(SkippedUpdateTag) ? null : SkippedUpdateTag, PreferredGatewayId = string.IsNullOrWhiteSpace(PreferredGatewayId) ? null : PreferredGatewayId, UserRules = new List(UserRules), SandboxCustomFolders = SandboxCustomFolders.Count == 0 ? null : CloneSandboxCustomFolders(SandboxCustomFolders), diff --git a/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs b/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs deleted file mode 100644 index 15de4b141..000000000 --- a/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs +++ /dev/null @@ -1,467 +0,0 @@ -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using OpenClaw.Shared; -using OpenClawTray.Dialogs; -using OpenClawTray.Helpers; -using System; -using System.IO; -using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; -using Updatum; - -namespace OpenClawTray.Services; - -/// -/// Coordinates update checks, user prompts, download, and installation. -/// All public methods must be called from the UI thread. -/// -internal sealed class UpdateCoordinator( - UpdatumManager updater, - AppState appState, - SettingsManager? settings, - Func getXamlRoot, - Action refreshStatus, - Action exit) -{ - private readonly SettingsManager? _settings = settings; - - // Cross-path concurrency for update 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. - private readonly SemaphoreSlim _updateCheckGate = new(1, 1); -#if !DEBUG - private int _updateInstallInProgress; -#endif - private int _manualUpdateCheckInFlight; - - public static UpdateCommandCenterInfo BuildInitialInfo() => new() - { - Status = "Not checked", - CurrentVersion = AppVersionInfo.Version - }; - - public async Task CheckForUpdatesAsync(bool userInitiated = false) - { - // === Stage 1: metadata check (gate-protected) === - if (!await _updateCheckGate.WaitAsync(TimeSpan.FromSeconds(30))) - { - Logger.Warn("Update check gate timed out: another check is in progress"); - appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Failed", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = "another update check is already in progress; try again in a moment" - }; - return true; // Don't block launch - } - -#if DEBUG - try - { - Logger.Info("Skipping update check in debug build"); - appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Skipped", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = "debug build" - }; - return true; - } - finally - { - _updateCheckGate.Release(); - } -#else - string releaseTag; - string changelog; - try - { - Logger.Info("Checking for updates..."); - appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Checking", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow - }; - var updateFound = await updater.CheckForUpdatesAsync(); - - if (!updateFound) - { - Logger.Info("No updates available"); - appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Current", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = "no updates available" - }; - return true; - } - - var release = updater.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 - { - Status = "Failed", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = "update metadata incomplete (missing version tag)" - }; - return true; - } - - releaseTag = release.TagName; - changelog = updater.GetChangelog(true) ?? "No release notes available."; - Logger.Info($"Update available: {releaseTag}"); - appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Available", - CurrentVersion = AppVersionInfo.Version, - LatestVersion = releaseTag, - CheckedAt = DateTime.UtcNow, - Detail = "prompted" - }; - - if (!string.IsNullOrWhiteSpace(_settings?.SkippedUpdateTag) && - string.Equals(_settings.SkippedUpdateTag, releaseTag, StringComparison.OrdinalIgnoreCase) && - !userInitiated) - { - Logger.Info($"Skipping update prompt for remembered version {releaseTag}"); - // Replace the whole object rather than mutating Detail in place: - // AppState.UpdateInfo only fires PropertyChanged on assignment, not on mutation. - appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Available", - CurrentVersion = AppVersionInfo.Version, - LatestVersion = releaseTag, - CheckedAt = DateTime.UtcNow, - Detail = "skipped by user" - }; - return true; - } - } - catch (OperationCanceledException) - { - Logger.Info("Update check cancelled"); - appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Failed", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = "update check cancelled" - }; - return true; - } - catch (Exception ex) - { - Logger.Warn($"Update check failed: {ex.Message}"); - appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Failed", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = ex.Message - }; - return true; - } - 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 (Interlocked.CompareExchange(ref _updateInstallInProgress, 1, 0) != 0) - { - Logger.Info("Update prompt/install already in progress; skipping"); - appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Failed", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = "an update is already being downloaded or installed" - }; - return true; - } - - try - { - var dialog = new UpdateDialog(releaseTag, changelog); - UpdateDialogResult result; - try - { - result = await dialog.ShowAsync(); - } - catch (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", - CurrentVersion = AppVersionInfo.Version, - LatestVersion = releaseTag, - CheckedAt = DateTime.UtcNow, - Detail = "download requested" - }; - if (_settings != null) - { - _settings.SkippedUpdateTag = string.Empty; - _settings.Save(); - } - var installed = await DownloadAndInstallUpdateAsync(); - if (!installed) - { - appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Failed", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = "download or install failed" - }; - } - return !installed; // Don't launch if update succeeded - } - - if (result == UpdateDialogResult.Skip && _settings != null) - { - _settings.SkippedUpdateTag = releaseTag; - _settings.Save(); - appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Available", - CurrentVersion = AppVersionInfo.Version, - LatestVersion = releaseTag, - CheckedAt = DateTime.UtcNow, - Detail = "skipped by user" - }; - } - else if (userInitiated && _settings != null - && 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 - } - catch (Exception ex) - { - Logger.Warn($"Update prompt/install failed: {ex.Message}"); - appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Failed", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = ex.Message - }; - return true; - } - finally - { - Interlocked.Exchange(ref _updateInstallInProgress, 0); - } -#endif - } - - // 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. - public async Task CheckForUpdatesUserInitiatedAsync() - { - if (Interlocked.CompareExchange(ref _manualUpdateCheckInFlight, 1, 0) != 0) - { - Logger.Info("Manual update check ignored: another check is already in progress"); - return; - } - - 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*. - var shouldContinue = await CheckForUpdatesAsync(userInitiated: true); - refreshStatus(); - - // 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) - { - switch (info.Status) - { - case "Current": - await ShowUpdateInfoDialogAsync( - "UpToDate", - LocalizationHelper.GetString("Update_Title_UpToDate"), - 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'); - await ShowUpdateInfoDialogAsync( - "Failed", - LocalizationHelper.GetString("Update_Title_Failed"), - 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", - LocalizationHelper.GetString("Update_Title_Skipped"), - LocalizationHelper.GetString("Update_Message_Skipped_Debug")); - break; -#endif - } - } - - if (!shouldContinue) - exit(); - } - finally - { - Interlocked.Exchange(ref _manualUpdateCheckInFlight, 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. - var xamlRoot = getXamlRoot(); - if (xamlRoot == null) - { - Logger.Warn($"[Update] No XAML root available to show dialog: {logKey}"); - return; - } - - var dialog = new ContentDialog - { - Title = title, - Content = message, - CloseButtonText = LocalizationHelper.GetString("Update_OK"), - DefaultButton = ContentDialogButton.Close, - XamlRoot = xamlRoot - }; - try - { - await dialog.ShowAsync(); - } - catch (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}"); - } - } - - private async Task DownloadAndInstallUpdateAsync() - { - DownloadProgressDialog? progressDialog = null; - try - { - progressDialog = new DownloadProgressDialog(updater); - progressDialog.ShowAsync(); // Fire and forget - - var downloadedAsset = await updater.DownloadUpdateAsync(); - - TryCloseProgressDialog(progressDialog); - - if (downloadedAsset == null || !File.Exists(downloadedAsset.FilePath)) - { - Logger.Error("Update download failed or file missing"); - return false; - } - - Logger.Info("Installing update and restarting..."); - await updater.InstallUpdateAsync(downloadedAsset); - return true; - } - catch (Exception ex) - { - Logger.Error($"Update failed: {ex.Message}"); - TryCloseProgressDialog(progressDialog); - return false; - } - } - - private static void TryCloseProgressDialog(DownloadProgressDialog? dialog) - { - if (dialog == null) return; - try - { - dialog.Close(); - } - // slopwatch-ignore: SW003 Shutdown cancellation or disposal is expected and the caller already preserves the safe state. - catch (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. - } - // slopwatch-ignore: SW003 Shutdown cancellation or disposal is expected and the caller already preserves the safe state. - catch (InvalidOperationException) - { - // Same as above for other "already-disposed" race variants. - } - } -} diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index deb54680a..346fc8309 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -226,9 +226,6 @@ Copy Debug Bundle - - Check Updates - Copy Port Diagnostics @@ -318,9 +315,6 @@ Welcome to OpenClaw - - OpenClaw Update - Testing... @@ -356,48 +350,6 @@ Open Settings - - - 🎉 Version {0} is available! - - - Current version: {0} - - - What's New: - - - Skip This Version - - - Remind Me Later - - - Download & Install - - - You're up to date - - - You're running the latest version (v{0}). - - - Couldn't check for updates - - - Something went wrong while checking for updates. - -{0} - - - Update check skipped - - - Update checks are disabled in debug builds. - - - OK - Open Dashboard @@ -1052,13 +1004,6 @@ On your gateway host (Mac/Linux), run: Cancel - - - Downloading Update... - - - Downloading update... - OpenClaw — Approve URL @@ -1530,9 +1475,6 @@ On your gateway host (Mac/Linux), run: Copy Support Context - - Check for Updates - Links @@ -5261,7 +5203,7 @@ Check your connection settings and try again. Link your WhatsApp phone - Click "Show QR" in the Linking section below. + Click "Show QR" in the Linking section below. On your phone: WhatsApp → Settings → Linked devices → Link a device. @@ -5273,7 +5215,7 @@ Check your connection settings and try again. Link your Signal phone - Click "Show QR" in the Linking section below. + Click "Show QR" in the Linking section below. On your phone: Signal → Settings → Linked devices → Link new device. @@ -5294,7 +5236,7 @@ Check your connection settings and try again. Paste the token into the Configuration form below. - Click "Save and start". The channel will start automatically. + Click "Save and start". The channel will start automatically. Connect Discord via a webhook @@ -5303,13 +5245,13 @@ Check your connection settings and try again. Open your Discord server settings → Integrations → Webhooks. - Click "New Webhook", give it a name, and copy the webhook URL. + Click "New Webhook", give it a name, and copy the webhook URL. Paste the URL into the Configuration form below. - Click "Save and start". + Click "Save and start". Connect Google Chat via a webhook @@ -5324,7 +5266,7 @@ Check your connection settings and try again. Paste the URL into the Configuration form below. - Click "Save and start". + Click "Save and start". Connect Slack via an app @@ -5339,7 +5281,7 @@ Check your connection settings and try again. Paste both into the Configuration form below. - Click "Save and start". + Click "Save and start". Connect Nostr via relays @@ -5354,7 +5296,7 @@ Check your connection settings and try again. Paste both into the Configuration form below. - Click "Save and start". + Click "Save and start". iMessage is macOS-only @@ -5372,10 +5314,10 @@ Check your connection settings and try again. Connect this channel - "{0}" is a plugin channel. Refer to its documentation for the fields it needs. + "{0}" is a plugin channel. Refer to its documentation for the fields it needs. - Use "Open Config page" in the Configuration section below to add settings under channels.{0}. + Use "Open Config page" in the Configuration section below to add settings under channels.{0}. Save the config; OpenClaw will start the channel automatically. diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 219bbb024..6b522c7ec 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -1,4 +1,4 @@ - + @@ -212,9 +212,6 @@ Copier le paquet de débogage - - Rechercher des mises à jour - Copier les diagnostics des ports @@ -293,9 +290,6 @@ Bienvenue sur OpenClaw - - Mettre à jour OpenClaw - Test en cours... @@ -329,47 +323,6 @@ Ouvrir les paramètres - - 🎉 La version {0} est disponible ! - - - Version actuelle : {0} - - - Les nouveautés : - - - Ignorer cette version - - - Me rappeler plus tard - - - Télécharger & Installer - - - Vous êtes à jour - - - Vous utilisez la dernière version (v{0}). - - - Impossible de vérifier les mises à jour - - - Une erreur s'est produite lors de la vérification des mises à jour. - -{0} - - - Vérification ignorée - - - La vérification des mises à jour est désactivée dans les builds de débogage. - - - OK - Ouvrir le tableau de bord @@ -1006,12 +959,6 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : Annuler - - Téléchargement en cours... - - - Téléchargement en cours... - OpenClaw — Approuver l'URL @@ -1482,9 +1429,6 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : Copier le contexte de support - - Rechercher des mises à jour - Liens @@ -5213,7 +5157,7 @@ Vérifiez vos paramètres de connexion et réessayez. Associer votre téléphone WhatsApp - Cliquez sur "Show QR" dans la section Liaison ci-dessous. + Cliquez sur "Show QR" dans la section Liaison ci-dessous. Sur votre téléphone : WhatsApp → Paramètres → Appareils liés → Lier un appareil. @@ -5225,7 +5169,7 @@ Vérifiez vos paramètres de connexion et réessayez. Associer votre téléphone Signal - Cliquez sur "Show QR" dans la section Liaison ci-dessous. + Cliquez sur "Show QR" dans la section Liaison ci-dessous. Sur votre téléphone : Signal → Paramètres → Appareils liés → Lier un nouvel appareil. @@ -5246,7 +5190,7 @@ Vérifiez vos paramètres de connexion et réessayez. Collez le jeton dans le formulaire de configuration ci-dessous. - Cliquez sur "Save and start". Le canal démarrera automatiquement. + Cliquez sur "Save and start". Le canal démarrera automatiquement. Connecter Discord via un webhook @@ -5255,13 +5199,13 @@ Vérifiez vos paramètres de connexion et réessayez. Ouvrez les paramètres de votre serveur Discord → Intégrations → Webhooks. - Cliquez sur "New Webhook", donnez-lui un nom et copiez l’URL du webhook. + Cliquez sur "New Webhook", donnez-lui un nom et copiez l’URL du webhook. Collez l’URL dans le formulaire de configuration ci-dessous. - Cliquez sur "Save and start". + Cliquez sur "Save and start". Connecter Google Chat via un webhook @@ -5276,7 +5220,7 @@ Vérifiez vos paramètres de connexion et réessayez. Collez l’URL dans le formulaire de configuration ci-dessous. - Cliquez sur "Save and start". + Cliquez sur "Save and start". Connecter Slack via une application @@ -5291,7 +5235,7 @@ Vérifiez vos paramètres de connexion et réessayez. Collez les deux dans le formulaire de configuration ci-dessous. - Cliquez sur "Save and start". + Cliquez sur "Save and start". Connecter Nostr via des relais @@ -5306,7 +5250,7 @@ Vérifiez vos paramètres de connexion et réessayez. Collez les deux dans le formulaire de configuration ci-dessous. - Cliquez sur "Save and start". + Cliquez sur "Save and start". iMessage est réservé à macOS @@ -5324,10 +5268,10 @@ Vérifiez vos paramètres de connexion et réessayez. Connecter ce canal - "{0}" est un canal de plugin. Consultez sa documentation pour connaître les champs requis. + "{0}" est un canal de plugin. Consultez sa documentation pour connaître les champs requis. - Utilisez "Open Config page" dans la section Configuration ci-dessous pour ajouter des paramètres sous channels.{0}. + Utilisez "Open Config page" dans la section Configuration ci-dessous pour ajouter des paramètres sous channels.{0}. Enregistrez la configuration ; OpenClaw démarrera le canal automatiquement. diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index c5997526a..a49d51128 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -1,4 +1,4 @@ - + @@ -212,9 +212,6 @@ Debugbundel kopiëren - - Updates zoeken - Poortdiagnostiek kopiëren @@ -294,9 +291,6 @@ Welkom bij OpenClaw - - OpenClaw-update - Testen... @@ -330,47 +324,6 @@ Instellingen openen - - 🎉 Versie {0} is beschikbaar! - - - Huidige versie: {0} - - - Wat is er nieuw: - - - Deze versie overslaan - - - Herinner me later - - - Downloaden en installeren - - - Je bent up-to-date - - - Je gebruikt de nieuwste versie (v{0}). - - - Kan niet op updates controleren - - - Er is iets misgegaan tijdens het controleren op updates. - -{0} - - - Updatecontrole overgeslagen - - - Updatecontroles zijn uitgeschakeld in debug-builds. - - - OK - Dashboard openen @@ -1007,12 +960,6 @@ Voer op uw gateway-host (Mac/Linux) uit: Annuleren - - Update downloaden... - - - Update downloaden... - OpenClaw — URL goedkeuren @@ -1483,9 +1430,6 @@ Voer op uw gateway-host (Mac/Linux) uit: Ondersteuningscontext kopiëren - - Updates zoeken - Koppelingen @@ -5214,7 +5158,7 @@ Controleer uw verbindingsinstellingen en probeer het opnieuw. Koppel uw WhatsApp-telefoon - Klik op "Show QR" in het gedeelte Koppelen hieronder. + Klik op "Show QR" in het gedeelte Koppelen hieronder. Op uw telefoon: WhatsApp → Instellingen → Gekoppelde apparaten → Apparaat koppelen. @@ -5226,7 +5170,7 @@ Controleer uw verbindingsinstellingen en probeer het opnieuw. Koppel uw Signal-telefoon - Klik op "Show QR" in het gedeelte Koppelen hieronder. + Klik op "Show QR" in het gedeelte Koppelen hieronder. Op uw telefoon: Signal → Instellingen → Gekoppelde apparaten → Nieuw apparaat koppelen. @@ -5247,7 +5191,7 @@ Controleer uw verbindingsinstellingen en probeer het opnieuw. Plak het token in het configuratieformulier hieronder. - Klik op "Save and start". Het kanaal start automatisch. + Klik op "Save and start". Het kanaal start automatisch. Discord verbinden via een webhook @@ -5256,13 +5200,13 @@ Controleer uw verbindingsinstellingen en probeer het opnieuw. Open uw Discord-serverinstellingen → Integraties → Webhooks. - Klik op "New Webhook", geef het een naam en kopieer de webhook-URL. + Klik op "New Webhook", geef het een naam en kopieer de webhook-URL. Plak de URL in het configuratieformulier hieronder. - Klik op "Save and start". + Klik op "Save and start". Google Chat verbinden via een webhook @@ -5277,7 +5221,7 @@ Controleer uw verbindingsinstellingen en probeer het opnieuw. Plak de URL in het configuratieformulier hieronder. - Klik op "Save and start". + Klik op "Save and start". Slack verbinden via een app @@ -5292,7 +5236,7 @@ Controleer uw verbindingsinstellingen en probeer het opnieuw. Plak beide in het configuratieformulier hieronder. - Klik op "Save and start". + Klik op "Save and start". Nostr verbinden via relays @@ -5307,7 +5251,7 @@ Controleer uw verbindingsinstellingen en probeer het opnieuw. Plak beide in het configuratieformulier hieronder. - Klik op "Save and start". + Klik op "Save and start". iMessage is alleen beschikbaar op macOS @@ -5325,10 +5269,10 @@ Controleer uw verbindingsinstellingen en probeer het opnieuw. Dit kanaal verbinden - "{0}" is een pluginkanaal. Raadpleeg de documentatie voor de vereiste velden. + "{0}" is een pluginkanaal. Raadpleeg de documentatie voor de vereiste velden. - Gebruik "Open Config page" in het gedeelte Configuratie hieronder om instellingen toe te voegen onder channels.{0}. + Gebruik "Open Config page" in het gedeelte Configuratie hieronder om instellingen toe te voegen onder channels.{0}. Sla de configuratie op; OpenClaw start het kanaal automatisch. diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 0ed8c4059..a4c0e2bc9 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -1,4 +1,4 @@ - + @@ -212,9 +212,6 @@ 复制调试包 - - 检查更新 - 复制端口诊断 @@ -293,9 +290,6 @@ 欢迎使用 OpenClaw - - OpenClaw 更新 - 测试中... @@ -329,47 +323,6 @@ 打开设置 - - 🎉 版本 {0} 已可用! - - - 当前版本: {0} - - - 更新内容: - - - 跳过此版本 - - - 稍后提醒 - - - 下载并安装 - - - 已是最新版本 - - - 您正在使用最新版本 (v{0})。 - - - 无法检查更新 - - - 检查更新时出错。 - -{0} - - - 已跳过更新检查 - - - 调试版本已禁用更新检查。 - - - 确定 - 打开仪表板 @@ -1006,12 +959,6 @@ 取消 - - 正在下载更新... - - - 正在下载更新... - OpenClaw — 批准 URL @@ -1482,9 +1429,6 @@ 复制支持上下文 - - 检查更新 - 链接 @@ -5214,7 +5158,7 @@ 关联您的 WhatsApp 手机 - 点击下方“关联”部分中的 "Show QR"。 + 点击下方“关联”部分中的 "Show QR"。 在手机上:WhatsApp → 设置 → 已关联设备 → 关联设备。 @@ -5226,7 +5170,7 @@ 关联您的 Signal 手机 - 点击下方“关联”部分中的 "Show QR"。 + 点击下方“关联”部分中的 "Show QR"。 在手机上:Signal → 设置 → 已关联设备 → 关联新设备。 @@ -5247,7 +5191,7 @@ 将令牌粘贴到下方的配置表单中。 - 点击 "Save and start"。频道将自动启动。 + 点击 "Save and start"。频道将自动启动。 通过 webhook 连接 Discord @@ -5256,13 +5200,13 @@ 打开您的 Discord 服务器设置 → 集成 → Webhooks。 - 点击 "New Webhook",为其命名,然后复制 webhook URL。 + 点击 "New Webhook",为其命名,然后复制 webhook URL。 将 URL 粘贴到下方的配置表单中。 - 点击 "Save and start"。 + 点击 "Save and start"。 通过 webhook 连接 Google Chat @@ -5277,7 +5221,7 @@ 将 URL 粘贴到下方的配置表单中。 - 点击 "Save and start"。 + 点击 "Save and start"。 通过应用连接 Slack @@ -5292,7 +5236,7 @@ 将两者粘贴到下方的配置表单中。 - 点击 "Save and start"。 + 点击 "Save and start"。 通过中继连接 Nostr @@ -5307,7 +5251,7 @@ 将两者粘贴到下方的配置表单中。 - 点击 "Save and start"。 + 点击 "Save and start"。 iMessage 仅适用于 macOS @@ -5325,10 +5269,10 @@ 连接此频道 - "{0}" 是一个插件频道。请参阅其文档以了解所需字段。 + "{0}" 是一个插件频道。请参阅其文档以了解所需字段。 - 使用下方“配置”部分中的 "Open Config page" 在 channels.{0} 下添加设置。 + 使用下方“配置”部分中的 "Open Config page" 在 channels.{0} 下添加设置。 保存配置;OpenClaw 将自动启动频道。 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 52037a4d5..a4d45ddf4 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -1,4 +1,4 @@ - + @@ -212,9 +212,6 @@ 複製偵錯套件 - - 檢查更新 - 複製連接埠診斷 @@ -293,9 +290,6 @@ 歡迎使用 OpenClaw - - OpenClaw 更新 - 測試中... @@ -329,47 +323,6 @@ 打開設定 - - 🎉 版本 {0} 已可用! - - - 目前版本: {0} - - - 更新內容: - - - 跳過此版本 - - - 稍後提醒 - - - 下載並安裝 - - - 已是最新版本 - - - 您正在使用最新版本 (v{0})。 - - - 無法檢查更新 - - - 檢查更新時發生錯誤。 - -{0} - - - 已略過更新檢查 - - - 偵錯版本已停用更新檢查。 - - - 確定 - 打開儀表板 @@ -1006,12 +959,6 @@ 取消 - - 正在下載更新... - - - 正在下載更新... - OpenClaw — 核准 URL @@ -1482,9 +1429,6 @@ 複製支援內容 - - 檢查更新 - 連結 @@ -5214,7 +5158,7 @@ 連結您的 WhatsApp 手機 - 點擊下方「連結」區段中的 "Show QR"。 + 點擊下方「連結」區段中的 "Show QR"。 在手機上:WhatsApp → 設定 → 已連結裝置 → 連結裝置。 @@ -5226,7 +5170,7 @@ 連結您的 Signal 手機 - 點擊下方「連結」區段中的 "Show QR"。 + 點擊下方「連結」區段中的 "Show QR"。 在手機上:Signal → 設定 → 已連結裝置 → 連結新裝置。 @@ -5247,7 +5191,7 @@ 將權杖貼到下方的設定表單中。 - 點擊 "Save and start"。頻道將自動啟動。 + 點擊 "Save and start"。頻道將自動啟動。 透過 webhook 連接 Discord @@ -5256,13 +5200,13 @@ 開啟您的 Discord 伺服器設定 → 整合 → Webhooks。 - 點擊 "New Webhook",為其命名,然後複製 webhook URL。 + 點擊 "New Webhook",為其命名,然後複製 webhook URL。 將 URL 貼到下方的設定表單中。 - 點擊 "Save and start"。 + 點擊 "Save and start"。 透過 webhook 連接 Google Chat @@ -5277,7 +5221,7 @@ 將 URL 貼到下方的設定表單中。 - 點擊 "Save and start"。 + 點擊 "Save and start"。 透過應用程式連接 Slack @@ -5292,7 +5236,7 @@ 將兩者貼到下方的設定表單中。 - 點擊 "Save and start"。 + 點擊 "Save and start"。 透過中繼連接 Nostr @@ -5307,7 +5251,7 @@ 將兩者貼到下方的設定表單中。 - 點擊 "Save and start"。 + 點擊 "Save and start"。 iMessage 僅適用於 macOS @@ -5325,10 +5269,10 @@ 連接此頻道 - "{0}" 是一個外掛頻道。請參閱其文件以了解所需欄位。 + "{0}" 是一個外掛頻道。請參閱其文件以了解所需欄位。 - 使用下方「設定」區段中的 "Open Config page" 在 channels.{0} 下新增設定。 + 使用下方「設定」區段中的 "Open Config page" 在 channels.{0} 下新增設定。 儲存設定;OpenClaw 將自動啟動頻道。 diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs index deadf5475..aa375b509 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs @@ -33,7 +33,6 @@ private static TaskCompletionSource CreateCompletedContentReady() // Legacy compatibility alias public string SelectedAgentId => _currentAgentId; public Action? OpenDashboardAction { get; set; } - public Action? CheckForUpdatesAction { get; set; } public Action? ConnectAction { get; set; } public Action? DisconnectAction { get; set; } public Action? ReconnectAction { get; set; } diff --git a/src/OpenClaw.Tray.WinUI/app.manifest b/src/OpenClaw.Tray.WinUI/app.manifest deleted file mode 100644 index 7361ce5c9..000000000 --- a/src/OpenClaw.Tray.WinUI/app.manifest +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - true/pm - PerMonitorV2 - - - - - - - - - - - - - - - - diff --git a/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs b/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs new file mode 100644 index 000000000..29c3c04d2 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/AppInstallerTemplateAssertionTests.cs @@ -0,0 +1,200 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace OpenClaw.Tray.Tests; + +/// +/// Structural assertions on the AppInstaller template and the publishing +/// infrastructure that renders it. 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 the stable feed link, with +/// no in-app surface to notice. +/// +/// We deliberately don't ship an in-app "Check for updates" affordance under +/// MSIX — Windows AppInstaller's AutomaticBackgroundTask handles polling at +/// the OS level. So these tests pin only the publishing-infrastructure +/// contract: template shape, bootstrap feed files, validation scripts, and +/// the feed-update workflow. +/// +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. + 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("{{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_HasNoDependenciesBlock() + { + // The MSIX is built with WindowsAppSDKSelfContained=true, so the + // WindowsAppRuntime is bundled inside the .msix payload. The + // AppInstaller XML therefore must NOT declare a block — + // a stale block here would either fail-to-resolve at + // install time, or worse, silently pull an extra framework package + // the app doesn't need. + var doc = XDocument.Parse(LoadTemplate()); + XNamespace ns = "http://schemas.microsoft.com/appx/appinstaller/2018"; + + Assert.Empty(doc.Descendants(ns + "Dependencies")); + Assert.Empty(doc.Descendants(ns + "Package")); + } + + [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/main/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 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")); + + // The listener lives only inside Start-Job; the parent must not + // open a second HttpListener on the same prefix. + 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")); + + 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 main", workflow); + Assert.Contains("validate-appinstaller-hosting.ps1", workflow); + Assert.Contains("-AllowGitHubContentTypes", workflow); + Assert.Contains("OpenClaw.Companion_${version}_x64.msix", workflow); + Assert.Contains("OpenClaw.Companion_${version}_arm64.msix", workflow); + + // MSIX is self-contained — the workflow must not fetch or pass a + // separate WindowsAppRuntime asset. + Assert.DoesNotContain("Microsoft.WindowsAppRuntime", workflow); + Assert.DoesNotContain("WindowsAppRuntimeUri", workflow); + + // Stable feed only — no wildcard alpha/staging file globbing. + Assert.DoesNotContain("OpenClaw.Companion_*_x64.msix", workflow); + Assert.DoesNotContain("OpenClaw.Companion_*_arm64.msix", workflow); + } +} diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 6306d950e..c5c1b5bdc 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -29,7 +29,6 @@ public void Startup_Order_PreservesInitializationInvariants() AssertInOrder( source, "_settings = new SettingsManager();", - "CheckForUpdatesAsync();", "ToastNotificationManagerCompat.OnActivated += OnToastActivated;", "InitializeTrayIcon();", "_gatewayRegistry = new GatewayRegistry", diff --git a/tests/OpenClaw.Tray.Tests/DeepLinkParserTests.cs b/tests/OpenClaw.Tray.Tests/DeepLinkParserTests.cs index 9e341cee3..97c49f3d6 100644 --- a/tests/OpenClaw.Tray.Tests/DeepLinkParserTests.cs +++ b/tests/OpenClaw.Tray.Tests/DeepLinkParserTests.cs @@ -91,7 +91,6 @@ public void ParseDeepLink_History() [Theory] [InlineData("openclaw://setup", "setup")] [InlineData("openclaw://healthcheck", "healthcheck")] - [InlineData("openclaw://check-updates", "check-updates")] [InlineData("openclaw://logs", "logs")] [InlineData("openclaw://log-folder", "log-folder")] [InlineData("openclaw://config", "config")] @@ -260,7 +259,6 @@ public void GetQueryParam_ValueWithEquals() [InlineData("openclaw://channel-summary", nameof(DeepLinkActions.CopyChannelSummary))] [InlineData("openclaw://activity-summary", nameof(DeepLinkActions.CopyActivitySummary))] [InlineData("openclaw://extensibility-summary", nameof(DeepLinkActions.CopyExtensibilitySummary))] - [InlineData("openclaw://check-updates", nameof(DeepLinkActions.CheckForUpdates))] [InlineData("openclaw://restart-ssh-tunnel", nameof(DeepLinkActions.RestartSshTunnel))] public void Handle_InvokesExpectedAction(string uri, string expectedAction) { @@ -283,11 +281,6 @@ public void Handle_InvokesExpectedAction(string uri, string expectedAction) CopyActivitySummary = () => invoked = nameof(DeepLinkActions.CopyActivitySummary), CopyExtensibilitySummary = () => invoked = nameof(DeepLinkActions.CopyExtensibilitySummary), OpenActivityStream = _ => invoked = nameof(DeepLinkActions.OpenActivityStream), - CheckForUpdates = () => - { - invoked = nameof(DeepLinkActions.CheckForUpdates); - return Task.CompletedTask; - }, RestartSshTunnel = () => invoked = nameof(DeepLinkActions.RestartSshTunnel) }; diff --git a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs b/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs deleted file mode 100644 index 52c0eca18..000000000 --- a/tests/OpenClaw.Tray.Tests/InstallerIssAssertionTests.cs +++ /dev/null @@ -1,202 +0,0 @@ -namespace OpenClaw.Tray.Tests; - -/// -/// Structural assertions on installer.iss. These pin contracts that cannot -/// be exercised by an in-process unit test because they require ISCC + the -/// resulting unins000.exe to verify end-to-end. -/// -/// Round 2 (Scott #5) — AppMutex coordination prevents the Inno uninstaller -/// from racing the running tray on shared state (settings.json, -/// gateways.json, device-key-ed25519.json, Logs/). The mutex name must -/// match App.xaml.cs's single-instance mutex. -/// -public sealed class InstallerIssAssertionTests -{ - private static string GetRepositoryRoot() - { - var envRepoRoot = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); - if (!string.IsNullOrWhiteSpace(envRepoRoot) && Directory.Exists(envRepoRoot)) - return envRepoRoot; - - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory != null) - { - if ((Directory.Exists(Path.Combine(directory.FullName, ".git")) || - File.Exists(Path.Combine(directory.FullName, ".git"))) && - File.Exists(Path.Combine(directory.FullName, "README.md"))) - return directory.FullName; - directory = directory.Parent; - } - - throw new InvalidOperationException( - "Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path."); - } - - [Fact] - public void Installer_HasAppMutexMatchingTraySingleInstance() - { - var iss = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "installer.iss")); - Assert.Contains("AppMutex=OpenClawTray", iss); - Assert.Contains("Inno requires \"{{\" to emit a literal opening brace in AppId.", iss); - Assert.Contains("AppId={{M0LTB0T-TRAY-4PP1-D3N7}", iss); - Assert.DoesNotContain("AppId={{M0LTB0T-TRAY-4PP1-D3N7}}", iss); - - // The matching tray-side mutex name must be present in App.xaml.cs. - var appXamlCs = File.ReadAllText(Path.Combine( - GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI", "App.xaml.cs")); - Assert.Contains("var mutexName = \"OpenClawTray\";", appXamlCs); - } - - [Fact] - public void Installer_DoesNotShipCommandPaletteExtension() - { - var iss = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "installer.iss")); - - Assert.DoesNotContain("cmdpalette", iss, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("CommandPalette", iss, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("Add-AppxPackage", iss, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("Remove-AppxPackage", iss, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void Installer_CreatesStartMenuEntrypointsForTraySetupAndSupport() - { - var iss = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "installer.iss")); - - Assert.Contains(@"#define MyAppName ""OpenClaw Companion""", iss); - Assert.Contains(@"#define MyCompression ""lzma""", iss); - Assert.Contains(@"#define MySolidCompression ""yes""", iss); - Assert.Contains("OutputBaseFilename=OpenClawCompanion-Setup-{#MyAppArch}", iss); - Assert.Contains(@"Name: ""{group}\{#MyAppName}""; Filename: ""{app}\{#MyAppExeName}""", iss); - Assert.Contains(@"Name: ""{group}\OpenClaw Gateway Setup""; Filename: ""{app}\{#MyAppExeName}""; Parameters: ""openclaw://setup""", iss); - Assert.Contains(@"Name: ""{group}\OpenClaw Companion Settings""; Filename: ""{app}\{#MyAppExeName}""; Parameters: ""openclaw://commandcenter""", iss); - Assert.Contains(@"Name: ""{group}\OpenClaw Chat""; Filename: ""{app}\{#MyAppExeName}""; Parameters: ""openclaw://chat""", iss); - Assert.Contains(@"Name: ""{group}\Check for Updates""; Filename: ""{app}\{#MyAppExeName}""; Parameters: ""openclaw://check-updates""", iss); - } - - [Fact] - public void Installer_RemovesGeneratedAppStateOnlyAfterGatewayCleanup() - { - var iss = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "installer.iss")); - - Assert.DoesNotContain("[UninstallRun]", iss); - Assert.Contains("[Code]", iss); - Assert.Contains("Uninstall-LocalGateway.ps1", iss); - Assert.Contains("UninstallSilent()", iss); - Assert.Contains("LocalGatewayCleanupRequested := True", iss); - Assert.Contains("OpenClawGateway WSL distro", iss); - Assert.Contains("MB_YESNO", iss); - Assert.Contains("ExpandConstant('{sys}\\WindowsPowerShell\\v1.0\\powershell.exe')", iss); - Assert.Contains("ewWaitUntilTerminated", iss); - Assert.Contains("MB_RETRYCANCEL", iss); - Assert.Contains("DeleteGeneratedAppState", iss); - Assert.Contains("CurUninstallStep = usPostUninstall", iss); - Assert.Contains("DelTree(ExpandConstant('{app}'), True, True, True)", iss); - Assert.DoesNotContain("Start-Sleep -Seconds 3", iss); - Assert.DoesNotContain("--uninstall --confirm-destructive", iss); - Assert.DoesNotContain("[UninstallDelete]", iss); - } - - [Fact] - public void UninstallLocalGatewayScript_DirectlyUnregistersWslDistro() - { - var script = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "scripts", "Uninstall-LocalGateway.ps1")); - - Assert.Contains("$DistroName = 'OpenClawGateway'", script); - Assert.Contains("'--list', '--quiet'", script); - Assert.Contains("'--terminate', $DistroName", script); - Assert.Contains("'--shutdown'", script); - Assert.Contains("'--unregister', $DistroName", script); - Assert.Contains("Start-Sleep -Seconds 2", script); - Assert.Contains("Remove-GatewayDirectory", script); - Assert.Contains("Remove-WindowsGatewayArtifacts", script); - Assert.Contains("gateways.json", script); - Assert.Contains("device-key-ed25519.json", script); - Assert.Contains("OpenClawTray", script); - Assert.Contains("setup-state.json", script); - Assert.Contains("wsl-keepalive", script); - Assert.Contains("Test-DistroListed", script); - Assert.Contains("Test-DistroNotFound", script); - Assert.Contains("FileAttributes]::ReparsePoint", script); - Assert.Contains("Refusing to recursively delete reparse point", script); - Assert.Contains("for ($attempt = 1; $attempt -le 6; $attempt++)", script); - Assert.Contains("exit $unregisterResult.ExitCode", script); - Assert.DoesNotContain("OpenClaw.Tray.WinUI.exe", script); - Assert.DoesNotContain("OpenClaw.SetupEngine.UI.exe", script); - Assert.DoesNotContain("--headless", script); - Assert.DoesNotContain("--confirm-destructive", script); - } - - [Fact] - public void Installer_RegistersOpenClawProtocol() - { - var iss = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "installer.iss")); - - Assert.Contains(@"Subkey: ""Software\Classes\openclaw""", iss); - Assert.Contains(@"ValueName: ""URL Protocol""", iss); - Assert.Contains(@"Subkey: ""Software\Classes\openclaw\shell\open\command""", iss); - Assert.Contains(@"{app}\{#MyAppExeName}", iss); - Assert.Contains(@"""%1""", iss); - } - - [Fact] - public void ReleaseBuildDoesNotShipSeparateSetupUiExecutable() - { - var iss = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "installer.iss")); - var ci = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "ci.yml")); - - Assert.Contains(@"FileExists(publish + ""\OpenClaw.Tray.WinUI.exe"")", iss); - Assert.Contains(@"FileExists(publish + ""\SetupEngine\OpenClaw.SetupEngine.UI.exe"")", iss); - Assert.Contains("SetupEngine.UI.exe should not be shipped", iss); - Assert.DoesNotContain("Publish SetupEngine.UI", ci); - Assert.DoesNotContain(@"dotnet publish src/OpenClaw.SetupEngine.UI", ci); - Assert.DoesNotContain("publish-setup", ci); - Assert.DoesNotContain(@"mkdir publish\SetupEngine", ci); - Assert.DoesNotContain(@"copy publish-setup\* publish\SetupEngine\ -Recurse", ci); - } - - [Fact] - public void MxcSdk_IsRestoredCopiedValidatedAndIncludedInInstallerPayload() - { - var repositoryRoot = GetRepositoryRoot(); - var packageJson = File.ReadAllText(Path.Combine(repositoryRoot, "package.json")); - var trayProject = File.ReadAllText(Path.Combine( - repositoryRoot, "src", "OpenClaw.Tray.WinUI", "OpenClaw.Tray.WinUI.csproj")); - var iss = File.ReadAllText(Path.Combine(repositoryRoot, "installer.iss")); - - Assert.Contains(@"""@microsoft/mxc-sdk""", packageJson); - Assert.Contains("RestoreMxcNodeBridge", trayProject); - Assert.Contains("npm ci --no-audit --no-fund", trayProject); - Assert.Contains("CopyWxcExecToOutput", trayProject); - Assert.Contains("CopyWxcExecToPublish", trayProject); - Assert.Contains("ValidateWxcExecShipped", trayProject); - Assert.Contains("ValidateWxcExecPublished", trayProject); - Assert.Contains(@"tools\mxc\$(MxcArch)\wxc-exec.exe", trayProject); - - // The Inno payload recurses through the prepared publish directory, so - // publish-time tools\mxc\\wxc-exec.exe is shipped with the app. - Assert.Contains(@"Source: ""{#publish}\*""; DestDir: ""{app}""; Flags: ignoreversion recursesubdirs", iss); - } - - [Fact] - public void MxcRuntime_ProbesShippedWxcExecAndSystemRunUsesIt() - { - var repositoryRoot = GetRepositoryRoot(); - var availability = File.ReadAllText(Path.Combine( - repositoryRoot, "src", "OpenClaw.Shared", "Mxc", "MxcAvailability.cs")); - var nodeService = File.ReadAllText(Path.Combine( - repositoryRoot, "src", "OpenClaw.Tray.WinUI", "Services", "NodeService.cs")); - - Assert.Contains(@"Path.Combine(root, ""tools"", ""mxc"", arch, ""wxc-exec.exe"")", availability); - Assert.Contains("WxcExecOverrideEnvVar", availability); - Assert.Contains("node_modules", availability); - Assert.Contains("@microsoft", availability); - Assert.Contains("mxc-sdk", availability); - - Assert.Contains("private ICommandRunner BuildSystemRunRunner()", nodeService); - Assert.Contains("MxcAvailability.Probe(_logger)", nodeService); - Assert.Contains("new DirectAppContainerExecutor(availability, _logger)", nodeService); - Assert.Contains("return new MxcCommandRunner(", nodeService); - } - -} diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs index 9582d19f3..060190d66 100644 --- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs @@ -39,7 +39,6 @@ public class LocalizationValidationTests "Onboarding_Connection_QrButton", "Onboarding_Connection_Token", "WindowTitle_TrayMenu", - "WindowTitle_Update", // STT/TTS card invariants — these are protocol/brand identifiers // not user-visible prose. They intentionally read the same in every // locale: "eleven_multilingual_v2" is an ElevenLabs model @@ -199,7 +198,7 @@ public class LocalizationValidationTests "ConfigPage_ConfigUnavailable", "ConfigPage_ConfigIsReadOnly", // ConnectionPage gateway terminal controls — surfaced after PR #597 - // landed in master. Seeded English-only across all 5 locales using the + // landed in main. Seeded English-only across all 5 locales using the // same deferred-translation pattern as the AgentEventsPage / SkillsPage // / CronPage entries above. The Description_Format key takes the WSL // distro name as {0} and is formatted in ConnectionPage.xaml.cs. @@ -521,7 +520,6 @@ public void MojibakeDetector_AllowsLegitimateUnicode() /// private static readonly HashSet LatinScriptInvariantResourceKeys = new(StringComparer.Ordinal) { - "Update_OK", "Onboarding_IncompleteSetup_Close", "ChatPage_OK", "ConnectionPage_ViaSSH", diff --git a/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs b/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs deleted file mode 100644 index 03f15efe0..000000000 --- a/tests/OpenClaw.Tray.Tests/ReleaseSigningWorkflowTests.cs +++ /dev/null @@ -1,138 +0,0 @@ -namespace OpenClaw.Tray.Tests; - -public sealed class ReleaseSigningWorkflowTests -{ - [Fact] - public void ReleaseWorkflow_SignsOnlyOpenClawOwnedPayloadExecutables() - { - var workflow = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "ci.yml")); - - Assert.DoesNotContain("azure/trusted-signing-action", workflow); - Assert.DoesNotContain("AZURE_CLIENT_SECRET", workflow); - Assert.Contains("environment: release-signing", workflow); - Assert.Contains("id-token: write", workflow); - Assert.Contains("uses: azure/artifact-signing-action@v2", workflow); - Assert.Contains("endpoint: https://eus.codesigning.azure.net/", workflow); - Assert.Contains("signing-account-name: openclaw", workflow); - Assert.Contains("certificate-profile-name: openclaw", workflow); - Assert.Contains("Stage x64 OpenClaw Executables for Signing", workflow); - Assert.Contains(@"New-Item -ItemType HardLink -Path signing-input-x64\OpenClaw.Tray.WinUI.exe -Target artifacts\tray-win-x64\OpenClaw.Tray.WinUI.exe", workflow); - Assert.DoesNotContain("signing-input-x64\\OpenClaw.SetupEngine.exe", workflow); - Assert.DoesNotContain("signing-input-x64\\OpenClaw.SetupEngine.UI.exe", workflow); - Assert.Contains("Sign x64 OpenClaw Executables", workflow); - Assert.Contains("files-folder: signing-input-x64", workflow); - Assert.Contains("Stage ARM64 OpenClaw Executables for Signing", workflow); - Assert.Contains(@"New-Item -ItemType HardLink -Path signing-input-arm64\OpenClaw.Tray.WinUI.exe -Target artifacts\tray-win-arm64\OpenClaw.Tray.WinUI.exe", workflow); - Assert.DoesNotContain("signing-input-arm64\\OpenClaw.SetupEngine.exe", workflow); - Assert.DoesNotContain("signing-input-arm64\\OpenClaw.SetupEngine.UI.exe", workflow); - Assert.Contains("Sign ARM64 OpenClaw Executables", workflow); - Assert.Contains("files-folder: signing-input-arm64", workflow); - Assert.Contains("files-folder-filter: exe", workflow); - Assert.DoesNotContain("files-folder-recurse: true", workflow); - } - - [Fact] - public void ReleaseWorkflow_VerifiesExecutableSigningPolicy() - { - var workflow = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "ci.yml")); - var verifier = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "scripts", "Test-ReleaseExecutableSignatures.ps1")); - - Assert.Contains("Test-ReleaseExecutableSignatures.ps1 -PayloadPath artifacts/tray-win-x64 -RequireSignedOpenClaw", workflow); - Assert.Contains("Test-ReleaseExecutableSignatures.ps1 -PayloadPath artifacts/tray-win-arm64 -RequireSignedOpenClaw", workflow); - Assert.Contains(@"^OpenClaw\.Tray\.WinUI\.exe$", verifier); - Assert.DoesNotContain(@"^SetupEngine\\OpenClaw\.SetupEngine\.exe$", verifier); - Assert.DoesNotContain(@"^SetupEngine\\OpenClaw\.SetupEngine\.UI\.exe$", verifier); - Assert.Contains("SetupEngine\\OpenClaw.SetupEngine.exe should not be present", verifier); - Assert.Contains("SetupEngine\\OpenClaw.SetupEngine.UI.exe should not be present", verifier); - Assert.Contains(@"(^|\\)createdump\.exe$", verifier); - Assert.Contains(@"(^|\\)RestartAgent\.exe$", verifier); - Assert.Contains(@"^tools\\mxc\\[^\\]+\\wxc-exec\.exe$", verifier); - Assert.Contains("Unknown executable in release payload", verifier); - } - - [Fact] - public void ReleaseWorkflow_BundlesAndVerifiesNativeRuntimeDependencies() - { - var root = GetRepositoryRoot(); - var workflow = File.ReadAllText(Path.Combine(root, ".github", "workflows", "ci.yml")); - var installer = File.ReadAllText(Path.Combine(root, "installer.iss")); - var verifier = File.ReadAllText(Path.Combine(root, "scripts", "Test-ReleaseNativeDependencies.ps1")); - var targets = File.ReadAllText(Path.Combine(root, "src", "Directory.Build.targets")); - - Assert.Contains("Test-ReleaseNativeDependencies.ps1 -PayloadPath publish -RequireAppLocalVCRuntime", workflow); - Assert.Contains("Test-ReleaseNativeDependencies.ps1 -PayloadPath artifacts/tray-win-x64 -RequireAppLocalVCRuntime", workflow); - Assert.Contains("Test-ReleaseNativeDependencies.ps1 -PayloadPath artifacts/tray-win-arm64 -RequireAppLocalVCRuntime -SkipNativeLoadProbe", workflow); - Assert.Contains("https://aka.ms/vc14/vc_redist.x64.exe", workflow); - Assert.Contains("https://aka.ms/vc14/vc_redist.arm64.exe", workflow); - Assert.Contains("Get-AuthenticodeSignature -LiteralPath $redist.Path", workflow); - Assert.Contains("O=Microsoft Corporation", workflow); - Assert.Contains("-InstallerVCRedistPath vc_redist.x64.exe", workflow); - Assert.Contains("publish-arm64 -RequireAppLocalVCRuntime -RequireInstallerVCRedist -InstallerVCRedistPath vc_redist.arm64.exe -SkipNativeLoadProbe", workflow); - Assert.Contains("/DvcRedist=vc_redist.x64.exe", workflow); - Assert.Contains("/DvcRedist=vc_redist.arm64.exe", workflow); - Assert.DoesNotContain("copy vc_redist.x64.exe publish-x64", workflow); - Assert.DoesNotContain("copy vc_redist.x64.exe publish-arm64", workflow); - Assert.Contains("OpenClawTray-${{ needs.test.outputs.semVer }}-win-arm64.zip", workflow); - Assert.Contains("AfterInstall: InstallVCRuntime", installer); - Assert.Contains("Exec(", installer); - Assert.Contains("ResultCode = 3010", installer); - Assert.Contains("ShouldLaunchTray", installer); - Assert.Contains("Skipping post-install tray launch", installer); - Assert.DoesNotContain(@"Filename: ""{tmp}\vc_redist.exe""", installer); - Assert.Contains("Get-AuthenticodeSignature -LiteralPath $File.FullName", verifier); - Assert.Contains("Get-VCRuntimeFiles", verifier); - Assert.Contains("vcruntime140.dll", verifier); - Assert.Contains("libsodium.dll", verifier); - Assert.Contains("OpenClawNativeDependencyProbe", verifier); - Assert.Contains("Microsoft.ML.OnnxRuntime.dll", verifier); - Assert.Contains("onnxruntime.dll", verifier); - Assert.Contains("sherpa-onnx-c-api.dll", verifier); - Assert.Contains("TTS native stack probe", verifier); - Assert.Contains("SkipNativeLoadProbe", verifier); - Assert.Contains("CopyOpenClawVCRuntimeToPublish", targets); - Assert.Contains("ResolveOpenClawVCRuntimeFromVSInstall", targets); - Assert.Contains("ResolveOpenClawVCRuntimeArm64FromVSInstall", targets); - Assert.Contains("VCRuntimeMinVersion", verifier); - } - - [Fact] - public void ReleaseWorkflow_PausesMsixForAlpha() - { - var workflow = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "ci.yml")); - - Assert.Contains("if: false # Paused for alpha.4; ship Inno setup and portable ZIP artifacts only.", workflow); - Assert.Contains("needs: [repo-hygiene, test, e2etests, build]", workflow); - Assert.DoesNotContain("Download win-x64 MSIX artifact", workflow); - Assert.DoesNotContain("Download win-arm64 MSIX artifact", workflow); - Assert.DoesNotContain("Sign Release MSIX Packages", workflow); - Assert.DoesNotContain(".msix", ExtractReleaseStep(workflow)); - } - - private static string ExtractReleaseStep(string workflow) - { - var start = workflow.IndexOf(" - name: Create Release", StringComparison.Ordinal); - Assert.True(start >= 0, "Could not find Create Release step."); - return workflow[start..]; - } - - private static string GetRepositoryRoot() - { - var env = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); - if (!string.IsNullOrWhiteSpace(env) && Directory.Exists(env)) - return env; - - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory != null) - { - if (File.Exists(Path.Combine(directory.FullName, "openclaw-windows-node.slnx")) && - Directory.Exists(Path.Combine(directory.FullName, "src"))) - { - return directory.FullName; - } - - directory = directory.Parent; - } - - throw new InvalidOperationException("Could not find repository root."); - } -} diff --git a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs index 7eb049397..0ccc5e998 100644 --- a/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs +++ b/tests/OpenClaw.Tray.Tests/SettingsRoundTripTests.cs @@ -53,7 +53,6 @@ public void RoundTrip_AllFields_Preserved() HubNavPaneOpen = false, TtsPiperVoiceId = "fr_FR-siwis-low", HasSeenActivityStreamTip = true, - SkippedUpdateTag = "v1.2.3", NotifyChatResponses = false, PreferStructuredCategories = true, UserRules = new List @@ -108,7 +107,6 @@ public void RoundTrip_AllFields_Preserved() Assert.Equal(original.HubNavPaneOpen, restored.HubNavPaneOpen); Assert.Equal(original.TtsPiperVoiceId, restored.TtsPiperVoiceId); Assert.Equal(original.HasSeenActivityStreamTip, restored.HasSeenActivityStreamTip); - Assert.Equal(original.SkippedUpdateTag, restored.SkippedUpdateTag); Assert.Equal(original.NotifyChatResponses, restored.NotifyChatResponses); Assert.Equal(original.PreferStructuredCategories, restored.PreferStructuredCategories); Assert.NotNull(restored.UserRules); @@ -173,7 +171,6 @@ public void MissingFields_UseDefaults() Assert.Null(settings.TtsElevenLabsModel); Assert.Null(settings.TtsElevenLabsVoiceId); Assert.False(settings.HasSeenActivityStreamTip); - Assert.Null(settings.SkippedUpdateTag); Assert.True(settings.NotifyChatResponses); Assert.True(settings.PreferStructuredCategories); // HubNavPaneOpen defaults to true (NavView starts expanded for new @@ -245,7 +242,6 @@ public void BackwardCompatibility_OldSettingsWithoutNewFields() Assert.Null(settings.TtsElevenLabsModel); Assert.Null(settings.TtsElevenLabsVoiceId); Assert.False(settings.HasSeenActivityStreamTip); - Assert.Null(settings.SkippedUpdateTag); Assert.True(settings.GlobalHotkeyEnabled); // HubNavPaneOpen wasn't in this older JSON shape; default true. Assert.True(settings.HubNavPaneOpen); diff --git a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs index bfc5d85a1..ec237bc70 100644 --- a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs +++ b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs @@ -138,8 +138,6 @@ public void DeepLinkHandler_HasTrayUtilityEntryPoints() Assert.Contains(@"case ""healthcheck"":", source); Assert.Contains("RunHealthCheck", source); - Assert.Contains(@"case ""check-updates"":", source); - Assert.Contains("CheckForUpdates", source); Assert.Contains(@"case ""logs"":", source); Assert.Contains("OpenLogFile?.Invoke", source); } diff --git a/tests/PackagingTests/Test-InnoUninstallOrdering.ps1 b/tests/PackagingTests/Test-InnoUninstallOrdering.ps1 deleted file mode 100644 index f1385d585..000000000 --- a/tests/PackagingTests/Test-InnoUninstallOrdering.ps1 +++ /dev/null @@ -1,637 +0,0 @@ -<# -.SYNOPSIS - Packaging test — verifies that Inno Setup's [UninstallRun] entry for - Uninstall-LocalGateway.ps1 runs BEFORE {app} directory deletion. - -.DESCRIPTION - WHAT THIS TEST VERIFIES - ----------------------- - RubberDucky finding 8 requires a packaging test that proves the script at - {app}\Uninstall-LocalGateway.ps1 can run (and does run) BEFORE Inno Setup - deletes the {app}\ directory during a silent uninstall. - - HOW IT WORKS - ------------ - 1. [BUILD] Require a pre-built Inno installer (.exe) via -InstallerPath, or - attempt to locate one in the expected build output path. - 2. [INSTALL] Run the installer silently to a temp prefix directory. - 3. [VERIFY] Assert that {app}\OpenClaw.Tray.WinUI.exe and - {app}\Uninstall-LocalGateway.ps1 both exist post-install. - 4. [UNINSTALL] Run unins000.exe /VERYSILENT /LOG=. - 5. [PARSE LOG] Grep the Inno uninstall log for: - a) Evidence that Uninstall-LocalGateway.ps1 was invoked (or - that the [UninstallRun] powershell entry ran). - b) Evidence that {app}\ directory was deleted. - c) Ordering: (a) appears BEFORE (b) in the log (by line number). - 6. [CLEANUP] Remove temp install directory and any WSL residual state created - by the test. (A fresh Inno install with no real gateway means - no WSL distro is ever registered, so WSL cleanup is a no-op.) - 7. [VERDICT] - PASS = files existed post-install AND hook line found before dir- - deletion line in the uninstall log. - FAIL = any of: files missing post-install, hook did not run, - hook ran AFTER directory deletion, or uninstall crashed. - SKIP = no Inno installer available at the expected/given path. - - NOTES ON INNO LOG FORMAT - ------------------------ - When Inno runs with /LOG= it writes a plain-text log with entries like: - Log opened. (YYYY-MM-DD) - ... - -- Run entry #0: Filename: powershell.exe ...Uninstall-LocalGateway.ps1... - ... - Dir: C:\...\OpenClawTray (directory): deleted. - Line ordering is the authoritative source of truth for the ordering check. - -.PARAMETER InstallerPath - Absolute path to the Inno-produced installer EXE. If omitted the test - searches standard build-output locations (publish-x64\installer\, - Output\OpenClawTray-Setup-x64.exe). If still not found the test exits - with SKIP. - -.PARAMETER TempInstallDir - Base directory under which a unique per-run subdirectory is created for - the test installation. Defaults to $env:TEMP\InnoOrderingTest. - The test cleans up this directory after completion (pass or fail). - -.PARAMETER KeepTempDir - When set, do NOT remove the temp install directory after the test. - Use for post-mortem investigation of a FAIL result. - -.PARAMETER OutputDir - Directory to write test artifacts (log, verdict.json, summary.md). - Defaults to .\packaging-test-output\\. - -.EXAMPLE - # Typical run (will locate installer automatically): - .\Test-InnoUninstallOrdering.ps1 - -.EXAMPLE - # Explicit installer path: - .\Test-InnoUninstallOrdering.ps1 -InstallerPath C:\build\OpenClawTray-Setup-x64.exe - -.EXAMPLE - # Keep temp dir for debugging: - .\Test-InnoUninstallOrdering.ps1 -InstallerPath C:\build\OpenClawTray-Setup-x64.exe -KeepTempDir - -.NOTES - Date: 2026-05-07 - Author: Bostick (Tester / Quality / Validation) - Branch: feat/wsl-gateway-uninstall - - Style mirrors validate-wsl-gateway-uninstall.ps1: - - Set-StrictMode -Version Latest - - $ErrorActionPreference = 'Stop' - - Structured step logging - - Stops processes by PID only - - No \\wsl$ or \\wsl.localhost paths - - EXIT CODES - ---------- - 0 PASS Ordering confirmed: hook ran, app dir deleted after. - 1 FAIL Ordering wrong, files missing, hook didn't run, or uninstall crashed. - 2 SKIP No installer available; test cannot run on this machine. - 3 ERROR Unexpected script error. -#> - -[CmdletBinding()] -param( - [string]$InstallerPath = "", - [string]$TempInstallDir = "", - [switch]$KeepTempDir, - [string]$OutputDir = "" -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -# --------------------------------------------------------------------------- -# Exit-code sentinels -# --------------------------------------------------------------------------- -$EXIT_PASS = 0 -$EXIT_FAIL = 1 -$EXIT_SKIP = 2 -$EXIT_ERROR = 3 - -# --------------------------------------------------------------------------- -# Script-level state -# --------------------------------------------------------------------------- -$script:steps = [System.Collections.Generic.List[object]]::new() -$script:verdict = 'UNKNOWN' -$utcStamp = (Get-Date).ToUniversalTime().ToString("yyyyMMdd-HHmmssZ") - -if ([string]::IsNullOrEmpty($OutputDir)) { - $OutputDir = Join-Path (Get-Location) "packaging-test-output\$utcStamp" -} -New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null - -# --------------------------------------------------------------------------- -# Logging helpers (mirror validate-wsl-gateway patterns) -# --------------------------------------------------------------------------- -function Add-Step { - param( - [string]$Name, - [string]$Status, # Passed | Failed | Skipped | Warning | Info - [string]$Message, - [hashtable]$Data = @{} - ) - $entry = [ordered]@{ - name = $Name - status = $Status - message = $Message - data = $Data - timestamp = (Get-Date).ToString("o") - } - $script:steps.Add($entry) - - $ts = (Get-Date).ToString("HH:mm:ss") - $color = switch ($Status) { - "Passed" { "Green" } - "Failed" { "Red" } - "Skipped" { "DarkGray" } - "Warning" { "Yellow" } - "Info" { "Cyan" } - default { "White" } - } - Write-Host "[$ts] [$Status] $Name — $Message" -ForegroundColor $color -} - -function Write-Info { - param([string]$Message) - $ts = (Get-Date).ToString("HH:mm:ss") - Write-Host "[$ts] $Message" -ForegroundColor DarkCyan -} - -# --------------------------------------------------------------------------- -# Write verdict JSON + summary MD -# --------------------------------------------------------------------------- -function Write-Results { - param( - [string]$Verdict, - [string]$Notes = "", - [int]$ExitCode = $EXIT_FAIL - ) - - $verdictData = [ordered]@{ - verdict = $Verdict - exit_code = $ExitCode - notes = $Notes - started_at = $script:startedAt - finished_at = (Get-Date).ToString("o") - installer = $script:installerPath - temp_dir = $script:tempInstallPath - output_dir = $OutputDir - steps = @($script:steps) - } - - $verdictPath = Join-Path $OutputDir "verdict.json" - $verdictData | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $verdictPath -Encoding UTF8 - - $summaryPath = Join-Path $OutputDir "summary.md" - $lines = @( - "# Inno Uninstall Ordering Test", - "", - "| Field | Value |", - "|-----------|-------|", - "| Verdict | $Verdict |", - "| ExitCode | $ExitCode |", - "| Installer | $($script:installerPath) |", - "| OutputDir | $OutputDir |", - "| Date | 2026-05-07 |", - "", - "## Notes", "", - $Notes, "", - "## Steps", "" - ) - foreach ($s in $script:steps) { - $lines += "- [$($s.status)] $($s.name): $($s.message)" - } - $lines | Set-Content -LiteralPath $summaryPath -Encoding UTF8 - - $verdictColor = switch ($Verdict) { - "PASS" { "Green" } - "SKIP" { "Cyan" } - "FAIL" { "Red" } - "ERROR" { "Red" } - default { "Yellow" } - } - Write-Host "" - Write-Host "════════════════════════════════════════" -ForegroundColor $verdictColor - Write-Host " VERDICT : $Verdict" -ForegroundColor $verdictColor - Write-Host " ExitCode : $ExitCode" -ForegroundColor $verdictColor - Write-Host " Output : $OutputDir" -ForegroundColor $verdictColor - Write-Host "════════════════════════════════════════" -ForegroundColor $verdictColor - Write-Host "" -} - -# --------------------------------------------------------------------------- -# Installer locator -# --------------------------------------------------------------------------- -function Find-Installer { - # Caller-supplied hint - if (-not [string]::IsNullOrEmpty($InstallerPath) -and (Test-Path -LiteralPath $InstallerPath)) { - return $InstallerPath - } - - # Script is in tests\PackagingTests\ → repo root is 3 levels up - $repoRoot = Split-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) -Parent - $candidates = @( - (Join-Path $repoRoot "Output\OpenClawTray-Setup-x64.exe"), - (Join-Path $repoRoot "installer-output\OpenClawTray-Setup-x64.exe"), - (Join-Path $repoRoot "publish-x64\installer\OpenClawTray-Setup-x64.exe") - ) - foreach ($c in $candidates) { - if (Test-Path -LiteralPath $c) { return $c } - } - - # Search Output/ recursively for any matching file - foreach ($searchRoot in @((Join-Path $repoRoot "Output"), (Join-Path $repoRoot "installer-output"))) { - if (Test-Path -LiteralPath $searchRoot) { - $found = Get-ChildItem -LiteralPath $searchRoot -Recurse -Filter "OpenClawTray-Setup*.exe" -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | Select-Object -First 1 - if ($found) { return $found.FullName } - } - } - - return $null -} - -# --------------------------------------------------------------------------- -# Parse Inno uninstall log for ordering evidence -# --------------------------------------------------------------------------- -function Test-UninstallLogOrdering { - param([string]$LogPath) - - if (-not (Test-Path -LiteralPath $LogPath)) { - return [ordered]@{ - log_found = $false - hook_line_index = -1 - dir_delete_line_index = -1 - hook_ran = $false - dir_deleted = $false - ordering_correct = $false - notes = "Log file not found: $LogPath" - } - } - - $lines = Get-Content -LiteralPath $LogPath -Encoding UTF8 -ErrorAction SilentlyContinue - - $hookIdx = -1 - $dirDelIdx = -1 - - for ($i = 0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - - # Hook evidence: Inno logs [UninstallRun] execution in multiple ways: - # "-- Run entry #0:" (start of run-entry block) - # "Executing: powershell.exe" ... "Uninstall-LocalGateway" (run detail) - # "Process exit code: 0" (completion) - # We key on the first mention of the script name in the run section. - if ($hookIdx -eq -1) { - if ($line -match 'Uninstall-LocalGateway' -or - $line -match 'UninstallLocalGateway' -or - ($line -match 'Run entry' -and $line -match 'powershell') -or - ($line -match 'Exec:.*powershell.*Uninstall') -or - ($line -match 'StatusMsg.*Removing local WSL gateway')) { - $hookIdx = $i - } - } - - # Directory deletion evidence: - # "Dir: C:\...\OpenClawTray (directory): deleted." - # "Deleting directory: C:\..." - if ($dirDelIdx -eq -1) { - if (($line -match '(?i)deleting directory') -or - ($line -match '(?i)Dir:.*directory.*delet')) { - # Ensure it's the {app} directory, not a subdirectory cleanup - if ($line -match 'OpenClawTray' -or $line -match [regex]::Escape($script:appDirPattern)) { - $dirDelIdx = $i - } - } - } - } - - $hookRan = ($hookIdx -ge 0) - $dirDeleted = ($dirDelIdx -ge 0) - $orderingOk = $hookRan -and $dirDeleted -and ($hookIdx -lt $dirDelIdx) - - return [ordered]@{ - log_found = $true - log_line_count = $lines.Count - hook_line_index = $hookIdx - hook_line_text = if ($hookIdx -ge 0) { $lines[$hookIdx] } else { "" } - dir_delete_line_index = $dirDelIdx - dir_delete_line_text = if ($dirDelIdx -ge 0) { $lines[$dirDelIdx] } else { "" } - hook_ran = $hookRan - dir_deleted = $dirDeleted - ordering_correct = $orderingOk - notes = if ($orderingOk) { - "hook at line $hookIdx < dir-delete at line $dirDelIdx — ordering CORRECT" - } elseif (-not $hookRan) { - "hook entry not found in log — [UninstallRun] may not have run" - } elseif (-not $dirDeleted) { - "dir-delete entry not found in log — check Inno verbosity" - } else { - "ORDERING WRONG: hook at line $hookIdx >= dir-delete at line $dirDelIdx" - } - } -} - -# --------------------------------------------------------------------------- -# WSL residual cleanup (no-op for a clean install with no gateway) -# --------------------------------------------------------------------------- -function Invoke-WslCleanupCheck { - $wslLines = @() - try { - $raw = & wsl --list --quiet 2>&1 - $wslLines = ($raw | Out-String) -split "`r?`n" | - ForEach-Object { ($_ -replace '\x00', '').Trim() } | - Where-Object { $_ } - } - catch { } - - $openClawDistros = @($wslLines | Where-Object { $_ -like '*OpenClawGateway*' }) - if ($openClawDistros.Count -gt 0) { - Add-Step "wsl-cleanup-check" "Warning" "$($openClawDistros.Count) OpenClawGateway distro(s) still registered after uninstall. Unexpected for a fresh-install test." @{ - distros = $openClawDistros - } - } - else { - Add-Step "wsl-cleanup-check" "Passed" "No OpenClawGateway WSL distros registered (expected for a fresh-install test)." - } -} - -# --------------------------------------------------------------------------- -# MAIN -# --------------------------------------------------------------------------- -$script:startedAt = (Get-Date).ToString("o") -$script:installerPath = "" -$script:tempInstallPath = "" -$script:appDirPattern = "" - -Write-Host "" -Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan -Write-Host "║ Test-InnoUninstallOrdering.ps1 (2026-05-07) ║" -ForegroundColor Cyan -Write-Host "║ Verifies [UninstallRun] hook runs BEFORE {app} deletion ║" -ForegroundColor Cyan -Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan -Write-Host " OutputDir : $OutputDir" -Write-Host "" - -$exitCode = $EXIT_ERROR - -try { - - # ===================================================================== - # STEP 1 — Locate installer - # ===================================================================== - $foundInstaller = Find-Installer - if ([string]::IsNullOrEmpty($foundInstaller)) { - Add-Step "locate-installer" "Skipped" "No Inno installer found. Pass -InstallerPath to specify one explicitly." @{ - searchedPaths = @("Output\OpenClawTray-Setup-x64.exe", - "installer-output\OpenClawTray-Setup-x64.exe", - "publish-x64\installer\OpenClawTray-Setup-x64.exe") - } - Write-Info "SKIP: No installer available on this machine. Build the installer first or use -InstallerPath." - Write-Results -Verdict "SKIP" -ExitCode $EXIT_SKIP ` - -Notes "Installer not found. Build with 'iscc installer.iss' or pass -InstallerPath." - exit $EXIT_SKIP - } - - $script:installerPath = $foundInstaller - Add-Step "locate-installer" "Passed" "Installer found: $foundInstaller" - Write-Info "Installer: $foundInstaller" - - # ===================================================================== - # STEP 2 — Create a temp install prefix - # ===================================================================== - if ([string]::IsNullOrEmpty($TempInstallDir)) { - $TempInstallDir = Join-Path $env:TEMP "InnoOrderingTest" - } - $runId = [System.Guid]::NewGuid().ToString("N").Substring(0, 8) - $tempInstallPath = Join-Path $TempInstallDir "run-$runId" - New-Item -ItemType Directory -Force -Path $tempInstallPath | Out-Null - $script:tempInstallPath = $tempInstallPath - $script:appDirPattern = $tempInstallPath # Inno will install to this dir - - Add-Step "create-temp-dir" "Passed" "Temp install prefix: $tempInstallPath" - Write-Info "Temp install dir: $tempInstallPath" - - # ===================================================================== - # STEP 3 — Silent install - # ===================================================================== - Write-Info "Running silent install..." - $installLog = Join-Path $OutputDir "install.log" - $installArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', - "/DIR=$tempInstallPath", "/LOG=$installLog") - - try { - $proc = Start-Process -FilePath $foundInstaller -ArgumentList $installArgs ` - -Wait -PassThru -WindowStyle Hidden - $installExitCode = $proc.ExitCode - Add-Step "silent-install" "Passed" "Installer exited $installExitCode." @{ - installerPath = $foundInstaller - installDir = $tempInstallPath - logPath = $installLog - exitCode = $installExitCode - } - Write-Info "Install exit code: $installExitCode" - } - catch { - Add-Step "silent-install" "Failed" "Installer threw: $($_.Exception.Message)" - Write-Results -Verdict "FAIL" -ExitCode $EXIT_FAIL ` - -Notes "Silent install threw an exception: $($_.Exception.Message)" - exit $EXIT_FAIL - } - - # ===================================================================== - # STEP 4 — Verify post-install file presence - # ===================================================================== - $exePath = Join-Path $tempInstallPath "OpenClaw.Tray.WinUI.exe" - $hookScriptPath = Join-Path $tempInstallPath "Uninstall-LocalGateway.ps1" - - $exeExists = Test-Path -LiteralPath $exePath - $hookScriptExists = Test-Path -LiteralPath $hookScriptPath - - if (-not $exeExists -or -not $hookScriptExists) { - Add-Step "verify-post-install-files" "Failed" "Expected files missing post-install." @{ - "OpenClaw.Tray.WinUI.exe exists" = $exeExists - "Uninstall-LocalGateway.ps1 exists" = $hookScriptExists - } - Write-Results -Verdict "FAIL" -ExitCode $EXIT_FAIL ` - -Notes "Post-install file check failed. EXE=$exeExists Hook=$hookScriptExists" - exit $EXIT_FAIL - } - - Add-Step "verify-post-install-files" "Passed" "Both required files exist post-install." @{ - exePath = $exePath - hookScriptPath = $hookScriptPath - } - - # ===================================================================== - # STEP 5 — Locate unins000.exe - # ===================================================================== - $uninsExe = Join-Path $tempInstallPath "unins000.exe" - if (-not (Test-Path -LiteralPath $uninsExe)) { - # Inno may produce unins001.exe etc. if a previous install left a unins000. - $uninsExe = Get-ChildItem -LiteralPath $tempInstallPath -Filter "unins*.exe" ` - -ErrorAction SilentlyContinue | Sort-Object Name | Select-Object -First 1 | - ForEach-Object { $_.FullName } - if ([string]::IsNullOrEmpty($uninsExe)) { - Add-Step "locate-uninstaller" "Failed" "unins000.exe not found in $tempInstallPath" - Write-Results -Verdict "FAIL" -ExitCode $EXIT_FAIL -Notes "Inno uninstaller not found." - exit $EXIT_FAIL - } - } - Add-Step "locate-uninstaller" "Passed" "Uninstaller: $uninsExe" - - # ===================================================================== - # STEP 6 — Silent uninstall with log capture - # ===================================================================== - $uninstallLog = Join-Path $OutputDir "uninstall.log" - $uninstallArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', - "/LOG=$uninstallLog") - - Write-Info "Running silent uninstall..." - try { - $proc2 = Start-Process -FilePath $uninsExe -ArgumentList $uninstallArgs ` - -Wait -PassThru -WindowStyle Hidden - $uninstallExitCode = $proc2.ExitCode - Add-Step "silent-uninstall" "Passed" "Uninstaller exited $uninstallExitCode." @{ - uninsExe = $uninsExe - logPath = $uninstallLog - exitCode = $uninstallExitCode - } - Write-Info "Uninstall exit code: $uninstallExitCode" - } - catch { - Add-Step "silent-uninstall" "Failed" "Uninstaller threw: $($_.Exception.Message)" - Write-Results -Verdict "FAIL" -ExitCode $EXIT_FAIL ` - -Notes "Silent uninstall threw: $($_.Exception.Message)" - exit $EXIT_FAIL - } - - # ===================================================================== - # STEP 7 — Parse log: verify hook ran AND ordering is correct - # ===================================================================== - Write-Info "Parsing uninstall log for ordering evidence..." - $ordering = Test-UninstallLogOrdering -LogPath $uninstallLog - - # Save ordering analysis - $orderingPath = Join-Path $OutputDir "ordering-analysis.json" - $ordering | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $orderingPath -Encoding UTF8 - - $orderingStatus = if ($ordering.ordering_correct) { "Passed" } ` - elseif (-not $ordering.log_found) { "Warning" } ` - else { "Failed" } - - Add-Step "log-ordering-check" $orderingStatus $ordering.notes @{ - log_path = $uninstallLog - hook_line = $ordering.hook_line_index - hook_line_text = $ordering.hook_line_text - dir_delete_line = $ordering.dir_delete_line_index - dir_delete_line_text = $ordering.dir_delete_line_text - ordering_correct = $ordering.ordering_correct - analysis_file = $orderingPath - } - - # ===================================================================== - # STEP 7b — Supplemental: verify {app} dir was deleted after uninstall - # ===================================================================== - $appDirGone = -not (Test-Path -LiteralPath $tempInstallPath) - if ($appDirGone) { - Add-Step "verify-app-dir-deleted" "Passed" "{app} directory removed by uninstaller (expected)." - } - else { - Add-Step "verify-app-dir-deleted" "Warning" "{app} directory still exists after uninstall: $tempInstallPath" - } - - # ===================================================================== - # STEP 8 — WSL cleanup check - # ===================================================================== - Invoke-WslCleanupCheck - - # ===================================================================== - # STEP 9 — Final verdict - # ===================================================================== - - # Determine if the log ordering check was conclusive. - # If the log wasn't found or the hook was not in it, try a weaker check: - # look for evidence in the uninstall log that Uninstall-LocalGateway.ps1 was - # at least attempted (it may exit 0 quietly without verbose log entries). - $hookConfirmed = $ordering.hook_ran - - if (-not $hookConfirmed -and $ordering.log_found) { - # Secondary check: scan raw log for the script name anywhere - $rawLog = Get-Content -LiteralPath $uninstallLog -Raw -Encoding UTF8 -ErrorAction SilentlyContinue - if ($rawLog -match 'Uninstall-LocalGateway') { - Add-Step "secondary-hook-check" "Passed" "Secondary scan found 'Uninstall-LocalGateway' in uninstall log." - $hookConfirmed = $true - } - else { - Add-Step "secondary-hook-check" "Warning" "'Uninstall-LocalGateway' not found anywhere in uninstall log. The [UninstallRun] entry may not have been executed." - } - } - elseif (-not $ordering.log_found) { - Add-Step "secondary-hook-check" "Warning" "Cannot perform secondary check: uninstall log not found." - } - - # Determine pass/fail/skip - if (-not $ordering.log_found) { - # No log = can't confirm ordering; FAIL with guidance - $finalVerdict = "FAIL" - $notes = "Uninstall log not produced. Ensure Inno's /LOG= switch works for this installer version." - $exitCode = $EXIT_FAIL - } - elseif ($ordering.ordering_correct) { - $finalVerdict = "PASS" - $notes = $ordering.notes - $exitCode = $EXIT_PASS - } - elseif (-not $hookConfirmed) { - $finalVerdict = "FAIL" - $notes = "Hook not confirmed in log. [UninstallRun] entry may be missing or not triggered." - $exitCode = $EXIT_FAIL - } - else { - $finalVerdict = "FAIL" - $notes = $ordering.notes - $exitCode = $EXIT_FAIL - } - - $script:verdict = $finalVerdict - Write-Results -Verdict $finalVerdict -ExitCode $exitCode -Notes $notes - -} -catch { - $errMsg = $_.Exception.Message - Add-Step "unhandled-error" "Failed" $errMsg - Write-Host "ERROR: $errMsg" -ForegroundColor Red - Write-Results -Verdict "ERROR" -ExitCode $EXIT_ERROR -Notes $errMsg - $exitCode = $EXIT_ERROR -} -finally { - # Cleanup temp install directory unless -KeepTempDir or it was already removed by uninstall - if (-not $KeepTempDir -and -not [string]::IsNullOrEmpty($script:tempInstallPath)) { - if (Test-Path -LiteralPath $script:tempInstallPath) { - try { - Remove-Item -LiteralPath $script:tempInstallPath -Recurse -Force -ErrorAction SilentlyContinue - Write-Info "Temp install dir removed: $($script:tempInstallPath)" - } - catch { - Write-Info "Warning: could not remove temp dir: $($script:tempInstallPath) — $($_.Exception.Message)" - } - } - } - - # Also remove the parent temp base dir if it was auto-created and is now empty - if (-not $KeepTempDir -and -not [string]::IsNullOrEmpty($TempInstallDir)) { - if (Test-Path -LiteralPath $TempInstallDir) { - $remaining = @(Get-ChildItem -LiteralPath $TempInstallDir -ErrorAction SilentlyContinue) - if ($remaining.Count -eq 0) { - Remove-Item -LiteralPath $TempInstallDir -Force -ErrorAction SilentlyContinue - } - } - } -} - -exit $exitCode