diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a11cb8c7d..76220a4fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,20 @@ jobs: shell: pwsh run: ./scripts/test-msix-ci-artifacts.ps1 + - name: Validate developer migration test package + shell: pwsh + run: ./scripts/test-migration-test-msix.ps1 + + # installer.iss is only compiled for real in the tag-only release job, so a + # Pascal Script error would otherwise be found at release time. The runner image + # does not ship Inno Setup; the release job installs it the same way. + - name: Install Inno Setup + run: choco install innosetup -y --no-progress + + - name: Compile installer script + shell: pwsh + run: ./scripts/Test-InstallerScriptCompiles.ps1 -RequireCompiler + - name: Validate Store MSIX bundle construction shell: pwsh run: ./scripts/test-msix-bundle.ps1 @@ -936,6 +950,50 @@ jobs: artifacts/msix/${{ matrix.architecture }}/msix-metadata.json if-no-files-found: error + - name: Resolve migration build switch + id: migration-switch + shell: pwsh + run: | + $override = $env:MigrationProductionEnabled + if ([string]::IsNullOrWhiteSpace($override)) { + $propsPath = Join-Path $env:GITHUB_WORKSPACE 'src\OpenClaw.Tray.WinUI\Migration.Build.props' + [xml]$props = Get-Content -LiteralPath $propsPath -Raw + $node = $props.SelectSingleNode('/Project/PropertyGroup/MigrationProductionEnabled') + if ($null -eq $node) { + throw "MigrationProductionEnabled is not declared in $propsPath." + } + $override = $node.InnerText + } + $value = $override.Trim().ToLowerInvariant() + if ($value -ne 'true' -and $value -ne 'false') { + throw "MigrationProductionEnabled must be true or false, got '$override'." + } + "enabled=$value" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Stage developer migration test package + if: steps.migration-switch.outputs.enabled == 'true' + shell: pwsh + run: > + .\scripts\Export-MigrationTestMsix.ps1 -Architecture ${{ matrix.architecture }} + -PackageDirectory "artifacts\msix\${{ matrix.architecture }}" + -OutputDirectory "artifacts\msix-migration-test\${{ matrix.architecture }}" + -VersionInfoPath "$env:RUNNER_TEMP\openclaw-msix-version.json" + + - name: Upload developer migration test artifact + if: steps.migration-switch.outputs.enabled == 'true' + uses: actions/upload-artifact@v7 + with: + name: openclaw-msix-dev-migration-test-${{ matrix.architecture }} + path: | + artifacts/msix-migration-test/${{ matrix.architecture }}/OpenClaw-MigrationTest-${{ matrix.architecture }}.msix + artifacts/msix-migration-test/${{ matrix.architecture }}/OpenClaw-MigrationTest.cer + artifacts/msix-migration-test/${{ matrix.architecture }}/msix-metadata.json + artifacts/msix-migration-test/${{ matrix.architecture }}/INSTALL.txt + if-no-files-found: error + # The disposable signer is valid for 30 days and the package is not timestamped, + # so a longer retention would only offer an artifact that can no longer install. + retention-days: 30 + - name: Provision disposable Dev MSIX certificate shell: pwsh run: .\scripts\setup-dev-msix-cert.ps1 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cd58769cb..fc1e5fffc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -542,6 +542,10 @@ leading and trailing pipe. Columns, in order: | inno-migration-startup | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | migration record parsing and completed-handoff admission | MigrationRecordCodec + InnoMigrationStartupGuard | App invokes the guard before ordinary startup; explicit destructive CLI uninstall remains separate | completed migration prevents normal unpackaged startup before settings or activation; development and packaged apps are unaffected | InnoMigrationContractTests.CompletedMigrationGuard_PrecedesSettingsAndActivation | source-shape | when packaged/unpackaged migration startup is fully hosted outside App | | store-migration-startup | authoritative | src/OpenClaw.Tray.WinUI/App.xaml.cs | Inno discovery, pending-record inspection, and Store migration admission policy | InnoInstallationDetector + MigrationStartupRecordReader + StoreMigrationStartupCoordinator + StoreMigrationStartupGuard | App calls the compile-time-gated adapter before instance forwarding and normal services | disabled builds perform no migration inspection; preview admission never mutates source state or enables normal startup for pending migration | StoreMigrationStartupCoordinatorTests.Disabled_DoesNotInspectInstallationOrRecords | behavioral | - | | store-migration-startup-closed | closed | src/OpenClaw.Tray.WinUI/App.xaml.cs | direct registry discovery and migration startup policy | StoreMigrationStartupGuard + StoreMigrationStartupCoordinator | one adapter invocation after explicit CLI uninstall and before protocol processing and instance forwarding | App does not regain installation discovery, record parsing, or admission decisions | InnoMigrationContractTests.StorePreviewGuard_PrecedesInstanceForwardingAndNormalServices | source-shape | when packaged migration bootstrap is hosted outside App | +| store-migration-workflow | authoritative | StoreMigrationStartupGuard | native message-box consent loop and inline adoption composition | StoreMigrationWorkflow + StoreMigrationOperations + StoreMigrationWindow | guard gates bootstrap and owns the pre-services window lifetime only | UI actions are serialized; explicit consent precedes shutdown and validation; only verified finalization permits normal startup | StoreMigrationWorkflowTests.ExplicitConfirmation_RecordsConsentBeforeGracefulShutdownAndCompletion | behavioral | - | +| inno-migration-handoff | authoritative | SettingsPage + App activation composition | explicit consent, Store listing policy, and migration shutdown authorization | InnoMigrationHandoff + InnoMigrationConsentStore + StoreMigrationListing | Settings forwards the action and displays its result; App supplies its existing shutdown callback | only configured production Release or explicit preview builds expose handoff; Dev stays isolated; exact same-user Inno and protected consent precede canonical graceful exit; inventory never means consent | InnoMigrationContractTests.InnoPreview_GatesHandoffAndUsesCanonicalShutdown | source-shape | when migration handoff is retired | +| migration-build-policy | authoritative | OpenClaw.Tray.WinUI.csproj | shared Inno/Store compile-time activation and fixed source floor | Migration.Build.props | the tray project imports one policy for packaged and unpackaged publishing | production requires a pinned nonzero source floor and supported Release RID; Debug and Dev cannot inherit production activation | MigrationBuildConfigurationTests.Production_InnoAndStoreShareThePinnedReleasePolicy | behavioral | - | +| migration-consent-locking | authoritative | InnoMigrationConsentStore | protected consent publication and inspection while source is running | MigrationOperationLock + InnoMigrationConsentStore | consent uses a shared preparation lease and a separate exclusive writer lock | consent remains compatible with Inno runtime readers but cannot overlap exclusive Store mutation; finalization removes the writer lock before deleting the completion receipt | InnoMigrationConsentStoreTests.RuntimeReadLease_AllowsExplicitGrantAndInspection | behavioral | - | | chat-markdown-presentation | authoritative | src/OpenClaw.Tray.WinUI/Chat/ReactorChatTimeline.cs | paragraph and heading typography, literal code-frame presentation and Copy | ChatMarkdownPresentation | timeline keeps sanitization, parser flags, inert links/images and the existing list-wrapping callback | presentation uses public MarkdownOptions callbacks; code content is never truncated or parsed as markup | ReactorChatLayoutProofTests.CodeBlock_PreservesLiteralContentAndCopiesExactText | behavioral | - | | chat-model-picker-presentation | authoritative | src/OpenClaw.Tray.WinUI/Chat/ReactorChatComposer.cs, then custom Button rows in ChatModelPicker | catalog search, provider groups, native row interaction states and model metadata presentation | NativeChatModelPicker through a generated Reactor wrapper | ChatModelPicker owns flyout lifetime; composer controller owns model mutation and authorization | native single-selection navigation never writes a model until explicit activation; filtering and resizing preserve raw provider-qualified identities | ReactorChatLayoutProofTests.ModelMenu_UsesNativeSelectionStatesAndPreservesNavigationDuringResize | behavioral | - | | chat-model-picker-button-rows-closed | closed | src/OpenClaw.Tray.WinUI/Chat/ChatModelPicker.cs | hand-built Button rows, custom selected fill/checkmark and search-box styling | NativeChatModelPicker | declarative trigger and flyout lifetime only | native AutoSuggestBox/ListView own search and row interaction visuals; no duplicate hand-styled selection path | ReactorChatLayoutProofTests.ModelMenu_UsesNativeSelectionStatesAndPreservesNavigationDuringResize | behavioral | - | diff --git a/docs/ONBOARDING_WIZARD.md b/docs/ONBOARDING_WIZARD.md index b7aaa109f..7716b03a0 100644 --- a/docs/ONBOARDING_WIZARD.md +++ b/docs/ONBOARDING_WIZARD.md @@ -255,6 +255,43 @@ The setup flow walks users through: The setup flow no longer configures remote/manual gateways inline. The Welcome page's **Connect to an existing gateway** option routes through `AdvancedSetupPage`, closes setup, and opens the tray app's Connections tab. +### Store migration preview + +The gated Inno-to-Store migration window reuses the wizard's visual design: +Mica backdrop, mascot, centered heading, themed content card, and persistent +footer actions. Its content scrolls independently so consent and recovery +actions remain available in smaller windows or with enlarged text. +Consent uses three short titled sections and a separate, non-dismissible warning. +Manual close and removal use step-specific headings and short instructions; +warnings stay in the same native InfoBar style as setup. Buttons retain the +wizard's 100-DIP minimum width, neutral left-hand dismissal, accent right-hand +primary action, and standard content/footer spacing. The title bar stays +"Move to the Store version"; retry/recovery states do not use setup step dots. +The window also sets the native OpenClaw icon for taskbar previews and the +window switcher; the custom title-bar image alone does not supply that icon. +The migration-capable Inno Settings page promotes this handoff with a +theme-aware recommended card that reuses the same card padding, typography, +accent badge, and primary-button styling as the rest of the app. The card +carries no decorative icon, so its text aligns with every other settings row, +and its action shares that single row. Its confirmation dialog +asks a question rather than repeating the button label, and states the handoff +grant and preserved data; detailed removal and finalization guidance stays in +the Store wizard, where the user can act on it. + +This is a separate pre-start workflow, not a page in the setup pipeline. +`StoreMigrationStartupGuard` and `StoreMigrationWorkflow` retain migration +ownership; displaying the window does not construct `SetupWindow`, install a +gateway, or start normal app services. Consent, retry, and uninstall verification +are unchanged. The same visual workflow serves configured production Release +builds and explicit Debug previews. The first supported Inno release is pinned +to `2026.9.5.0`, and `MigrationProductionEnabled` is checked in as `true`, so +non-Dev Release builds for `win-x64` and `win-arm64` carry the migration +surfaces. Enablement is gated at tag time, not by the checked-in default: +publication is blocked until that release's artifacts and acceptance are +verified, and the switch is set to `false` and retagged if acceptance fails. +See [Release migration gates](RELEASING.md) for the shared build contract and +the two-PR coordinated-release requirements. + ## Screen Details ### Welcome diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 35e444c06..74c6236bf 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -19,11 +19,19 @@ submission. Dev-signed packages stay in Actions. ## Inno-to-Store migration foundation -Issue #1374's foundation does not advertise or initiate migration. No Store -action, MSIX importer, or completion writer is enabled by this change. - -`MigrationPreparation.Prepare` is a non-UI API for a future explicitly consented -Inno action. It inventories existing state without moving the WSL gateway or +Issue #1374 is delivered in two PRs: the preservation/startup foundation, then +the complete migration experience and release enablement. There is no planned +third PR. The Store product ID is **`9NFPR3BGDRR5`**. The selected first supported +Inno release is **`v2026.9.5`**, the planned next patch after `v2026.9.4`, provided +both PRs ship together. The fixed source floor is pinned to **`2026.9.5.0`**. +`MigrationProductionEnabled` is checked in as `true` so the coordinated release +and its CI artifacts carry migration; publication stays gated on the tag-time +acceptance listed under [Coordinated production release](#coordinated-production-release) +below. A listing ID is not evidence that a Store-distributed +package has passed migration acceptance. + +`MigrationPreparation.Prepare` is the non-UI inventory API used after explicit +consent and verified source shutdown. It inventories state without moving the WSL gateway or Local AI payload. Its protected intent is stored under `%APPDATA%\OpenClawTray\store-migration\intent.dpapi`. Repeating preparation with unchanged state reuses the unexpired intent. Intent expires after 30 days; only @@ -49,7 +57,7 @@ and exited processes do not block. Unresolved possible source processes still fail closed. The finalizer serializes with `prepare.lock`, rereads the DPAPI completion receipt under that lock, and recaptures the bounded migration inventory before applying the receipt's saved auto-start preference through the -packaged startup API. It deletes intent followed by the same validated completion +packaged startup API. It deletes explicit consent, intent, then the same validated completion receipt, so a crash after intent deletion retries from the retained receipt. It fails closed on source inspection, receipt, inventory, startup-preference, or cleanup failure and leaves the receipt for restart recovery. It never starts @@ -109,6 +117,16 @@ sessions under that lock, using the same fail-closed process policy as finalizat Running sources and busy locks return the close-Inno/Retry decision; uncertain process inspection blocks migration without publishing a record. +Explicit consent is allowed while Inno holds its runtime reader: consent reads +and grants take a read-shared `prepare.lock` handle, which still excludes Store's +exclusive preparation, completion, and finalization. Grants also take an exclusive +`consent.lock` to serialize writers across sessions. A read never creates either +lock. A durable consent reader can finish while another writer is waiting, and +atomic replacement refuses to overwrite a read-locked record. Consent alone +never permits state adoption or preservation-mode uninstall. Finalization removes +the consent writer lock before deleting consent, intent, and completion, keeping +the completion receipt last. + The file handle, not the session-local single-instance mutex, provides continuous cross-session exclusion for participating Inno releases. Process inspection is a compatibility backstop for already-running older sources, not a lease preventing @@ -120,18 +138,124 @@ Before rollout, prove exact signed x64 and ARM64 packages, storage/DPAPI access, restart recovery, current-head guidance, and manual uninstall preservation. Passing unit or PowerShell contract tests is not signed-package migration proof. -### Store migration consent preview - -The Store-side preview adds installation detection, startup admission, and an -explicit consent/close-Inno retry flow, and it writes protected intent and -completion records. It is not the full migration journey: runtime state adoption, -source-version enablement, and the recovery/finalization journey remain out of -scope. **Ordinary -builds remain unchanged.** There is no -production minimum source version, runtime toggle, or environment-variable -bypass. Do not enable migration by choosing the current app version as a -placeholder for a verified safeguard-containing Inno release. - +### Coordinated production release + +`src\OpenClaw.Tray.WinUI\Migration.Build.props` owns the common Inno and Store +build contract. The tray project imports it for both x64 and ARM64 publishing, +including CI's installer and Store package payloads: + +| Property | Checked-in value | Shipping requirement | +|---|---|---| +| `MigrationStoreProductId` | `9NFPR3BGDRR5` | Verify the listing and installed production package identity. | +| `MigrationMinimumSourceVersion` | `2026.9.5.0` | Verify that the selected first stable Inno release contains all preservation, lifetime-lock, and startup safeguards. | +| `MigrationProductionEnabled` | `true` | Enabled for the coordinated `2026.9.5` release. Set to `false` to ship a release without migration. | + +Production builds emit `PRODUCTION_MIGRATION` and protected-flow configuration +metadata only for non-Dev **Release** builds targeting `win-x64` or `win-arm64`. +Enabling production without a valid, nonzero three- or four-part numeric source +floor fails the build. Debug and Dev builds remain outside this production gate; +Debug previews require their separate explicit flags. There is no runtime or +environment-variable activation switch. + +The same coordinated release can introduce the safeguards and the full migration +flow. A separate preliminary Inno release is not required. Users on older Inno +versions must **update Inno first**, then migrate: Store installation cannot repair +an older uninstaller or give an older running binary the lifetime lock. + +The minimum is the **fixed first supported Inno version**, not the current target +app version, GitVersion output, or reserved MSIX package version. Do not raise it +on each subsequent release. If the first release changes before shipping, update +the single pinned value and repeat acceptance with those exact artifacts. + +Before tagging the coordinated release or claiming issue #1374 complete: + +- Verify the selected `v2026.9.5` release contains both PRs and make that supported + Inno update available to existing users, with update-first guidance for older + sources. If the release number or contents change, revise the pin before shipping. +- Prove exact production identity, Store listing handoff, DPAPI/storage access, + and signed x64 and ARM64 packages through the real distribution path. +- Exercise fresh consent, running-Inno graceful shutdown, manual close/Retry, + restart recovery, older-source rejection, and ordinary non-migration startup. +- Verify normal interactive and silent Inno removal preserves the completed + migration, while explicit full cleanup retains its separate destructive contract. +- Verify saved gateway credentials, a real WSL or remote gateway connection, + Local AI state, and startup preference survive; Store services start only after + exact source removal and finalization. +- Collect current UI evidence for longer consent, light/dark themes, compact + windows, scaling, keyboard navigation, and screen-reader behavior. Unit tests, + fixture screenshots, and locally test-signed packages do not replace missing + signed-package, ARM64, accessibility, or gateway continuity proof. + +These are tag-time gates, not merge-time gates. The switch is checked in as `true` +so the coordinated release and its CI artifacts carry migration, but a tag must not +be published until each item above is satisfied against that tag's real artifacts. +If acceptance fails before the Store package is published, set +`MigrationProductionEnabled` to `false` and retag rather than shipping an unverified +launch gate. A synthetic source floor supplied for disposable package testing is not +approval of a production release version. + +The switch is a pre-publication gate, not a recall. Once a Store package carrying +`PRODUCTION_MIGRATION` is published, the floor and the entire flow are compiled into +that package: a later Inno release built with the switch off cannot disable migration +for users who already have the Store build, and that Inno release still sits above the +pinned floor. Turning the switch off only prevents *new* migration-capable Store +builds. To stop migrations already reaching users, publish a corrected Store package. + +Two things keep the Store app from starting. A handoff holding a completion receipt +keeps it from starting because that state has data that must not be abandoned, and the +receipt decides this rather than the state name: a receipt still protects the handoff +when the source looks unsupported, when the recorded source version no longer matches, +or when the record cannot be decoded. Separately, a previous app that is positively +detected as installed keeps the Store app inactive even with no receipt, because issue +#1374 permits only one active production client. That block requires payload evidence, +not just an uninstall registration, so an interrupted uninstall that leaves an orphan +registry key cannot strand the user without a working client. The two blocks differ in +how long they last, and the difference matters when supporting a user. The receipt block +is durable: it records that data has already moved, so a later pass that fails or cannot +read the record does not release it. The installed-source block is only ever as good as +the pass that measured it, so it is recomputed every time and never carried forward. +Removing the previous app is precisely how a user ends that block, and a stale copy of it +would keep the Store app closed after the previous app was already gone. The remaining +unhappy paths, a failed inspection or a record needing recovery, inform the user and then +continue to normal startup, because refusing to launch cannot repair either one. +Still confirm before the tag that the released Inno installer registers +`DisplayVersion` `2026.9.5` and `DisplayName` `OpenClaw Companion version 2026.9.5`. +Both are refused, and each produces a different message, so check for the right one. +A prerelease suffix is a version refusal: that user is told to update the previous app +to a supported version first. A mismatched name is an unsupported installation: that +user is told migration is unavailable. Neither is offered migration, so the registered +values still have to be exact. + +### Developer migration test package + +Neither standard MSIX artifact can exercise migration. The unsigned Store package +is a submission asset and cannot be installed, and the Dev package is refused by +the Store migration guard because its identity is not the production one. + +CI therefore also publishes `openclaw-msix-dev-migration-test-` from +`scripts\Export-MigrationTestMsix.ps1`: the production-identity Store package, +test-signed with a disposable certificate whose subject matches the production +publisher. "Dev" in the artifact name means *for developers*; the package identity +is production. + +That identity is what makes it useful and what makes it dangerous: + +- It shares a package family name with the Store release, so it is **not** + side-by-side. Uninstall the Store build before installing it. +- Migration runs against real data directories. The Store guard rejects data path + redirection, so it cannot be aimed at a scratch profile. +- With an Inno installation present, it will adopt and then uninstall it. + +Use a disposable machine or VM. The exporter refuses to sign an already-signed +package and refuses any package built without migration enabled, so the artifact +cannot silently ship disabled. It stays workflow-only and must never be published. + +### Migration experience and Debug previews + +The Store-side preview hosts a dedicated WinUI migration window before normal +services start. Release non-Dev builds now ship this experience by default; +Debug and Dev builds do not. Do not enable migration by choosing the current app +version as a placeholder for a verified safeguard-containing Inno release. An explicit test build may set `StoreMigrationPreview=true` and `StoreMigrationPreviewMinimumSourceVersion` to a three- or four-part numeric test source version. This is accepted only for **Debug MSIX builds with the @@ -147,6 +271,43 @@ dotnet publish .\src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj ` -p:StoreMigrationPreviewMinimumSourceVersion= ``` +The optional unpackaged Inno handoff is independently gated: + +```powershell +dotnet publish .\src\OpenClaw.Tray.WinUI\OpenClaw.Tray.WinUI.csproj ` + -c Debug -r win-x64 --self-contained -m:1 ` + -p:PackageMsix=false -p:DevBuild=false -p:InnoMigrationPreview=true ` + -p:MigrationPreviewStoreProductId= +``` + +Use that payload only in a disposable, exact current-user Inno fixture. +Release, packaged, and Dev-identity Inno previews fail the build. No preview +product ID is supplied by default. Without one, Settings hides **Install Store version +and migrate**. The ID accepts only 12 uppercase ASCII letters/digits and opens +`ms-windows-store://pdp/?ProductId=...`; do not substitute a guessed listing. +The shutdown receiver can still be exercised without a Store listing. + +The Settings action confirms explicit consent before saving `consent.dpapi` +and opening the listing. This is a distinct `consent` record in the shared +current-user DPAPI contract, not an inventory intent. It binds the exact +source version, user, architecture, canonical paths and target package identity, +expires after 30 days, and rejects a future creation time. Missing, expired, +invalid or source-mismatched consent requires Store confirmation. Operational +read failures block instead of silently consenting. Consent and completion +remain independent: consent never authorizes destructive uninstall. +Explicit confirmation does not overwrite corrupt consent. A rejected grant +enters recovery, which stays retryable: the window keeps Retry and the +Installed apps shortcut so a user who removes the previous app is noticed by +the next pass, and it offers to discard migration records that can no longer +carry the handoff. Records are judged one file at a time, so a corrupt receipt +beside a good intent costs only the receipt. Two things are discardable: a +record that does not decode, and a leftover consent or intent once the previous +app is gone and no readable receipt remains, because there is then nothing left +to migrate from. A receipt that still decodes is never deleted, and a retry +never treats an earlier confirmation as still standing. A discard that cannot +run, usually because the previous app is running and holds the migration lease, +is reported in the window rather than passing silently. + The preview: - Reads only the exact production Inno uninstall registration, not display-name @@ -156,45 +317,64 @@ The preview: - Requires coherent source version and executable architecture evidence. Prerelease/informational versions do not satisfy the stable version gate. - Reads existing intent/completion records through the shared DPAPI codec. - Invalid or inaccessible records block startup. An orphan intent requires - recovery; completion without Inno requires finalization, not fresh startup. + A completion receipt requires finalization before normal startup, and that holds + even when the receipt cannot be decoded or read: its presence on disk proves data + already moved. An orphan intent, or an unreadable record with no receipt beside it, + is reported and then gets out of the way, because refusing to launch cannot repair + it. Completion without Inno requires finalization, not fresh startup. - Stops before production instance forwarding, settings, gateway/node/MCP services, updates, or startup-task reconciliation when migration is needed. All normal launch, protocol, and startup-task activations use this gate. -- Shows a localized consent decision through native **Yes / No** buttons. - The body refers directly to those buttons, without a separate action legend. - A valid Inno intent does not replace that Store-side consent. +- Shows localized **Migrate / Not now** buttons unless valid explicit handoff + consent already exists. An inventory intent never replaces consent. + Keyboard focus defaults to Not now. Not now closes the Store window without + changing source state, and leaves the Store app inactive while the source app + is installed, per issue #1374. The user must migrate or remove the source app + before the Store app starts normally. + Unlike PR 1's native Yes/No preview, this window labels its buttons directly; + it does not append a separate action legend or reinterpret Yes/No as Migrate. Before acceptance, every locale discloses protected migration records, the normal Inno startup block after successful validation, and the required manual - uninstall followed by reopening Store. It explains preservation, no automatic + uninstall followed by Store finalization. It explains preservation, no automatic uninstall, and the option to leave without starting migration. Retry guidance - permits uninstall only after the preview reports recorded completion. -- After consent, checks the production Inno mutex. While Inno is running, the - preview instructs the user to close it, choose **Yes** to retry, or choose - **No** to exit the preview. It never force-closes the source process. + permits uninstall only after the window reports recorded completion. +- After consent, requests graceful exit through the existing current-user + activation pipe. This is a private fixed IPC message, not an `openclaw:` + route. Only the gated, exact Inno process accepts it after rechecking protected + consent, then delegates to the canonical app shutdown coordinator. The sender + bounds IPC to two seconds and checks mutex release for up to ten seconds. + Unsupported, unavailable or slow receivers leave **Close the previous app / + Retry** guidance. Sending the message alone is not proof of exit; source + ownership is checked again before preparation and completion. No force-kill. - When Inno is closed, the preview acquires exclusive migration ownership, rechecks exact source evidence, and writes a protected, DPAPI-bound inventory intent. It then captures the inventory again, requires its fingerprint to match the intent, and requires canonical operator credential resolution for the active saved gateway before atomically writing a protected completion - receipt. This is not state adoption. It does not start gateway, node, or MCP + receipt. Data is adopted in place. It does not start gateway, node, or MCP services; provision or repair gateways; delete source state; invoke uninstall; - or finalize migration. The receipt blocks normal Store startup and tells the - user to uninstall Inno manually. On a later Store start, only an exact + automatically. The receipt blocks normal Store startup and exposes **Open + Installed apps**. The user uninstalls Inno manually, then selects **Retry** or + reopens Store. Only an exact `NotInstalled` result plus absent canonical source payload/process/mutex evidence permits finalization. The finalizer does not acquire the Inno-visible mutex, serializes on `prepare.lock`, rereads and matches the durable completion receipt, recaptures the inventory fingerprint, applies the saved auto-start preference through the tray adapter, and clears records only after that call - succeeds. + succeeds. Successful finalization resumes the original launch, including any + pending protocol activation. Errors remain visible with Retry; state transitions + are announced to accessibility clients and no percentage is invented. - Preserves normal fresh-install behavior when there is no exact Inno registration and no pending migration record. This admission result is not an authorization to uninstall. The consent workflow reacquires exclusive ownership, revalidates source state, and writes completion -only after its active-gateway credential check succeeds. Runtime state adoption, -source-version enablement, and the complete recovery/finalization journey remain -release gates. +only after its active-gateway credential check succeeds. Official signed-package +acceptance on x64 and ARM64, local and remote gateway continuity, verification +of the selected Inno release's safeguards, actual Store handoff, and production +enablement remain release gates. The source floor and Store listing ID are +assigned above; UI tests with fake operations are not package-boundary or real +migration proof. ## Release checklist diff --git a/installer.iss b/installer.iss index 1312af3b9..f3028e501 100644 --- a/installer.iss +++ b/installer.iss @@ -153,25 +153,56 @@ function OpenMigrationOperationFile( external 'CreateFileW@kernel32.dll stdcall'; function CloseMigrationOperationFile(Handle: THandle): Boolean; external 'CloseHandle@kernel32.dll stdcall'; -function MigrationPathAttributes(FileName: String): LongWord; +function MigrationPathAttributes(FileName: String): Integer; external 'GetFileAttributesW@kernel32.dll stdcall'; +function MigrationPathIsUncRoot(Path: String): Boolean; +var + Rest: String; + Separator: Integer; +begin + Result := False; + if Copy(Path, 1, 2) <> '\\' then + Exit; + Rest := Copy(Path, 3, Length(Path) - 2); + Separator := Pos('\', Rest); + if Separator = 0 then + // '\\server': no share component left to walk into. + Result := True + else + // '\\server\share': a share root, with nothing addressable above it. + Result := Pos('\', Copy(Rest, Separator + 1, Length(Rest) - Separator)) = 0; +end; + function MigrationPathIsOrdinary(Path: String): Boolean; var - Attributes: LongWord; + Attributes: Integer; Parent: String; begin Result := False; while Path <> '' do begin + // GetFileAttributesW reports failure as INVALID_FILE_ATTRIBUTES. That value is + // read as a signed -1 here rather than compared against an unsigned $FFFFFFFF + // literal, whose type Pascal Script resolves inconsistently. The bit pattern is + // identical, and the reparse-point test below is unaffected by the signedness. Attributes := MigrationPathAttributes(Path); - if Attributes = $FFFFFFFF then + if Attributes = -1 then begin + // ERROR_FILE_NOT_FOUND / ERROR_PATH_NOT_FOUND: nothing is there to be a + // reparse point. Any other failure means the component cannot be cleared. if (DLLGetLastError <> 2) and (DLLGetLastError <> 3) then Exit; end else if (Attributes and $400) <> 0 then Exit; + // A local walk ends at 'C:\', where ExtractFileDir returns its own argument. A UNC + // walk has no such fixed point: ExtractFileDir('\\server\share') yields '\\server', + // which is not a filesystem object, so probing it fails with a code that is neither + // 2 nor 3 and would refuse the uninstall outright. Stop at the share root instead; + // every component below it has already been checked, exactly as on a local disk. + if MigrationPathIsUncRoot(Path) then + Break; Parent := ExtractFileDir(Path); if Parent = Path then Break; diff --git a/scripts/Export-MigrationTestMsix.ps1 b/scripts/Export-MigrationTestMsix.ps1 new file mode 100644 index 000000000..895302c30 --- /dev/null +++ b/scripts/Export-MigrationTestMsix.ps1 @@ -0,0 +1,267 @@ +<# +.SYNOPSIS + Test-signs the production-identity Store package for developer migration testing. +.DESCRIPTION + The unsigned Store submission package cannot be installed, and the Dev package + is barred from migration by identity. This produces the only artifact that can + exercise the Store-side migration UI and handoff: the production-identity + package signed with a disposable certificate whose subject matches the + production publisher. + + The package keeps the production identity on purpose, so it shares a package + family name with the Microsoft Store release and is not side-by-side with it. + The disposable private key is removed before the script returns. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('x64', 'arm64')][string]$Architecture, + [Parameter(Mandatory)][string]$PackageDirectory, + [Parameter(Mandatory)][string]$OutputDirectory, + [ValidateNotNullOrEmpty()][string]$VersionInfoPath, + [string]$SignToolPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path $PSScriptRoot -Parent +$PackageDirectory = [IO.Path]::GetFullPath([IO.Path]::Combine($repositoryRoot, $PackageDirectory)) +$OutputDirectory = [IO.Path]::GetFullPath([IO.Path]::Combine($repositoryRoot, $OutputDirectory)) +if ((Test-Path -LiteralPath $OutputDirectory) -and + (-not (Test-Path -LiteralPath $OutputDirectory -PathType Container) -or + @(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0)) { + throw "The migration test artifact output directory must be absent or empty: $OutputDirectory" +} + +function Resolve-SignTool { + if (-not [string]::IsNullOrWhiteSpace($SignToolPath)) { + return (Resolve-Path -LiteralPath $SignToolPath).Path + } + + $command = Get-Command SignTool.exe -CommandType Application -ErrorAction SilentlyContinue + if ($null -ne $command) { + return $command.Source + } + + $windowsKits = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + # Prefer the host architecture so an ARM64 runner does not sign through emulation, + # and order by SDK version rather than lexicographically so 10.0.26100 beats 10.0.9xxxx. + $preferred = switch ($env:PROCESSOR_ARCHITECTURE) { + 'ARM64' { @('arm64', 'x64') } + default { @('x64') } + } + $candidate = Get-ChildItem -LiteralPath $windowsKits -Filter SignTool.exe -File -Recurse ` + -ErrorAction SilentlyContinue | + Where-Object { $preferred -contains $_.Directory.Name } | + Sort-Object ` + @{ Expression = { $preferred.IndexOf($_.Directory.Name) } }, ` + @{ Expression = { + $parsed = [version]'0.0.0.0' + if ([version]::TryParse($_.Directory.Parent.Name, [ref]$parsed)) { $parsed } + else { [version]'0.0.0.0' } + }; Descending = $true } | + Select-Object -First 1 + if ($null -eq $candidate) { + throw 'SignTool.exe was not found in PATH or the Windows 10 SDK.' + } + + $candidate.FullName +} + +$packages = @(Get-ChildItem -LiteralPath $PackageDirectory -Filter '*.msix' -File -Recurse) +if ($packages.Count -ne 1) { + throw "Expected one Store MSIX in '$PackageDirectory'; found $($packages.Count)." +} +$package = $packages[0] + +[xml]$sourceManifest = Get-Content -LiteralPath ( + Join-Path $repositoryRoot 'src\OpenClaw.Tray.WinUI\Package.appxmanifest') -Raw +$expectedIdentity = [string]$sourceManifest.Package.Identity.Name +$expectedPublisher = [string]$sourceManifest.Package.Identity.Publisher +if ([string]::IsNullOrWhiteSpace($expectedIdentity) -or [string]::IsNullOrWhiteSpace($expectedPublisher)) { + throw 'The source manifest does not declare a production identity.' +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$archive = [IO.Compression.ZipFile]::OpenRead($package.FullName) +try { + if ($null -ne $archive.GetEntry('AppxSignature.p7x')) { + throw 'The Store package is already signed; refusing to re-sign a submission asset.' + } + $reader = [IO.StreamReader]::new($archive.GetEntry('AppxManifest.xml').Open()) + try { [xml]$manifest = $reader.ReadToEnd() } + finally { $reader.Dispose() } + + # The artifact exists to exercise migration, so prove the build actually + # compiled it in rather than shipping a silently disabled package. + $assembly = $archive.GetEntry('OpenClaw.Tray.WinUI.dll') + if ($null -eq $assembly) { throw 'The Store package is missing OpenClaw.Tray.WinUI.dll.' } + $buffer = [IO.MemoryStream]::new() + $assemblyStream = $assembly.Open() + try { $assemblyStream.CopyTo($buffer) } + finally { $assemblyStream.Dispose() } + $assemblyText = [Text.Encoding]::UTF8.GetString($buffer.ToArray()) + $buffer.Dispose() + if (-not $assemblyText.Contains('MigrationMinimumSourceVersion')) { + throw 'The Store package was not built with production migration enabled.' + } +} +finally { $archive.Dispose() } + +$identity = $manifest.Package.Identity +if ([string]$identity.Name -ne $expectedIdentity -or [string]$identity.Publisher -ne $expectedPublisher) { + throw 'The package does not carry the production Store identity required for migration.' +} +if ([string]$identity.ProcessorArchitecture -ne $Architecture) { + throw "Expected a $Architecture package; found $($identity.ProcessorArchitecture)." +} + +$versionInfo = $null +if ($VersionInfoPath) { + . (Join-Path $PSScriptRoot 'MsixVersioning.ps1') + $allocationCommit = (& git -C $repositoryRoot rev-parse HEAD) -join '' + if ($LASTEXITCODE -ne 0) { throw 'Unable to resolve source for the MSIX allocation.' } + $versionInfo = Read-MsixVersionInfo ` + -Path ([IO.Path]::GetFullPath([IO.Path]::Combine($repositoryRoot, $VersionInfoPath))) ` + -SourceCommit $allocationCommit +} +$sourceCommit = (& git -C $repositoryRoot rev-parse HEAD) -join '' +if ($LASTEXITCODE -ne 0 -or $sourceCommit -notmatch '^[0-9a-fA-F]{40}$') { + throw 'Unable to resolve the current source commit.' +} +$sourceTreeDirty = [bool](& git -C $repositoryRoot status --porcelain) +if ($LASTEXITCODE -ne 0) { throw 'Unable to inspect the current source tree.' } + +$packageName = "OpenClaw-MigrationTest-$Architecture.msix" +$certificateName = 'OpenClaw-MigrationTest.cer' +$friendlyName = "OpenClaw Migration Test Signing $([guid]::NewGuid().ToString('N'))" +$certificate = New-SelfSignedCertificate ` + -Type CodeSigningCert ` + -Subject $expectedPublisher ` + -KeyUsage DigitalSignature ` + -KeyAlgorithm RSA ` + -KeyLength 2048 ` + -KeyExportPolicy NonExportable ` + -HashAlgorithm SHA256 ` + -NotAfter (Get-Date).AddDays(30) ` + -FriendlyName $friendlyName ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}') +try { + New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + $signedPackage = Join-Path $OutputDirectory $packageName + Copy-Item -LiteralPath $package.FullName -Destination $signedPackage + + & (Resolve-SignTool) sign /fd SHA256 /sha1 $certificate.Thumbprint $signedPackage + if ($LASTEXITCODE -ne 0) { + throw "SignTool.exe failed to sign the migration test package. Exit code: $LASTEXITCODE." + } + + $signature = Get-AuthenticodeSignature -LiteralPath $signedPackage + if ($null -eq $signature.SignerCertificate -or + $signature.SignerCertificate.Thumbprint -ne $certificate.Thumbprint) { + throw 'The migration test package was not signed by the disposable certificate.' + } + + $certificatePath = Join-Path $OutputDirectory $certificateName + Export-Certificate -Cert $certificate -FilePath $certificatePath -Type CERT | Out-Null + $packageHash = (Get-FileHash -LiteralPath $signedPackage -Algorithm SHA256).Hash.ToLowerInvariant() + $certificateHash = (Get-FileHash -LiteralPath $certificatePath -Algorithm SHA256).Hash.ToLowerInvariant() + + [ordered]@{ + repository = 'https://github.com/openclaw/openclaw-windows-node' + sourceCommit = $sourceCommit.ToLowerInvariant() + sourceTreeDirty = $sourceTreeDirty + architecture = $Architecture + archive = $packageName + sha256 = $packageHash + signed = $true + signing = 'migration-test-only' + identityName = [string]$identity.Name + packageVersion = [string]$identity.Version + publisher = [string]$identity.Publisher + sideBySideWithStore = $false + migrationEnabled = $true + certificate = $certificateName + certificateThumbprint = $certificate.Thumbprint + certificateSha256 = $certificateHash + certificateExpiresUtc = $certificate.NotAfter.ToUniversalTime().ToString('O') + workflowRunId = $env:GITHUB_RUN_ID + workflowRunAttempt = $env:GITHUB_RUN_ATTEMPT + msixVersionAllocation = $versionInfo + } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'msix-metadata.json') -Encoding utf8 + + @" +OpenClaw migration test package ($Architecture) + +FOR DEVELOPERS ONLY. "Dev" in this artifact name means "for developers". This is +NOT the Dev package identity, and it is NOT a Microsoft Store-signed release. +Install only from a workflow and source revision you trust. Pull request builds +can contain unreviewed code. No private key is included. + +Read this before installing: + +* This package carries the production Store identity, so it shares a package + family name with the Microsoft Store release. It is NOT side-by-side. Uninstall + the Store build of OpenClaw first, and expect to uninstall this package before + taking a Store update later. +* Migration deliberately runs against your real data directories. Data path + redirection is rejected by the Store migration guard, so this cannot be pointed + at a scratch profile. +* With an Inno "OpenClaw Companion" installation present, this package will offer + to adopt it and then uninstall it. Use a disposable machine or virtual machine. +* With no Inno installation present, it starts normally and shows nothing. + +Source: $sourceCommit +Package version: $($identity.Version) +Package SHA-256: $packageHash +Certificate thumbprint: $($certificate.Thumbprint) +Certificate SHA-256: $certificateHash + +1. Extract this download. Compare its package and certificate hashes with + msix-metadata.json using Get-FileHash -Algorithm SHA256. +2. Install Microsoft.VCLibs.140.00.UWPDesktop 14.0.33728.0 or newer for + $Architecture if absent: + https://learn.microsoft.com/troubleshoot/developer/visualstudio/cpp/libraries/c-runtime-packages-desktop-bridge +3. From this directory, in elevated PowerShell, explicitly trust the public + certificate (this changes machine trust): + Import-Certificate -FilePath .\$certificateName -CertStoreLocation Cert:\LocalMachine\TrustedPeople +4. As the intended Windows user, install: + Add-AppxPackage -Path .\$packageName +5. Launch OpenClaw from Start to exercise the Store-side migration experience. + +When finished, remove the package and then remove this certificate from elevated +PowerShell only if no installed package still relies on it: +Remove-Item -LiteralPath 'Cert:\LocalMachine\TrustedPeople\$($certificate.Thumbprint)' + +The unsigned Store submission artifact remains the Partner Center asset. This +test-signed package stays workflow-only and must never be published. +"@ | Set-Content -LiteralPath (Join-Path $OutputDirectory 'INSTALL.txt') -Encoding utf8 +} +finally { + # Never throw from finally: it would mask an in-flight failure. Report here, verify below. + try { + Remove-Item "Cert:\CurrentUser\My\$($certificate.Thumbprint)" -Force -ErrorAction Stop + } catch { + Write-Warning "Could not remove the disposable signing key: $($_.Exception.Message)" + } + + # Also verified here, because a failure in the body terminates the script before the + # check below. That is exactly when a key is most likely to have been left behind. + # Both the probe and the report are guarded: under -ErrorActionPreference Stop, or a + # caller's WarningPreference of Stop, either could throw and replace an in-flight failure. + try { + if (Test-Path -LiteralPath "Cert:\CurrentUser\My\$($certificate.Thumbprint)") { + Write-Warning ("The disposable signing key $($certificate.Thumbprint) is still in " + + 'Cert:\CurrentUser\My. Remove it before trusting this host again.') + } + } catch { + Write-Host "Could not verify disposable signing key removal: $($_.Exception.Message)" + } +} + +if (Test-Path -LiteralPath "Cert:\CurrentUser\My\$($certificate.Thumbprint)") { + throw ("The disposable signing key $($certificate.Thumbprint) is still in Cert:\CurrentUser\My. " + + 'Remove it before trusting this host again.') +} + +Write-Host "Staged test-signed migration package: $OutputDirectory" diff --git a/scripts/Test-InstallerScriptCompiles.ps1 b/scripts/Test-InstallerScriptCompiles.ps1 new file mode 100644 index 000000000..6038ebb1c --- /dev/null +++ b/scripts/Test-InstallerScriptCompiles.ps1 @@ -0,0 +1,112 @@ +<# +.SYNOPSIS + Compiles installer.iss against a stub payload to catch Inno Setup script errors. + +.DESCRIPTION + The real installer is only built in the tag-only release job, so a syntax or + Pascal Script error in installer.iss would otherwise surface at release time. + This gate compiles the script itself with a throwaway payload so pull requests + fail fast instead. + + Both the production and DevBuild variants are compiled because the migration + lock code is inside an #ifndef DevBuild block, so a single variant cannot + cover it. + + This proves the script compiles. It does not prove installer runtime behavior. +#> +[CmdletBinding()] +param( + [string]$InnoCompiler, + # CI must not silently pass when the compiler is absent. + [switch]$RequireCompiler +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repoRoot = Split-Path -Parent $PSScriptRoot + +function Resolve-InnoCompiler { + $candidates = @( + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "$env:ProgramFiles\Inno Setup 6\ISCC.exe", + "$env:LOCALAPPDATA\Programs\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 + } + + return $null +} + +if (-not $InnoCompiler) { + $InnoCompiler = Resolve-InnoCompiler +} + +if (-not $InnoCompiler) { + if ($RequireCompiler) { + throw 'Inno Setup compiler (ISCC.exe) was not found, but -RequireCompiler was specified.' + } + Write-Warning 'Inno Setup compiler (ISCC.exe) was not found. Skipping installer script compilation.' + exit 2 +} + +$workRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("openclaw-iss-compile-" + [System.Guid]::NewGuid().ToString('n')) +$payloadDir = Join-Path $workRoot 'publish' +$outputDir = Join-Path $workRoot 'out' + +try { + New-Item -ItemType Directory -Path $payloadDir -Force | Out-Null + New-Item -ItemType Directory -Path $outputDir -Force | Out-Null + + # ISCC resolves every [Files] Source at compile time, so the stub only needs to + # exist. Contents are irrelevant to a compilation check. + Set-Content -LiteralPath (Join-Path $payloadDir 'OpenClaw.Tray.WinUI.exe') -Value 'stub' -Encoding ascii + $vcRedist = Join-Path $workRoot 'vc_redist.stub.exe' + Set-Content -LiteralPath $vcRedist -Value 'stub' -Encoding ascii + + $variants = @( + @{ Name = 'x64'; Arch = 'x64'; Dev = $false }, + @{ Name = 'arm64'; Arch = 'arm64'; Dev = $false }, + @{ Name = 'x64 (DevBuild)'; Arch = 'x64'; Dev = $true } + ) + + foreach ($variant in $variants) { + $isccArgs = @( + '/Q', + "/DMyAppVersion=0.0.0", + "/DMyAppArch=$($variant.Arch)", + "/Dpublish=$payloadDir", + "/DvcRedist=$vcRedist", + "/O$outputDir" + ) + if ($variant.Dev) { + $isccArgs += '/DDevBuild=1' + } + $isccArgs += (Join-Path $repoRoot 'installer.iss') + + Write-Host "Compiling installer.iss: $($variant.Name)" + & $InnoCompiler @isccArgs + if ($LASTEXITCODE -ne 0) { + throw "ISCC failed to compile installer.iss for $($variant.Name). Exit code: $LASTEXITCODE." + } + } + + Write-Host 'installer.iss compiles for all variants.' +} +finally { + if (Test-Path -LiteralPath $workRoot) { + Remove-Item -LiteralPath $workRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +# GitHub Actions appends `exit $LASTEXITCODE` to pwsh steps. +exit 0 diff --git a/scripts/Uninstall-LocalGateway.ps1 b/scripts/Uninstall-LocalGateway.ps1 index 97168f471..e87a0239e 100644 --- a/scripts/Uninstall-LocalGateway.ps1 +++ b/scripts/Uninstall-LocalGateway.ps1 @@ -420,9 +420,65 @@ function Resolve-AppDataDir { continue } - $identityDir = Join-Path (Join-Path $DataDir 'gateways') $id + $gatewaysDir = Join-Path $DataDir 'gateways' + $identityDir = Join-Path $gatewaysDir $id + # This directory is deleted recursively, so the record id must stay a single + # path segment. A traversal id would otherwise escape the gateways directory. + # \A and \z rather than ^ and $: .NET lets $ match before a trailing newline. + if ($id -notmatch '\A[A-Za-z0-9._-]+\z' -or $id -eq '.' -or $id -eq '..') { + Add-CleanupWarning "Skipped identity cleanup for local gateway record with an unsafe id '$id'." + continue + } + try { if (Test-Path -LiteralPath $identityDir -PathType Container) { + # Remove-Item -Recurse follows junctions on Windows PowerShell 5.1, which is + # what runs this during Inno uninstall. Deleting through a reparse point would + # destroy whatever it targets, so refuse it the way the migration codec does. + # Every ancestor up to $DataDir is checked, not just the leaf: a junction at + # 'gateways' redirects the whole subtree while the leaf itself looks ordinary. + # Descendants are checked too, because -Recurse follows a junction planted + # inside the identity directory just as readily as one above it. + $redirected = $null + $probe = $identityDir + $stopAt = $DataDir.TrimEnd('\', '/') + while ($probe) { + $probeItem = Get-Item -LiteralPath $probe -Force -ErrorAction Stop + if ($probeItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + $redirected = $probe + break + } + if ($probe.TrimEnd('\', '/') -eq $stopAt) { break } + $parent = Split-Path -Path $probe -Parent + if (-not $parent -or $parent -eq $probe) { break } + $probe = $parent + } + if (-not $redirected) { + # Walked explicitly rather than with Get-ChildItem -Recurse, which follows + # junctions on 5.1 and would enumerate the target it is meant to detect. + $pending = New-Object System.Collections.Stack + $pending.Push($identityDir) + while ($pending.Count -gt 0 -and -not $redirected) { + $current = $pending.Pop() + foreach ($child in [System.IO.Directory]::EnumerateFileSystemEntries($current)) { + $childItem = Get-Item -LiteralPath $child -Force -ErrorAction Stop + if ($childItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { + $redirected = $child + break + } + if ($childItem.PSIsContainer) { + $pending.Push($child) + } + } + } + } + + if ($redirected) { + Add-CleanupWarning ("Skipped identity cleanup for local gateway record '$id': " + + "'$redirected' is a reparse point.") + continue + } + Remove-Item -LiteralPath $identityDir -Recurse -Force -ErrorAction Stop Write-GatewayLog "Deleted identity directory for local gateway record $id." } diff --git a/scripts/test-ci-workflow-contract.ps1 b/scripts/test-ci-workflow-contract.ps1 index 8e0e042e8..6009e9b3c 100644 --- a/scripts/test-ci-workflow-contract.ps1 +++ b/scripts/test-ci-workflow-contract.ps1 @@ -347,6 +347,8 @@ foreach ($token in @( "./scripts/test-ci-change-classifier.ps1", "./scripts/test-ci-gate-results.ps1", "./scripts/test-ci-workflow-contract.ps1", + "./scripts/Test-InstallerScriptCompiles.ps1 -RequireCompiler", + "choco install innosetup -y --no-progress", "./scripts/test-stable-correction-release-validator.ps1" )) { Assert-Contains ` @@ -677,6 +679,12 @@ foreach ($token in @( '-MsixRevision $env:DEV_MSIX_REVISION', '-MsixOutputDirectory "$env:RUNNER_TEMP\openclaw-dev-appx"', '.\scripts\Export-DevMsixArtifact.ps1', + '.\scripts\Export-MigrationTestMsix.ps1 -Architecture', + 'id: migration-switch', + "if: steps.migration-switch.outputs.enabled == 'true'", + 'name: openclaw-msix-dev-migration-test-${{ matrix.architecture }}', + 'artifacts/msix-migration-test/${{ matrix.architecture }}/OpenClaw-MigrationTest-${{ matrix.architecture }}.msix', + 'OpenClaw-MigrationTest.cer', 'MSIX_VERSION_INFO: ${{ needs.reserve-msix-version.outputs.versionInfo || needs.metadata.outputs.msixVersionInfo }}', 'MSIX_SOURCE_VERSION: ${{ needs.reserve-msix-version.outputs.sourceVersion || needs.metadata.outputs.msixSourceVersion }}', '-ExpectedVersion $info.packageBaseVersion', diff --git a/scripts/test-migration-test-msix.ps1 b/scripts/test-migration-test-msix.ps1 new file mode 100644 index 000000000..cf7d3165a --- /dev/null +++ b/scripts/test-migration-test-msix.ps1 @@ -0,0 +1,209 @@ +<# +.SYNOPSIS + Exercises developer migration test package validation contracts. +.DESCRIPTION + Uses synthetic ZIPs, a stubbed Authenticode probe, and a fake SignTool. The + happy path creates a short-lived current-user certificate that the exporter + removes itself. No certificate is trusted and no product build is launched. +#> +[CmdletBinding()] +param([string]$RepoRoot = (Split-Path $PSScriptRoot -Parent)) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$exporter = Join-Path $RepoRoot 'scripts\Export-MigrationTestMsix.ps1' +$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) "openclaw-migration-msix-tests-$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $temporaryRoot | Out-Null +$scenarioNumber = 0 + +[xml]$sourceManifest = Get-Content -LiteralPath ( + Join-Path $RepoRoot 'src\OpenClaw.Tray.WinUI\Package.appxmanifest') -Raw +$productionIdentity = [string]$sourceManifest.Package.Identity.Name +$productionPublisher = [string]$sourceManifest.Package.Identity.Publisher + +$fakeSignTool = Join-Path $temporaryRoot 'SignTool.ps1' +$signToolLog = Join-Path $temporaryRoot 'signtool-args.txt' +@" +param([Parameter(ValueFromRemainingArguments = `$true)][string[]]`$Arguments) +`$Arguments -join ' ' | Set-Content -LiteralPath '$signToolLog' +if (`$Arguments[0] -ne 'sign') { exit 2 } +if (`$Arguments -notcontains '/fd') { exit 4 } +if (`$Arguments[[array]::IndexOf(`$Arguments, '/fd') + 1] -ne 'SHA256') { exit 5 } +if (`$Arguments -notcontains '/sha1') { exit 6 } +`$thumbprint = `$Arguments[[array]::IndexOf(`$Arguments, '/sha1') + 1] +if (`$thumbprint -notmatch '^[0-9A-Fa-f]{40}`$') { exit 7 } +if (-not (Test-Path -LiteralPath `$Arguments[-1])) { exit 3 } +exit 0 +"@ | Set-Content -LiteralPath $fakeSignTool + +function Get-AuthenticodeSignature { + param([string]$LiteralPath) + if (-not (Test-Path -LiteralPath $LiteralPath)) { throw 'Signature probe received a missing package.' } + $signer = Get-ChildItem Cert:\CurrentUser\My | + Where-Object { $_.FriendlyName -like 'OpenClaw Migration Test Signing*' } | + Sort-Object NotBefore -Descending | + Select-Object -First 1 + [pscustomobject]@{ Status = 'Valid'; SignerCertificate = $signer } +} + +function Assert-Fails { + param([scriptblock]$Action, [string]$Expected) + try { + & $Action + throw 'The operation unexpectedly succeeded.' + } + catch { + if (-not $_.Exception.Message.Contains($Expected, [StringComparison]::OrdinalIgnoreCase)) { + throw "Expected failure containing '$Expected', received: $($_.Exception.Message)" + } + } +} + +function New-Package { + param( + [string]$Directory, + [string]$Name = 'OpenClaw-x64.msix', + [string]$Identity = $productionIdentity, + [string]$Publisher = $productionPublisher, + [string]$Architecture = 'x64', + [string]$Version = '2026.9.5.0', + [switch]$Signed, + [switch]$WithoutMigration, + [string]$Omit = '' + ) + New-Item -ItemType Directory -Path $Directory -Force | Out-Null + $zip = [IO.Compression.ZipFile]::Open((Join-Path $Directory $Name), [IO.Compression.ZipArchiveMode]::Create) + try { + $entries = @('AppxManifest.xml', 'OpenClaw.Tray.WinUI.exe', 'OpenClaw.Tray.WinUI.dll') + if ($Signed) { $entries += 'AppxSignature.p7x' } + foreach ($name in $entries) { + if ($name -eq $Omit) { continue } + $writer = [IO.StreamWriter]::new($zip.CreateEntry($name).Open()) + try { + $content = switch ($name) { + 'AppxManifest.xml' { + "" + } + 'OpenClaw.Tray.WinUI.dll' { + if ($WithoutMigration) { 'Synthetic assembly without migration metadata.' } + else { 'Synthetic assembly. MigrationMinimumSourceVersion 2026.9.5.0' } + } + default { 'Synthetic contract-test content, not executable.' } + } + $writer.Write($content) + } + finally { $writer.Dispose() } + } + } + finally { $zip.Dispose() } +} + +function New-Arguments { + $script:scenarioNumber++ + @{ + Architecture = 'x64' + PackageDirectory = Join-Path $temporaryRoot "input-$scenarioNumber" + OutputDirectory = Join-Path $temporaryRoot "output-$scenarioNumber" + SignToolPath = $fakeSignTool + } +} + +try { + Add-Type -AssemblyName System.IO.Compression.FileSystem + + # The submission asset must never be re-signed and republished. + $arguments = New-Arguments + New-Package -Directory $arguments.PackageDirectory -Signed + Assert-Fails { & $exporter @arguments } 'already signed' + + # A package built with migration disabled would install but prove nothing. + $arguments = New-Arguments + New-Package -Directory $arguments.PackageDirectory -WithoutMigration + Assert-Fails { & $exporter @arguments } 'not built with production migration enabled' + + foreach ($mismatch in @( + @{ Identity = 'OpenClawFoundation.OpenClaw.Dev'; Error = 'production Store identity' }, + @{ Publisher = 'CN=Wrong'; Error = 'production Store identity' }, + @{ Architecture = 'arm64'; Error = 'Expected a x64 package' } + )) { + $arguments = New-Arguments + $package = @{ Directory = $arguments.PackageDirectory } + foreach ($key in @('Identity', 'Publisher', 'Architecture')) { + if ($mismatch.ContainsKey($key)) { $package[$key] = $mismatch[$key] } + } + New-Package @package + Assert-Fails { & $exporter @arguments } $mismatch.Error + if (Test-Path $arguments.OutputDirectory) { throw 'Failed verification published output.' } + } + + $arguments = New-Arguments + New-Package -Directory $arguments.PackageDirectory + New-Package -Directory $arguments.PackageDirectory -Name 'extra.msix' + Assert-Fails { & $exporter @arguments } 'found 2' + + $arguments = New-Arguments + New-Package -Directory $arguments.PackageDirectory + New-Item -ItemType Directory -Path $arguments.OutputDirectory -Force | Out-Null + Set-Content (Join-Path $arguments.OutputDirectory 'stale.txt') 'stale' + Assert-Fails { & $exporter @arguments } 'must be absent or empty' + + $arguments = New-Arguments + New-Package -Directory $arguments.PackageDirectory + & $exporter @arguments + $packageName = 'OpenClaw-MigrationTest-x64.msix' + $expectedFiles = @('INSTALL.txt', 'msix-metadata.json', 'OpenClaw-MigrationTest.cer', $packageName) + $files = @(Get-ChildItem -LiteralPath $arguments.OutputDirectory -File | Select-Object -ExpandProperty Name) + if (@(Compare-Object $expectedFiles $files).Count -gt 0) { + throw "Unexpected migration test artifact contents: $($files -join ',')" + } + $metadata = Get-Content (Join-Path $arguments.OutputDirectory 'msix-metadata.json') -Raw | ConvertFrom-Json + $actualHash = (Get-FileHash (Join-Path $arguments.OutputDirectory $packageName) -Algorithm SHA256).Hash + if (-not $metadata.signed -or $metadata.signing -ne 'migration-test-only' -or + $metadata.identityName -ne $productionIdentity -or $metadata.publisher -ne $productionPublisher -or + $metadata.sideBySideWithStore -or -not $metadata.migrationEnabled -or + $metadata.sha256 -ne $actualHash -or $metadata.sourceCommit -notmatch '^[0-9a-f]{40}$') { + throw 'Migration test package provenance did not match its inputs.' + } + $publicCertificate = [Security.Cryptography.X509Certificates.X509Certificate2]::new( + (Join-Path $arguments.OutputDirectory 'OpenClaw-MigrationTest.cer')) + try { + $signToolArguments = Get-Content -LiteralPath $signToolLog -Raw + $expectedSignToolArguments = + "sign /fd SHA256 /sha1 $($publicCertificate.Thumbprint) " + + (Join-Path $arguments.OutputDirectory $packageName) + if ($signToolArguments.Trim() -ne $expectedSignToolArguments) { + throw "SignTool received unexpected arguments: $signToolArguments" + } + if ($publicCertificate.HasPrivateKey -or + $publicCertificate.Thumbprint -ne $metadata.certificateThumbprint) { + throw 'The exported certificate is not the expected public-only signer.' + } + if (Get-Item "Cert:\CurrentUser\My\$($publicCertificate.Thumbprint)" -ErrorAction SilentlyContinue) { + throw 'The disposable signing key was left in the current user store.' + } + } + finally { $publicCertificate.Dispose() } + + $instructions = Get-Content (Join-Path $arguments.OutputDirectory 'INSTALL.txt') -Raw + foreach ($warning in @( + "Add-AppxPackage -Path .\$packageName", + 'NOT the Dev package identity', + 'It is NOT side-by-side', + 'real data directories', + 'uninstall it')) { + if (-not $instructions.Contains($warning)) { + throw "Migration test instructions omitted '$warning'." + } + } + + Write-Host 'Migration test MSIX artifact contracts passed.' +} +finally { + Get-ChildItem Cert:\CurrentUser\My -ErrorAction SilentlyContinue | + Where-Object { $_.FriendlyName -like 'OpenClaw Migration Test Signing*' } | + ForEach-Object { Remove-Item "Cert:\CurrentUser\My\$($_.Thumbprint)" -Force -ErrorAction SilentlyContinue } + if ([IO.Directory]::Exists($temporaryRoot)) { + [IO.Directory]::Delete($temporaryRoot, $true) + } +} +$global:LASTEXITCODE = 0 diff --git a/src/OpenClaw.Connection/Migration/InnoInstallation.cs b/src/OpenClaw.Connection/Migration/InnoInstallation.cs index 48e8e90e3..645db43a0 100644 --- a/src/OpenClaw.Connection/Migration/InnoInstallation.cs +++ b/src/OpenClaw.Connection/Migration/InnoInstallation.cs @@ -4,6 +4,20 @@ public enum InnoInstallationStatus { NotInstalled, Detected, + + /// + /// A canonical current-user registration whose payload is positively gone: the registration + /// passed every identity, location, and uninstall-command check, and both the tray executable + /// and the uninstaller are definitely absent rather than merely unreadable. + /// + /// This is deliberately not . An uninstall that removes the payload + /// but leaves the registration behind would otherwise be refused forever: the receipt holds a + /// durable startup block, the source can never come back, and the user is left with neither + /// the previous app nor the Store app. Absence proved here still authorizes nothing on its + /// own; finalization re-verifies removal independently before acting. + /// + /// + OrphanedRegistration, Unsupported, InspectionFailed } @@ -18,10 +32,30 @@ public sealed record InnoInstallation( string Architecture, Version Version); +/// What the inspection concluded. +/// Verified installation evidence, present only for . +/// Why an unsupported or failed inspection reached that conclusion. +/// The registered release version, when one could be parsed. +/// +/// Whether the registration was refused because its version is not a stable numeric release, +/// which is the case for a prerelease build. Such a version never parses, so it reaches startup +/// policy with no to compare against the minimum. Without this +/// flag the refusal is indistinguishable from a bad location, user, or architecture, and the +/// user is told to check things that are already correct. +/// +/// +/// Whether the source tray executable is actually on disk. A registration alone does not prove an +/// installed app: a botched or interrupted uninstall can leave the registry entry behind with the +/// payload gone. Startup policy uses this to tell a real installed source, which must keep the +/// Store app inactive, from an orphan registration, where blocking would leave no working app. +/// public sealed record InnoInstallationDetection( InnoInstallationStatus Status, InnoInstallation? Installation = null, - string? Reason = null); + string? Reason = null, + Version? RegisteredVersion = null, + bool SourcePayloadPresent = false, + bool RegisteredVersionUnsupported = false); public interface IInnoInstallationDetector { diff --git a/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs b/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs index 9fa4749f3..ef77e4176 100644 --- a/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs +++ b/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs @@ -17,6 +17,8 @@ public sealed class InnoInstallationDetector : IInnoInstallationDetector internal const string UninstallKey = @"Software\Microsoft\Windows\CurrentVersion\Uninstall\{M0LTB0T-TRAY-4PP1-D3N7}_is1"; + private const string TrayExecutableName = "OpenClaw.Tray.WinUI.exe"; + private readonly string _installDirectory; private readonly IOpenClawLogger _logger; private readonly IInnoInstallationReadSource _source; @@ -39,6 +41,11 @@ internal InnoInstallationDetector( public InnoInstallationDetection Detect() { + // Held outside the try so a failure raised while validating the rest of the installation + // cannot report an app that is really on disk as absent. Every throw site below runs after + // the payload probe, and reporting no payload there would drop the startup block and let + // the Store app run beside a working previous client. + var sourcePayloadPresent = false; try { // HKCU can alias the same registration in both views. Inspect HKLM even @@ -48,40 +55,80 @@ public InnoInstallationDetection Detect() var machine64 = _source.ReadRegistration(RegistryHive.LocalMachine, RegistryView.Registry64); var machine32 = _source.ReadRegistration(RegistryHive.LocalMachine, RegistryView.Registry32); if (machine64 is not null || machine32 is not null) - return Unsupported("A machine-wide production Inno registration is present."); + { + sourcePayloadPresent = + HasTrayExecutable(machine64, allowCanonicalFallback: false) || + HasTrayExecutable(machine32, allowCanonicalFallback: false); + return Unsupported("A machine-wide production Inno registration is present.", + sourcePayloadPresent: sourcePayloadPresent); + } if (user64 is null && user32 is null) return new(InnoInstallationStatus.NotInstalled); if (user64 is not null && user32 is not null && user64 != user32) - return Unsupported("Production Inno registrations disagree between registry views."); + { + sourcePayloadPresent = HasTrayExecutable(user64) || HasTrayExecutable(user32); + return Unsupported("Production Inno registrations disagree between registry views.", + sourcePayloadPresent: sourcePayloadPresent); + } var registration = user64 ?? user32!; + // Computed once from the registration this pass trusts, so every unsupported result + // below reports whether an actual app is installed rather than only a leftover key. + sourcePayloadPresent = HasTrayExecutable(registration); if (!MigrationVersionPolicy.TryParseReleaseVersion(registration.DisplayVersion, out var version)) - return Unsupported("The Inno DisplayVersion is not a stable numeric release version."); + return Unsupported("The Inno DisplayVersion is not a stable numeric release version.", + sourcePayloadPresent: sourcePayloadPresent, registeredVersionUnsupported: true); // Inno's default UninstallDisplayName includes the unnormalized AppVersion. if (registration.DisplayName != $"OpenClaw Companion version {registration.DisplayVersion}" || registration.Publisher != "OpenClaw Foundation") - return Unsupported("The Inno registration does not identify the production publisher and application."); + return Unsupported("The Inno registration does not identify the production publisher and application.", + sourcePayloadPresent: sourcePayloadPresent); if (!IsDefaultInstallPath(registration.InstallLocation)) - return Unsupported("The Inno installation is not at the canonical default per-user location."); + return Unsupported("The Inno installation is not at the canonical default per-user location.", + sourcePayloadPresent: sourcePayloadPresent); - var executable = Path.Combine(_installDirectory, "OpenClaw.Tray.WinUI.exe"); + var executable = Path.Combine(_installDirectory, TrayExecutableName); var uninstaller = Path.Combine(_installDirectory, "unins000.exe"); if (!string.Equals(registration.UninstallString, $"\"{uninstaller}\"", StringComparison.OrdinalIgnoreCase)) - return Unsupported("The Inno uninstall command must contain only the quoted canonical unins000.exe path."); + return Unsupported("The Inno uninstall command must contain only the quoted canonical unins000.exe path.", + sourcePayloadPresent: sourcePayloadPresent); + + // Decided ahead of the payload sweep below. That sweep reports a missing tray + // executable as an installation too old to migrate and carries a version for admission + // to offer as an update, which for a registration with no payload at all would send the + // user to update an app that is no longer installed. Both the executable and the + // uninstaller must be positively absent: an unreadable path, an unexpected object + // shape, or a denied probe is unknown, and unknown must never qualify as removed. + if (_source.IsDefinitelyAbsent(executable) && _source.IsDefinitelyAbsent(uninstaller)) + { + const string orphanReason = + "The canonical Inno registration has neither an executable nor an uninstaller payload."; + _logger.Warn(orphanReason); + return new(InnoInstallationStatus.OrphanedRegistration, Reason: orphanReason, + RegisteredVersion: version); + } foreach (var name in new[] { - "OpenClaw.Tray.WinUI.exe", "unins000.exe", "app-identity.txt", + TrayExecutableName, "unins000.exe", "app-identity.txt", "Test-InnoMigration.ps1", "MigrationRecordCodec.cs", "Uninstall-LocalGateway.ps1" }) { if (!_source.IsOrdinaryFile(Path.Combine(_installDirectory, name))) - return Unsupported($"The required Inno payload {name} is missing or is not an ordinary file."); + { + // An otherwise canonical installation that predates the migration payload is + // simply too old. Carry the registered version so admission can ask the user + // to update instead of declaring the installation unsupportable. + return Unsupported( + $"The required Inno payload {name} is missing or is not an ordinary file.", version, + sourcePayloadPresent); + } } if (_source.ReadIdentity(Path.Combine(_installDirectory, "app-identity.txt")) is not ("release" or "release\n" or "release\r\n")) - return Unsupported("The installed application identity is not release."); + return Unsupported("The installed application identity is not release.", + sourcePayloadPresent: sourcePayloadPresent); var binary = _source.ReadExecutable(executable); var architecture = binary.Machine switch { @@ -90,23 +137,28 @@ public InnoInstallationDetection Detect() _ => null }; if (architecture is null) - return Unsupported("The installed executable has an unsupported PE architecture."); + return Unsupported("The installed executable has an unsupported PE architecture.", + sourcePayloadPresent: sourcePayloadPresent); if (!MigrationVersionPolicy.TryParseReleaseVersion(binary.Version, out var binaryVersion) || binaryVersion != version) - return Unsupported("The executable file version does not match the registered release version."); + return Unsupported("The executable file version does not match the registered release version.", + sourcePayloadPresent: sourcePayloadPresent); return new(InnoInstallationStatus.Detected, - new(_installDirectory, executable, uninstaller, architecture, version)); + new(_installDirectory, executable, uninstaller, architecture, version), + SourcePayloadPresent: true); } catch (InvalidDataException ex) { - return Unsupported(ex.Message); + return Unsupported(ex.Message, sourcePayloadPresent: sourcePayloadPresent); } catch (BadImageFormatException) { - return Unsupported("The installed executable is not a valid native PE executable."); + return Unsupported("The installed executable is not a valid native PE executable.", + sourcePayloadPresent: sourcePayloadPresent); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or + InvalidDataException or ArgumentException or NotSupportedException) { const string reason = "The Inno installation could not be inspected."; _logger.Error(reason, ex); @@ -123,10 +175,53 @@ private bool IsDefaultInstallPath(string? path) string.Equals(path, _installDirectory + "\\", StringComparison.OrdinalIgnoreCase)); } - private InnoInstallationDetection Unsupported(string reason) + /// + /// Positive evidence that an app is actually installed, rather than a registration left behind + /// by an interrupted uninstall. The registration's own location is preferred so a non-canonical + /// install still counts as present. + /// + /// + /// Whether a registration that names no usable path may be probed against the canonical + /// per-user directory. False for machine-wide registrations, whose payload never lives there: + /// probing it would answer for a different installation entirely. + /// + private bool HasTrayExecutable( + InnoInstallationRegistration? registration, bool allowCanonicalFallback = true) + { + var directory = registration?.InstallLocation is { } location && + !string.IsNullOrWhiteSpace(location) && + Path.IsPathFullyQualified(location) + ? location + : allowCanonicalFallback ? _installDirectory : null; + if (directory is null) + return false; + + try + { + return _source.IsOrdinaryFile(Path.Combine(directory, TrayExecutableName)); + } + // A registration is ordinary user-writable data: IsPathFullyQualified rejects neither + // invalid characters nor reserved names, the location may be unreadable or too long, and + // an ancestor may be a reparse point. Failing to probe one registration must mean "no + // evidence from this one", not the end of the whole inspection. Without this the sibling + // probe in the OR above is skipped and a live installation goes unseen. + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or + IOException or UnauthorizedAccessException or + SecurityException or InvalidDataException) + { + _logger.Warn("The Inno registration names an unusable install location."); + return false; + } + } + + private InnoInstallationDetection Unsupported( + string reason, Version? registeredVersion = null, bool sourcePayloadPresent = false, + bool registeredVersionUnsupported = false) { _logger.Warn(reason); - return new(InnoInstallationStatus.Unsupported, Reason: reason); + return new(InnoInstallationStatus.Unsupported, Reason: reason, + RegisteredVersion: registeredVersion, SourcePayloadPresent: sourcePayloadPresent, + RegisteredVersionUnsupported: registeredVersionUnsupported); } } @@ -140,6 +235,14 @@ internal interface IInnoInstallationReadSource { InnoInstallationRegistration? ReadRegistration(RegistryHive hive, RegistryView view); bool IsOrdinaryFile(string path); + + /// + /// Whether the path is proved to hold nothing. Distinct from , + /// which answers false for a directory or device of the same name and so cannot be + /// read as absence. Only a not-found result proves absence here; every other failure throws + /// so the inspection fails closed rather than reporting a payload as removed. + /// + bool IsDefinitelyAbsent(string path); string ReadIdentity(string path); InnoInstallationExecutable ReadExecutable(string path); } @@ -174,6 +277,20 @@ public bool IsOrdinaryFile(string path) catch (DirectoryNotFoundException) { return false; } } + public bool IsDefinitelyAbsent(string path) + { + MigrationRecordCodec.RejectReparsePoints(path); + try + { + _ = File.GetAttributes(path); + return false; + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + return true; + } + } + public string ReadIdentity(string path) { MigrationRecordCodec.RejectReparsePoints(path); diff --git a/src/OpenClaw.Connection/Migration/InnoMigrationConsentStore.cs b/src/OpenClaw.Connection/Migration/InnoMigrationConsentStore.cs new file mode 100644 index 000000000..26f65b2b8 --- /dev/null +++ b/src/OpenClaw.Connection/Migration/InnoMigrationConsentStore.cs @@ -0,0 +1,166 @@ +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; +using OpenClaw.Shared; + +namespace OpenClaw.Connection.Migration; + +/// +/// Persists explicit same-user Inno consent independently of a preparation inventory. +/// Call Grant only after UI confirmation and exact same-user Inno installation detection. +/// No source settings, services, or identities are inspected or changed. +/// +[SupportedOSPlatform("windows")] +public sealed class InnoMigrationConsentStore( + MigrationBinding binding, + IOpenClawLogger logger, + TimeProvider? timeProvider = null) +{ + internal const string WriterLockFileName = "consent.lock"; + private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System; + + /// + /// Writes a fresh 30-day grant under a shared prepare.lock lease and an exclusive + /// consent writer lock, so consent can be granted while Inno is running. + /// Explicit confirmation may renew an + /// expired, otherwise valid grant or change its source version. Corrupt, wrong-kind, + /// wrong-binding, and future-dated records require recovery and are never overwritten. + /// Invalid-record failures surface as InvalidDataException with the original exception retained. + /// Any completion receipt, even an invalid one, blocks a new grant. + /// + public void Grant(string sourceVersion) + { + using var identity = WindowsIdentity.GetCurrent(); + if (binding.UserSid != identity.User?.Value) + throw new InvalidOperationException("Migration consent must use the current Windows user."); + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + try + { + MigrationRecordStorage.CreateProtectedDirectory(directory); + using var migrationLock = AcquireReadLease(directory, FileMode.OpenOrCreate); + var writerPath = Path.Combine(directory, WriterLockFileName); + MigrationRecordCodec.RejectReparsePoints(writerPath); + using var writerLock = new FileStream(writerPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + + var completionPath = Path.Combine(directory, MigrationRecordCodec.CompletionFileName); + MigrationRecordCodec.RejectReparsePoints(completionPath); + if (Exists(completionPath)) + throw new InvalidOperationException("A completion receipt already exists. Resume or recover migration before granting consent again."); + + var now = _clock.GetUtcNow().UtcDateTime; + _ = ReadConsent(directory, now, allowExpired: true); + var record = new MigrationRecord + { + Kind = "consent", + MigrationId = Guid.NewGuid().ToString("D"), + SourceVersion = sourceVersion, + Binding = binding, + CreatedUtc = now, + ExpiresUtc = now.AddDays(30), + Fingerprint = MigrationRecordCodec.ConsentFingerprint + }; + MigrationRecordStorage.WriteAtomic( + Path.Combine(directory, MigrationRecordCodec.ConsentFileName), + MigrationRecordCodec.Encode(record, now)); + } + catch (Exception exception) when (IsInvalidRecord(exception)) + { + logger.Warn($"Inno migration consent requires recovery ({exception.GetType().Name})."); + throw new InvalidDataException("Inno migration consent requires recovery.", exception); + } + } + + /// + /// Missing, expired, or invalid consent returns false and requires fresh Store confirmation. + /// Inspection creates no files or directories. Operational IO/access failures, including + /// a missing or contended prepare.lock when consent exists, propagate to the caller. + /// This read never renews consent; inventory intents and completion receipts are not consent. + /// + public bool HasValidConsent(string sourceVersion) + { + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + try + { + var consentPath = Path.Combine(directory, MigrationRecordCodec.ConsentFileName); + MigrationRecordCodec.RejectReparsePoints(consentPath); + // A grant appearing after this check can safely require fresh confirmation. + if (!Exists(consentPath)) + return false; + using var migrationLock = AcquireReadLease(directory, FileMode.Open); + var record = ReadConsent(directory, _clock.GetUtcNow().UtcDateTime, allowExpired: false); + if (record is null) + return false; + if (!string.Equals(record.SourceVersion, sourceVersion, StringComparison.Ordinal)) + { + logger.Warn("Inno migration consent source version changed. Confirm migration again."); + return false; + } + return true; + } + catch (Exception exception) when (IsInvalidRecord(exception)) + { + logger.Warn($"Inno migration consent is invalid. Confirm migration again ({exception.GetType().Name})."); + return false; + } + } + + private static FileStream AcquireReadLease(string directory, FileMode mode) + { + var lockPath = Path.Combine(directory, "prepare.lock"); + MigrationRecordCodec.RejectReparsePoints(lockPath); + return new FileStream(lockPath, mode, FileAccess.Read, FileShare.Read); + } + + private MigrationRecord? ReadConsent(string directory, DateTime now, bool allowExpired) + { + var path = Path.Combine(directory, MigrationRecordCodec.ConsentFileName); + MigrationRecordCodec.RejectReparsePoints(path); + FileStream stream; + try + { + stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + } + catch (FileNotFoundException) { return null; } + catch (DirectoryNotFoundException) { return null; } + + using (stream) + { + if (stream.Length == 0 || stream.Length > MigrationRecordCodec.MaximumRecordBytes) + throw new InvalidDataException("Invalid migration consent size."); + using var reader = new BinaryReader(stream); + var bytes = reader.ReadBytes((int)stream.Length); + MigrationRecord record; + try + { + record = allowExpired + ? MigrationRecordCodec.DecodeForRenewedConsent(bytes, binding, now) + : MigrationRecordCodec.Decode(bytes, binding, now); + } + catch (IOException exception) + { + // Decode uses only memory. BinaryReader reports malformed string lengths + // as IO errors; file IO above must still propagate as an operational failure. + throw new InvalidDataException("Invalid migration consent encoding.", exception); + } + if (record.Kind != "consent") + throw new InvalidDataException("Expected explicit migration consent."); + return record; + } + } + + private static bool Exists(string path) + { + try + { + _ = File.GetAttributes(path); + return true; + } + catch (FileNotFoundException) { return false; } + catch (DirectoryNotFoundException) { return false; } + } + + private static bool IsInvalidRecord(Exception exception) => + exception is InvalidDataException or CryptographicException or EndOfStreamException or + ArgumentException or DecoderFallbackException or FormatException; +} diff --git a/src/OpenClaw.Connection/Migration/MigrationOperationLock.cs b/src/OpenClaw.Connection/Migration/MigrationOperationLock.cs index 15d819b21..66520cc52 100644 --- a/src/OpenClaw.Connection/Migration/MigrationOperationLock.cs +++ b/src/OpenClaw.Connection/Migration/MigrationOperationLock.cs @@ -45,7 +45,7 @@ private static FileStream Open(MigrationBinding binding, FileAccess access, File info.Create(security); else { - if (RequiresHardening(info, owner)) + if (RequiresDirectoryHardening(info, owner)) info.SetAccessControl(security); ScrubExistingEntries(info, owner); } @@ -60,7 +60,7 @@ private static FileStream Open(MigrationBinding binding, FileAccess access, File /// launch can lack on a roaming or administratively hardened profile. Reading is cheap and /// always permitted to the owner, so only correct a directory that actually drifted. /// - private static bool RequiresHardening(DirectoryInfo info, SecurityIdentifier owner) + internal static bool RequiresDirectoryHardening(DirectoryInfo info, SecurityIdentifier owner) { try { diff --git a/src/OpenClaw.Connection/Migration/MigrationPreparation.cs b/src/OpenClaw.Connection/Migration/MigrationPreparation.cs index f1a6c8559..f5732419b 100644 --- a/src/OpenClaw.Connection/Migration/MigrationPreparation.cs +++ b/src/OpenClaw.Connection/Migration/MigrationPreparation.cs @@ -59,21 +59,7 @@ internal MigrationRecord PrepareUnderLock(string sourceVersion) InventoryJson = JsonSerializer.Serialize(inventory) }; var bytes = MigrationRecordCodec.Encode(record, now); - var temporaryPath = Path.Combine(directory, $".{Guid.NewGuid():N}.tmp"); - try - { - using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) - { - stream.Write(bytes); - stream.Flush(flushToDisk: true); - } - File.Move(temporaryPath, intentPath, overwrite: true); - } - finally - { - if (File.Exists(temporaryPath)) - File.Delete(temporaryPath); - } + MigrationRecordStorage.WriteAtomic(intentPath, bytes); return record; } } diff --git a/src/OpenClaw.Connection/Migration/MigrationRecordCodec.cs b/src/OpenClaw.Connection/Migration/MigrationRecordCodec.cs index 9d50211a4..c1634bd19 100644 --- a/src/OpenClaw.Connection/Migration/MigrationRecordCodec.cs +++ b/src/OpenClaw.Connection/Migration/MigrationRecordCodec.cs @@ -41,10 +41,22 @@ public MigrationRecord() } } + /// + /// A migration path is unusable because of its shape, not because a record is corrupt. + /// Startup admission reports this as an inspection problem: recovery cannot repair a + /// reparse point, so offering recovery would be dead-end guidance. + /// + public sealed class MigrationPathRejectedException : IOException + { + public MigrationPathRejectedException(string message) : base(message) { } + } + public static class MigrationRecordCodec { public const string DirectoryName = "store-migration"; public const string IntentFileName = "intent.dpapi"; + public const string ConsentFileName = "consent.dpapi"; + public const string ConsentFingerprint = "0000000000000000000000000000000000000000000000000000000000000000"; public const string CompletionFileName = "completed.dpapi"; public const string PackageName = "OpenClawFoundation.OpenClaw"; public const string PackagePublisher = "CN=4BA40A7A-B719-4C40-BF91-84AF4F1136FC"; @@ -104,7 +116,7 @@ public static MigrationRecord Decode(byte[] bytes, MigrationBinding expected, Da return DecodeCore(bytes, expected, utcNow, false); } - // Only a newly confirmed preparation may renew an expired intent. + // Only a newly confirmed grant/preparation may renew expired consent/intent. #if NET10_0_OR_GREATER [System.Runtime.Versioning.SupportedOSPlatform("windows")] #endif @@ -175,6 +187,16 @@ public static MigrationRecord ReadCompletion(string path, MigrationBinding expec } public static void RejectReparsePoints(string path) + { + if (HasReparsePointAncestor(path)) + throw new InvalidDataException("Migration paths must not contain reparse points."); + } + + /// + /// Probes the same condition as without throwing, for + /// callers that must report a path shape separately from a corrupt record. + /// + public static bool HasReparsePointAncestor(string path) { var current = Path.GetFullPath(path); while (!string.IsNullOrEmpty(current)) @@ -182,12 +204,14 @@ public static void RejectReparsePoints(string path) try { if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) - throw new InvalidDataException("Migration paths must not contain reparse points."); + return true; } catch (FileNotFoundException) { } catch (DirectoryNotFoundException) { } current = Path.GetDirectoryName(current); } + + return false; } private static void Validate(MigrationRecord record, MigrationBinding expected, DateTime utcNow, bool renewConsent = false) @@ -223,6 +247,15 @@ private static void Validate(MigrationRecord record, MigrationBinding expected, record.TargetVersion != "" || string.IsNullOrWhiteSpace(record.InventoryJson)) throw new InvalidDataException("Migration intent is invalid or expired. Confirm migration again."); } + else if (record.Kind == "consent") + { + // Consent authorizes later preparation, not an inventory snapshot or uninstall. + if (record.CreatedUtc > utcNow || record.ExpiresUtc != record.CreatedUtc.AddDays(30) || + (!renewConsent && utcNow >= record.ExpiresUtc) || + record.TargetVersion != "" || record.InventoryJson != "" || + record.Fingerprint != ConsentFingerprint || record.AutoStart) + throw new InvalidDataException("Migration consent is invalid or expired. Confirm migration again."); + } else if (record.Kind == "completed") { // Neither aging nor a backward clock correction may re-enable destructive uninstall. diff --git a/src/OpenClaw.Connection/Migration/MigrationRecordStorage.cs b/src/OpenClaw.Connection/Migration/MigrationRecordStorage.cs new file mode 100644 index 000000000..b20cea9df --- /dev/null +++ b/src/OpenClaw.Connection/Migration/MigrationRecordStorage.cs @@ -0,0 +1,61 @@ +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; + +namespace OpenClaw.Connection.Migration; + +[SupportedOSPlatform("windows")] +internal static class MigrationRecordStorage +{ + /// + /// An existing directory is only re-hardened when it actually drifted. Rewriting the DACL needs + /// WRITE_DAC and WRITE_OWNER, which a roaming or administratively hardened profile can withhold + /// from an ordinary launch, and a refusal here is not an invalid record: it would surface as a + /// generic inspection failure and block a migration whose state was already correct. + /// + internal static void CreateProtectedDirectory(string directory) + { + MigrationRecordCodec.RejectReparsePoints(directory); + using var identity = WindowsIdentity.GetCurrent(); + var owner = identity.User ?? throw new InvalidOperationException("Cannot resolve the current Windows user."); + var security = new DirectorySecurity(); + security.SetOwner(owner); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + foreach (var sid in new[] + { + owner, + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null) + }) + { + security.AddAccessRule(new FileSystemAccessRule(sid, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, AccessControlType.Allow)); + } + var info = new DirectoryInfo(directory); + if (!info.Exists) + info.Create(security); + else if (MigrationOperationLock.RequiresDirectoryHardening(info, owner)) + info.SetAccessControl(security); + } + + internal static void WriteAtomic(string path, byte[] bytes) + { + MigrationRecordCodec.RejectReparsePoints(path); + var temporaryPath = Path.Combine(Path.GetDirectoryName(path)!, $".{Guid.NewGuid():N}.tmp"); + try + { + using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + File.Move(temporaryPath, path, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } +} diff --git a/src/OpenClaw.Connection/Migration/MigrationStartupRecordReader.cs b/src/OpenClaw.Connection/Migration/MigrationStartupRecordReader.cs index 4c6c5aa1a..46d1a34c0 100644 --- a/src/OpenClaw.Connection/Migration/MigrationStartupRecordReader.cs +++ b/src/OpenClaw.Connection/Migration/MigrationStartupRecordReader.cs @@ -15,9 +15,16 @@ public enum MigrationStartupRecordStatus Unavailable } +/// +/// reports that a completion receipt exists on disk even when +/// it could not be decoded. Data has already moved in that case, so admission must still protect +/// the handoff; alone cannot distinguish a +/// corrupt receipt from a corrupt intent. +/// public sealed record MigrationStartupRecord( MigrationStartupRecordStatus Status, - MigrationRecord? Record = null); + MigrationRecord? Record = null, + bool CompletionPresent = false); public interface IMigrationStartupRecordReader { @@ -40,30 +47,75 @@ public MigrationStartupRecord Read() { var completed = ReadFile(MigrationRecordCodec.CompletionFileName, "completed"); if (completed is not null) - return new(MigrationStartupRecordStatus.Completed, completed); + return new(MigrationStartupRecordStatus.Completed, completed, true); var intent = ReadFile(MigrationRecordCodec.IntentFileName, "intent"); return intent is null ? new(MigrationStartupRecordStatus.None) : new(MigrationStartupRecordStatus.Intent, intent); } + catch (MigrationPathRejectedException ex) + { + // A reparse point above the record directory is an environment shape, not a corrupt + // record. Recovery cannot repair it, so report it as an inspection problem instead. + // The receipt is still probed: refusing to open the path is about not reading or + // deleting through a redirection, and "the path is unsafe" is not evidence that + // data has not already moved. + logger.Error($"Store migration record path is unusable ({ex.Message})."); + return new(MigrationStartupRecordStatus.Unavailable, null, CompletionFileExists()); + } catch (Exception ex) when (ex is InvalidDataException or CryptographicException or - EndOfStreamException or ArgumentException or DecoderFallbackException) + EndOfStreamException or ArgumentException or FormatException or + DecoderFallbackException) { logger.Warn($"Store migration record requires recovery ({ex.GetType().Name})."); - return new(MigrationStartupRecordStatus.Invalid); + return new(MigrationStartupRecordStatus.Invalid, null, CompletionFileExists()); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) { logger.Error($"Store migration record inspection failed ({ex.GetType().Name})."); - return new(MigrationStartupRecordStatus.Unavailable); + return new(MigrationStartupRecordStatus.Unavailable, null, CompletionFileExists()); } } + /// + /// Existence only. A receipt that cannot be decoded still proves the handoff moved data, so + /// this answers "is the receipt definitely absent?" and treats every other outcome as + /// present. It runs from catch handlers and must never throw. + /// + /// is deliberately not used: it reports false for + /// an inaccessible path, an invalid path, and a directory of the same name, so it cannot + /// distinguish absence from failure and would fail open on exactly the paths that matter. + /// Only the not-found results prove nothing moved. + /// + /// + private bool CompletionFileExists() + { + try + { + _ = File.GetAttributes(RecordPath(MigrationRecordCodec.CompletionFileName)); + return true; + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + logger.Info($"No migration receipt is present ({ex.GetType().Name})."); + return false; + } + catch (Exception ex) + { + logger.Warn($"Could not confirm the migration receipt ({ex.GetType().Name}); assuming it exists."); + return true; + } + } + + private string RecordPath(string fileName) => + Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, fileName); + private MigrationRecord? ReadFile(string fileName, string expectedKind) { - var path = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, fileName); - MigrationRecordCodec.RejectReparsePoints(path); + var path = RecordPath(fileName); + if (MigrationRecordCodec.HasReparsePointAncestor(path)) + throw new MigrationPathRejectedException("Migration paths must not contain reparse points."); FileStream stream; try { @@ -77,9 +129,24 @@ public MigrationStartupRecord Read() if (stream.Length == 0 || stream.Length > MigrationRecordCodec.MaximumRecordBytes) throw new InvalidDataException("Invalid migration startup record size."); using var reader = new BinaryReader(stream); - var record = MigrationRecordCodec.DecodeForRenewedConsent( - reader.ReadBytes((int)stream.Length), binding, - (timeProvider ?? TimeProvider.System).GetUtcNow().UtcDateTime); + var bytes = reader.ReadBytes((int)stream.Length); + MigrationRecord record; + try + { + record = MigrationRecordCodec.DecodeForRenewedConsent( + bytes, binding, (timeProvider ?? TimeProvider.System).GetUtcNow().UtcDateTime); + } + // Defensive only: nothing in the decode raises this today, but the type derives from + // IOException and would otherwise be silently reclassified as a corrupt record. + catch (MigrationPathRejectedException) { throw; } + catch (IOException exception) + { + // Decode uses only memory, so this is corruption rather than an IO failure. + // BinaryReader reports a malformed string length that way, and reporting it as an + // inspection failure would route the record to InspectionFailed instead of + // Recovery, which is the only screen that offers a way out. + throw new InvalidDataException("Invalid migration startup record encoding.", exception); + } if (record.Kind != expectedKind) throw new InvalidDataException("Unexpected migration startup record kind."); return record; diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationAdoptionPreparationCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationAdoptionPreparationCoordinator.cs index 851d6fee0..9d16e184f 100644 --- a/src/OpenClaw.Connection/Migration/StoreMigrationAdoptionPreparationCoordinator.cs +++ b/src/OpenClaw.Connection/Migration/StoreMigrationAdoptionPreparationCoordinator.cs @@ -1,5 +1,6 @@ using OpenClaw.Shared; using System.Runtime.Versioning; +using System.Security.Cryptography; namespace OpenClaw.Connection.Migration; @@ -67,7 +68,9 @@ public StoreMigrationPreparationDecision Prepare(InnoInstallation expected) logger.Info("Store migration preparation is blocked by an active runtime or migration operation."); return new(StoreMigrationPreparationState.InnoRunning); } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or + InvalidDataException or InvalidOperationException or + FormatException or CryptographicException) { logger.Error($"Store migration preparation failed: {exception.Message}"); return new(StoreMigrationPreparationState.ValidationFailed); diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationCompletionCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationCompletionCoordinator.cs index f7a931a56..2eec2fc23 100644 --- a/src/OpenClaw.Connection/Migration/StoreMigrationCompletionCoordinator.cs +++ b/src/OpenClaw.Connection/Migration/StoreMigrationCompletionCoordinator.cs @@ -9,8 +9,6 @@ public enum StoreMigrationCompletionState InnoRunning, SourceChanged, IntentUnavailable, - NoActiveGateway, - CredentialUnavailable, ValidationFailed, Completed } @@ -21,15 +19,15 @@ public sealed record StoreMigrationCompletionDecision( /// /// Revalidates prepared source state under exclusive ownership and records completion. -/// It only proves a canonical active-gateway credential can resolve. It never starts, -/// repairs, or provisions gateway, node, or MCP services. +/// It never inspects gateway credentials: migration leaves gateway state in place, and +/// inventory capture has already rejected damaged identities before completion is reached. +/// It never starts, repairs, or provisions gateway, node, or MCP services. /// [SupportedOSPlatform("windows")] public sealed class StoreMigrationCompletionCoordinator( IMigrationSourceLeaseProvider leaseProvider, IInnoInstallationDetector detector, MigrationBinding binding, - ICredentialResolver credentialResolver, IOpenClawLogger logger, TimeProvider? timeProvider = null, IInnoSourceActivityVerifier? sourceActivity = null) @@ -75,16 +73,6 @@ public StoreMigrationCompletionDecision Complete(InnoInstallation expected, stri { return new(StoreMigrationCompletionState.SourceChanged); } - var registry = new GatewayRegistry(binding.RoamingDirectory, logger: logger); - registry.Load(); - var active = registry.GetActive(); - if (active is null) - return new(StoreMigrationCompletionState.NoActiveGateway); - - var resolution = credentialResolver.ResolveOperatorDetailed( - active, registry.GetIdentityDirectory(active.Id)); - if (resolution.Credential is null) - return new(StoreMigrationCompletionState.CredentialUnavailable); var receipt = new MigrationRecord { @@ -109,7 +97,7 @@ public StoreMigrationCompletionDecision Complete(InnoInstallation expected, stri } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException or - CryptographicException) + FormatException or CryptographicException) { logger.Error($"Store migration completion failed: {exception.Message}"); return new(StoreMigrationCompletionState.ValidationFailed); diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationFinalizationCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationFinalizationCoordinator.cs index 5d9e1a9b8..8ef4ed2f1 100644 --- a/src/OpenClaw.Connection/Migration/StoreMigrationFinalizationCoordinator.cs +++ b/src/OpenClaw.Connection/Migration/StoreMigrationFinalizationCoordinator.cs @@ -3,6 +3,7 @@ using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Security; +using System.Security.Cryptography; using OpenClaw.Shared; namespace OpenClaw.Connection.Migration; @@ -17,7 +18,18 @@ public enum StoreMigrationFinalizationState Finalized } -public sealed record StoreMigrationFinalizationDecision(StoreMigrationFinalizationState State) +/// What finalization concluded. +/// +/// Whether finalization got past verifying that the Inno source is gone. Once it is, no outcome +/// may refuse launch. The completion receipt exists to stop two live installations sharing one +/// data directory, and with the source removed there is no second installation left to stop. +/// Blocking past that point strands the user: the receipt blocks the app that is already +/// uninstalled, and the migration window will not hand over to the app that remains. Every such +/// outcome keeps the receipt, so the failed step is still retried on the next launch. +/// +public sealed record StoreMigrationFinalizationDecision( + StoreMigrationFinalizationState State, + bool SourceRemoved = false) { /// /// A refused startup preference still finalizes the migration: Windows gave a durable answer, @@ -136,8 +148,16 @@ public InnoSourceRemovalStatus VerifyRemoved() var uninstaller = Path.Combine(_sourceDirectory, "unins000.exe"); MigrationRecordCodec.RejectReparsePoints(executable); MigrationRecordCodec.RejectReparsePoints(uninstaller); - if (File.Exists(executable) || File.Exists(uninstaller)) + // This is the last gate before records are cleaned and the handoff is declared + // finished, so absence has to be proved rather than assumed. File.Exists answers + // false for a denied probe, an invalid path, and a directory of the same name alike, + // which would report a source that is merely unreadable as removed. + var executableState = ProbeSourceObject(executable); + var uninstallerState = ProbeSourceObject(uninstaller); + if (executableState == SourceObjectState.Present || uninstallerState == SourceObjectState.Present) return InnoSourceRemovalStatus.SourcePresent; + if (executableState == SourceObjectState.Unknown || uninstallerState == SourceObjectState.Unknown) + return InnoSourceRemovalStatus.InspectionFailed; var activity = new InnoSourceActivityVerifier(executable, _processes).VerifyStopped(); if (activity == InnoSourceActivityStatus.Running) @@ -149,8 +169,8 @@ public InnoSourceRemovalStatus VerifyRemoved() return createdNew ? InnoSourceRemovalStatus.Removed : InnoSourceRemovalStatus.SourcePresent; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or - SecurityException or Win32Exception or - InvalidOperationException or ArgumentException) + SecurityException or Win32Exception or InvalidDataException or + InvalidOperationException or ArgumentException or NotSupportedException) { return InnoSourceRemovalStatus.InspectionFailed; } @@ -162,6 +182,32 @@ private static string CanonicalizeDirectory(string directory) throw new ArgumentException("Source installation directory must be absolute.", nameof(directory)); return Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); } + + private enum SourceObjectState { Absent, Present, Unknown } + + /// + /// Any object at the path counts as present, whatever its shape: the question here is whether + /// the source is gone, and something occupying the executable's name is not gone. Only a + /// not-found result proves absence; anything that merely could not be probed is unknown and + /// must leave the caller blocking. + /// + private static SourceObjectState ProbeSourceObject(string path) + { + try + { + _ = File.GetAttributes(path); + return SourceObjectState.Present; + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + return SourceObjectState.Absent; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or + SecurityException or ArgumentException or NotSupportedException) + { + return SourceObjectState.Unknown; + } + } } [SupportedOSPlatform("windows")] @@ -311,6 +357,7 @@ public MigrationInventory Capture() => /// /// Reparse-safe final cleanup that keeps the completion receipt until all earlier cleanup succeeds. +/// The caller must hold prepare.lock while consent, intent, then completion are removed. /// [SupportedOSPlatform("windows")] public sealed class MigrationFinalizationRecordCleaner(MigrationBinding binding) : IStoreMigrationRecordCleaner @@ -319,10 +366,14 @@ public void ClearCompleted(MigrationRecord receipt) { ArgumentNullException.ThrowIfNull(receipt); var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + var consentLockPath = Path.Combine(directory, InnoMigrationConsentStore.WriterLockFileName); + var consentPath = Path.Combine(directory, MigrationRecordCodec.ConsentFileName); var intentPath = Path.Combine(directory, MigrationRecordCodec.IntentFileName); var completionPath = Path.Combine(directory, MigrationRecordCodec.CompletionFileName); MigrationRecordCodec.RejectReparsePoints(directory); + MigrationRecordCodec.RejectReparsePoints(consentLockPath); + MigrationRecordCodec.RejectReparsePoints(consentPath); MigrationRecordCodec.RejectReparsePoints(intentPath); MigrationRecordCodec.RejectReparsePoints(completionPath); var durable = MigrationRecordCodec.ReadCompletion( @@ -331,6 +382,8 @@ public void ClearCompleted(MigrationRecord receipt) throw new InvalidDataException("Completion receipt changed before cleanup."); // The receipt is the recovery anchor, so it is always deleted last. + File.Delete(consentLockPath); + File.Delete(consentPath); File.Delete(intentPath); File.Delete(completionPath); } @@ -373,7 +426,12 @@ public async Task FinalizeAsync() return new(StoreMigrationFinalizationState.AwaitingInnoRemoval); if (detected.Status is InnoInstallationStatus.Unsupported or InnoInstallationStatus.InspectionFailed) return new(StoreMigrationFinalizationState.InspectionFailed); - if (detected.Status != InnoInstallationStatus.NotInstalled) + // An orphaned registration is admitted here for the same reason admission allows it: the + // payload is proved gone and only the registry key remains. It is not taken as permission + // to finalize. The removal verifier below still has to return Removed on its own probes, + // so a surviving executable, uninstaller, process, or mutex continues to block. + if (detected.Status is not (InnoInstallationStatus.NotInstalled or + InnoInstallationStatus.OrphanedRegistration)) throw new InvalidOperationException("Unknown Inno installation detection result."); var sourceRemovalStatus = sourceRemoval.VerifyRemoved(); @@ -394,7 +452,7 @@ public async Task FinalizeAsync() var durable = records.Read(); if (durable.Status != MigrationStartupRecordStatus.Completed || durable.Record is null) - return new(StoreMigrationFinalizationState.InspectionFailed); + return new(StoreMigrationFinalizationState.InspectionFailed, SourceRemoved: true); MigrationInventory current; try @@ -405,13 +463,13 @@ public async Task FinalizeAsync() InvalidDataException) { logger.Error($"Store migration finalization inventory failed: {exception.Message}"); - return new(StoreMigrationFinalizationState.InspectionFailed); + return new(StoreMigrationFinalizationState.InspectionFailed, SourceRemoved: true); } if (!string.Equals(current.Fingerprint, durable.Record.Fingerprint, StringComparison.Ordinal)) { logger.Warn("Store migration finalization inventory no longer matches the completion receipt."); - return new(StoreMigrationFinalizationState.InspectionFailed); + return new(StoreMigrationFinalizationState.InspectionFailed, SourceRemoved: true); } var startupRefused = false; @@ -428,7 +486,7 @@ public async Task FinalizeAsync() UnauthorizedAccessException or InvalidOperationException) { logger.Error($"Store migration startup preference finalization failed: {exception.Message}"); - return new(StoreMigrationFinalizationState.StartupPreferenceFailed); + return new(StoreMigrationFinalizationState.StartupPreferenceFailed, SourceRemoved: true); } try @@ -436,22 +494,23 @@ public async Task FinalizeAsync() cleaner.ClearCompleted(durable.Record); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or - InvalidDataException) + InvalidDataException or FormatException or + CryptographicException) { logger.Error($"Store migration record cleanup failed: {exception.Message}"); - return new(StoreMigrationFinalizationState.RecordCleanupFailed); + return new(StoreMigrationFinalizationState.RecordCleanupFailed, SourceRemoved: true); } logger.Info($"Store migration finalized: {durable.Record.MigrationId}."); return new(startupRefused ? StoreMigrationFinalizationState.StartupPreferenceRefused - : StoreMigrationFinalizationState.Finalized); + : StoreMigrationFinalizationState.Finalized, SourceRemoved: true); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException) { logger.Error($"Store migration finalization could not acquire the preparation lock: {exception.Message}"); - return new(StoreMigrationFinalizationState.RecordCleanupFailed); + return new(StoreMigrationFinalizationState.RecordCleanupFailed, SourceRemoved: true); } } } diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationRecoveryDiscard.cs b/src/OpenClaw.Connection/Migration/StoreMigrationRecoveryDiscard.cs new file mode 100644 index 000000000..5b2f68e3e --- /dev/null +++ b/src/OpenClaw.Connection/Migration/StoreMigrationRecoveryDiscard.cs @@ -0,0 +1,209 @@ +using System.Runtime.Versioning; +using System.Security; +using System.Security.Cryptography; +using System.Text; +using OpenClaw.Shared; + +namespace OpenClaw.Connection.Migration; + +public enum StoreMigrationDiscardState +{ + Discarded, + RecordsAreReadable, + Busy, + Failed +} + +/// +/// The escape from when the records +/// cannot carry the migration any further. Without it a receipt that stops decoding, after a DPAPI +/// key loss or a profile move, blocks every launch with no in-app way back. +/// +/// Records are classified one file at a time rather than through the aggregate startup status, +/// which stops at the first unreadable file. A corrupt receipt beside a perfectly good intent must +/// only cost the receipt. +/// +/// +/// Two things are discardable: a record that does not decode, and any record left behind once the +/// source app is gone and no readable receipt exists, because there is then nothing left to +/// migrate from and the record can never be acted on again. A receipt that decodes is never +/// discarded; it is the proof that data moved. +/// +/// +/// Discarding does not expose user data. While the Store package stays registered, +/// scripts/Test-InnoMigration.ps1 answers a missing receipt with exit 11 and an undecodable +/// one with exit 2, and both preserve generated state and the gateway. +/// +/// +[SupportedOSPlatform("windows")] +public sealed class StoreMigrationRecoveryDiscard( + MigrationBinding binding, + IInnoInstallationDetector detector, + IOpenClawLogger logger) +{ + private enum RecordState + { + Missing, + Readable, + Unreadable + } + + /// + /// Best-effort and unlocked, for deciding whether to offer the action at all. It must not + /// throw: recovery is already on screen when this runs. re-plans under + /// the lock, so an offer made here is never trusted as authority to delete. + /// + public bool CanDiscard() + { + try + { + return Plan().Count > 0; + } + catch (Exception exception) + { + logger.Warn($"Could not classify the migration records ({exception.GetType().Name})."); + return false; + } + } + + public StoreMigrationDiscardState Discard() + { + var directory = Directory; + try + { + RejectPath(directory); + var lockPath = Path.Combine(directory, "prepare.lock"); + RejectPath(lockPath); + // Exclusive: uninstall reads these same records to decide whether to preserve data. + // OpenOrCreate, because a missing lease file is not evidence that there is nothing to + // discard. Opening it with Open would report success having deleted nothing, which is + // the same dead end this class exists to remove. + using var discardLock = new FileStream( + lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + + var targets = Plan(); + if (targets.Count == 0) + return StoreMigrationDiscardState.RecordsAreReadable; + + // The consent writer lock is residue rather than a record, but it is only meaningful + // alongside consent, and no grant can be in flight while this lease is held. + targets.Insert(0, Path.Combine(directory, InnoMigrationConsentStore.WriterLockFileName)); + foreach (var path in targets) + { + RejectPath(path); + File.Delete(path); + } + + logger.Info($"Discarded {targets.Count} store migration record file(s)."); + return StoreMigrationDiscardState.Discarded; + } + catch (DirectoryNotFoundException exception) + { + // Nothing is left to discard, which is the outcome the user asked for. + logger.Info($"No store migration records to discard ({exception.GetType().Name})."); + return StoreMigrationDiscardState.Discarded; + } + catch (MigrationPathRejectedException exception) + { + // A rejected path shape is not lock contention. Telling the user to close the previous + // app would send them after something that can never help. + logger.Error($"Store migration record paths were rejected: {exception.Message}"); + return StoreMigrationDiscardState.Failed; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + logger.Warn($"Store migration records are in use ({exception.GetType().Name})."); + return StoreMigrationDiscardState.Busy; + } + catch (Exception exception) + { + logger.Error($"Could not discard store migration records: {exception}"); + return StoreMigrationDiscardState.Failed; + } + } + + private string Directory => Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + + /// + /// A rejected path shape is an operational refusal, never evidence about a record's contents. + /// It is raised as so it stays distinguishable + /// from lock contention all the way out to the caller. + /// + private static void RejectPath(string path) + { + if (MigrationRecordCodec.HasReparsePointAncestor(path)) + throw new MigrationPathRejectedException($"Migration path {path} contains a reparse point."); + } + + /// + /// The receipt is planned last so an interrupted discard still looks like a handoff that needs + /// recovery rather than one that never happened. Operational failures propagate: a record that + /// merely could not be opened is not evidence that it is unreadable. + /// + private List Plan() + { + var directory = Directory; + var completionPath = Path.Combine(directory, MigrationRecordCodec.CompletionFileName); + var completion = Classify(completionPath, "completed"); + if (completion == RecordState.Readable) + return []; + + // An orphaned registration counts as gone for the same reason admission does: the payload + // is proved absent. Without this a readable consent or intent left beside an orphan would + // be undiscardable, and recovery would offer a button that clears nothing. + var sourceGone = detector.Detect().Status is InnoInstallationStatus.NotInstalled or + InnoInstallationStatus.OrphanedRegistration; + var targets = new List(); + Consider(Path.Combine(directory, MigrationRecordCodec.ConsentFileName), "consent"); + Consider(Path.Combine(directory, MigrationRecordCodec.IntentFileName), "intent"); + if (completion == RecordState.Unreadable) + targets.Add(completionPath); + return targets; + + void Consider(string path, string kind) + { + var state = Classify(path, kind); + if (state == RecordState.Unreadable || (sourceGone && state == RecordState.Readable)) + targets.Add(path); + } + } + + private RecordState Classify(string path, string expectedKind) + { + byte[] bytes; + try + { + // Mirrors the reader: a path shape is an operational refusal, not a corrupt record. + RejectPath(path); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length == 0 || stream.Length > MigrationRecordCodec.MaximumRecordBytes) + return RecordState.Unreadable; + using var reader = new BinaryReader(stream); + bytes = reader.ReadBytes((int)stream.Length); + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + return RecordState.Missing; + } + + // Decoding is pure memory work, so it is judged separately. Everything outside the + // allowlist propagates: a record that merely could not be read is not evidence of + // corruption, and only corruption may nominate a file for deletion. IOException is part + // of the allowlist here precisely because there is no file IO left in this block: + // BinaryReader reports a malformed string length that way, and treating it as an + // operational failure would hide the discard offer and restore the lockout. + try + { + var record = MigrationRecordCodec.DecodeForRenewedConsent(bytes, binding, DateTime.UtcNow); + return record.Kind == expectedKind ? RecordState.Readable : RecordState.Unreadable; + } + catch (Exception exception) when (exception is InvalidDataException or CryptographicException + or EndOfStreamException or IOException + or ArgumentException or FormatException + or DecoderFallbackException) + { + logger.Warn($"Migration record {expectedKind} does not decode ({exception.GetType().Name})."); + return RecordState.Unreadable; + } + } +} diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs index 43ecdf955..df73b2c54 100644 --- a/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs +++ b/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs @@ -17,15 +17,59 @@ public enum StoreMigrationStartupState public sealed record StoreMigrationStartupDecision( StoreMigrationStartupState State, - InnoInstallation? Installation = null) + InnoInstallation? Installation = null, + bool HoldsCompletionReceipt = false, + bool SourcePayloadPresent = false) { public bool AllowsNormalStartup => State is StoreMigrationStartupState.Disabled or StoreMigrationStartupState.NotRequired; + + /// + /// A handoff that has already moved data, or an installed source the Store app must not run + /// beside, refuses launch. The remaining states inform the user and then get out of the way: + /// refusing to start cannot repair a failed inspection or a corrupt record, and the records + /// stay on disk either way. Blocking there would only deny the user the app. + /// + /// This is driven by receipt presence rather than by the state name. A receipt can coexist + /// with a state that reads as informational (an unsupported source, a version mismatch, an + /// undecodable record), and starting normally in those cases would leave both installations + /// live against the same data. + /// + /// + /// An unsupported or out-of-date source blocks only when + /// confirms the app is really installed. Issue #1374 permits one active production client, and + /// an installed source the user must update or replace is still a client. The presence check is + /// what keeps that from becoming a lockout: an interrupted uninstall can leave a registration + /// with no payload, and blocking on that alone would leave no working app at all. + /// + /// + public bool BlocksStartup => HoldsDurableBlock || SourceBlocksStartup; + + /// + /// The half of that outlives the pass that observed it. Data has + /// moved, or is about to, so a later inspection that fails or throws must not drop the block: + /// the evidence is a record on disk, and not being able to re-read it does not unmake it. + /// + public bool HoldsDurableBlock => + HoldsCompletionReceipt || + State is StoreMigrationStartupState.FinalizationRequired or + StoreMigrationStartupState.AwaitingInnoRemoval; + + /// + /// The half of that is only ever as good as the pass that measured + /// it. It asserts a source is installed right now, so it must be recomputed every pass and + /// never latched: the user removing the source is exactly how this block is meant to end, and + /// a stale copy would keep the Store app closed after the previous app was already gone. + /// + public bool SourceBlocksStartup => + SourcePayloadPresent && + State is StoreMigrationStartupState.UnsupportedInstallation or + StoreMigrationStartupState.UpdateInno; } /// /// Read-only admission before instance forwarding or normal app services. -/// ConsentRequired is a handoff to a future consent workflow, not permission to migrate. +/// ConsentRequired is a handoff to the consent workflow, not permission to migrate. /// public sealed class StoreMigrationStartupCoordinator( IInnoInstallationDetector detector, @@ -37,60 +81,97 @@ public StoreMigrationStartupDecision Evaluate(bool enabled, string? minimumSourc if (!enabled) return new(StoreMigrationStartupState.Disabled); + // Read before the policy check so a malformed version or architecture policy cannot + // return a non-blocking decision while a receipt sits on disk. + var pending = records.Read(); + // Captured once so every downstream state carries it. A receipt outranks the state name. + // Status is checked alongside the flag so a caller that reports Completed without it + // still cannot produce a non-blocking decision. + var receipt = pending.CompletionPresent || pending.Status == MigrationStartupRecordStatus.Completed; + if (!MigrationVersionPolicy.TryParseReleaseVersion(minimumSourceVersion, out var minimum) || architecture is not ("x64" or "arm64")) { - logger.Error("Store migration preview has no valid source-version or architecture policy."); - return new(StoreMigrationStartupState.InspectionFailed); + logger.Error("Store migration has no valid source-version or architecture policy."); + return Decide(StoreMigrationStartupState.InspectionFailed, receipt); } - var pending = records.Read(); if (pending.Status == MigrationStartupRecordStatus.Unavailable) - return Decide(StoreMigrationStartupState.InspectionFailed); + return Decide(StoreMigrationStartupState.InspectionFailed, receipt); if (pending.Status == MigrationStartupRecordStatus.Invalid) - return Decide(StoreMigrationStartupState.RecoveryRequired); + return Decide(StoreMigrationStartupState.RecoveryRequired, receipt); var detected = detector.Detect(); + var sourcePayloadPresent = detected.SourcePayloadPresent; if (detected.Status == InnoInstallationStatus.InspectionFailed) - return Decide(StoreMigrationStartupState.InspectionFailed); + return Decide(StoreMigrationStartupState.InspectionFailed, receipt); if (detected.Status == InnoInstallationStatus.Unsupported) - return Decide(StoreMigrationStartupState.UnsupportedInstallation); + { + // An installation that predates the migration payload is not unsupportable; it just + // needs the update that ships migration. Asking for that is actionable guidance. + // A prerelease source lands here as well: its version never parses, so it arrives + // with no version to compare, and reporting it as an unsupported location, user, or + // architecture would send the user to verify things that are already correct. + var needsSourceUpdate = detected.RegisteredVersionUnsupported || + (detected.RegisteredVersion is { } registered && registered < minimum); + return Decide(needsSourceUpdate + ? StoreMigrationStartupState.UpdateInno + : StoreMigrationStartupState.UnsupportedInstallation, receipt, + sourcePayloadPresent: sourcePayloadPresent); + } - if (detected.Status == InnoInstallationStatus.NotInstalled) + // An orphaned canonical registration is treated exactly as an absent source. Its payload + // has been positively proved gone, so the leftover registry key is the only thing that + // separates it from NotInstalled, and refusing on a registry key alone is what would + // leave the user with no app at all. This grants nothing: a completed handoff still goes + // to finalization, which independently re-verifies removal before it acts. + if (detected.Status is InnoInstallationStatus.NotInstalled or + InnoInstallationStatus.OrphanedRegistration) { return Decide(pending.Status switch { MigrationStartupRecordStatus.None => StoreMigrationStartupState.NotRequired, MigrationStartupRecordStatus.Completed => StoreMigrationStartupState.FinalizationRequired, _ => StoreMigrationStartupState.RecoveryRequired - }); + }, receipt); } var installation = detected.Installation ?? throw new InvalidOperationException("Detected Inno installation has no installation evidence."); if (installation.Architecture != architecture) - return Decide(StoreMigrationStartupState.UnsupportedInstallation, installation); + return Decide(StoreMigrationStartupState.UnsupportedInstallation, receipt, installation, + sourcePayloadPresent); if (pending.Status == MigrationStartupRecordStatus.Completed) { - var completed = pending.Record - ?? throw new InvalidOperationException("Completed migration has no receipt."); + // Status without a decoded record should be unreachable, but throwing here would + // escape into the UI error boundary and be reported as a plain inspection failure, + // dropping the very receipt this branch proves exists. Recover instead. + if (pending.Record is not { } completed) + { + logger.Error("Completed migration has no decoded receipt; recovery is required."); + return Decide(StoreMigrationStartupState.RecoveryRequired, receipt, installation); + } if (!MigrationVersionPolicy.TryParseReleaseVersion(completed.SourceVersion, out var sourceVersion) || sourceVersion != installation.Version) - return Decide(StoreMigrationStartupState.RecoveryRequired, installation); - return Decide(StoreMigrationStartupState.AwaitingInnoRemoval, installation); + return Decide(StoreMigrationStartupState.RecoveryRequired, receipt, installation); + return Decide(StoreMigrationStartupState.AwaitingInnoRemoval, receipt, installation); } if (installation.Version < minimum) - return Decide(StoreMigrationStartupState.UpdateInno, installation); + return Decide(StoreMigrationStartupState.UpdateInno, receipt, installation, sourcePayloadPresent); // Even a valid, unexpired Inno intent does not replace Store-side consent. - return Decide(StoreMigrationStartupState.ConsentRequired, installation); + return Decide(StoreMigrationStartupState.ConsentRequired, receipt, installation); } - private StoreMigrationStartupDecision Decide(StoreMigrationStartupState state, InnoInstallation? installation = null) + private StoreMigrationStartupDecision Decide( + StoreMigrationStartupState state, bool holdsCompletionReceipt, + InnoInstallation? installation = null, bool sourcePayloadPresent = false) { - logger.Info($"Store migration startup admission: {state}."); - return new(state, installation); + logger.Info( + $"Store migration startup admission: {state} (receipt: {holdsCompletionReceipt}, " + + $"source payload: {sourcePayloadPresent})."); + return new(state, installation, holdsCompletionReceipt, sourcePayloadPresent); } } diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 24660f5cb..63ce8db28 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -542,7 +542,7 @@ private async Task OnLaunchedAsync(LaunchActivatedEventArgs args) return; // Environment.Exit called inside; defensive return } - if (StoreMigrationStartupGuard.ShouldStopLaunch()) + if (await StoreMigrationStartupGuard.ShouldStopLaunchAsync(DeepLinkPipeName)) { Exit(); return; @@ -593,7 +593,8 @@ private async Task OnLaunchedAsync(LaunchActivatedEventArgs args) return; } - _activationRouter = new ActivationRouter(AppIdentity.ProtocolScheme, DeepLinkPipeName); + _activationRouter = new ActivationRouter(AppIdentity.ProtocolScheme, DeepLinkPipeName, + InnoMigrationHandoff.CreateShutdownHandler(_dispatcherQueue!, ExitApplication)); if (!ownsMutex) { diff --git a/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationHandoff.cs b/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationHandoff.cs new file mode 100644 index 000000000..dca67a5c5 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationHandoff.cs @@ -0,0 +1,87 @@ +using Microsoft.UI.Dispatching; +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Helpers; + +internal static class InnoMigrationHandoff +{ + public static bool IsEnabled + { + get + { +#if INNO_MIGRATION_PREVIEW || PRODUCTION_MIGRATION + return !AppIdentity.IsDev && !PackageHelper.IsPackaged && + !GatewayFixtureIsolation.IsEnabled && !MigrationEnvironment.HasPathOverride; +#else + return false; +#endif + } + } + + public static bool IsAvailable => IsEnabled && StoreMigrationListing.TryCreateUri( + MigrationEnvironment.StoreProductId, out _); + + public static Action? CreateShutdownHandler(DispatcherQueue dispatcher, Action exit) => + IsEnabled ? () => + { + if (!dispatcher.TryEnqueue(() => + { + try + { + var (binding, installation) = InspectOwnInstallation(); + if (!new InnoMigrationConsentStore(binding, new AppLogger()) + .HasValidConsent(installation.Version.ToString())) + { + Logger.Warn("Migration shutdown rejected: explicit current-user consent is missing or invalid."); + return; + } + exit(); + } + catch (Exception exception) + { + Logger.Error($"Migration shutdown rejected: {exception.Message}"); + } + })) + Logger.Warn("Migration shutdown rejected: UI dispatcher is unavailable."); + } : null; + + public static async Task GrantAndLaunchAsync() + { + if (!IsAvailable || !StoreMigrationListing.TryCreateUri( + MigrationEnvironment.StoreProductId, out var uri)) + { + Logger.Error("Migration Store entry point is unavailable or has no configured product ID."); + return "Migration2_InnoFailed"; + } + + try + { + var (binding, installation) = InspectOwnInstallation(); + new InnoMigrationConsentStore(binding, new AppLogger()).Grant(installation.Version.ToString()); + if (!await global::Windows.System.Launcher.LaunchUriAsync(uri!)) + throw new InvalidOperationException("Windows declined the Store listing URI."); + return "Migration2_InnoGranted"; + } + catch (Exception exception) + { + Logger.Error($"Migration consent handoff failed: {exception}"); + return "Migration2_InnoFailed"; + } + } + + private static (MigrationBinding Binding, InnoInstallation Installation) InspectOwnInstallation() + { + if (!IsEnabled) + throw new InvalidOperationException("Inno migration is disabled."); + var binding = MigrationEnvironment.CreateBinding(); + var detected = MigrationEnvironment.CreateDetector().Detect(); + if (detected.Status != InnoInstallationStatus.Detected || detected.Installation is not { } installation || + installation.Architecture != binding.Architecture || + !string.Equals(Path.GetFullPath(Environment.ProcessPath ?? ""), + Path.Combine(binding.InstallDirectory, "OpenClaw.Tray.WinUI.exe"), StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("This process is not the supported current-user Inno installation."); + return (binding, installation); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationStartupGuard.cs b/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationStartupGuard.cs index c7cbce2b4..ac91b459b 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationStartupGuard.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationStartupGuard.cs @@ -13,6 +13,9 @@ internal static class InnoMigrationStartupGuard public static bool ShouldStopLaunch(out IDisposable? runtimeLease) { runtimeLease = null; +#if !INNO_MIGRATION_PREVIEW && !PRODUCTION_MIGRATION + return false; +#else if (AppIdentity.IsDev || PackageHelper.IsPackaged || GatewayFixtureIsolation.IsEnabled) return false; @@ -27,6 +30,9 @@ public static bool ShouldStopLaunch(out IDisposable? runtimeLease) }; // Acquisition must precede the receipt check, including when no receipt exists yet. // App retains this handle until process exit, even if startup or shutdown fails. + // An unavailable lock says nothing about whether migration completed, so it must not + // block startup on its own. InnoSourceActivityVerifier still detects this process by + // image path, so the Store side keeps its interlock without a lease. try { runtimeLease = MigrationOperationLock.AcquireRuntime(binding); @@ -60,7 +66,8 @@ InvalidDataException or ArgumentException or NotSupportedException or catch (FileNotFoundException) { return false; } catch (DirectoryNotFoundException) { return false; } catch (Exception ex) when (ex is InvalidDataException or CryptographicException or - EndOfStreamException or ArgumentException or DecoderFallbackException) + EndOfStreamException or ArgumentException or FormatException or + DecoderFallbackException) { Logger.Warn($"Migration completion receipt rejected ({ex.GetType().Name})."); return false; @@ -72,6 +79,7 @@ InvalidDataException or ArgumentException or NotSupportedException or } return ShowGuidance(resourceKey); +#endif } private static bool ShowGuidance(string resourceKey) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/MigrationEnvironment.cs b/src/OpenClaw.Tray.WinUI/Helpers/MigrationEnvironment.cs new file mode 100644 index 000000000..6505169ad --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/MigrationEnvironment.cs @@ -0,0 +1,42 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Principal; +using OpenClaw.Connection.Migration; + +namespace OpenClawTray.Helpers; + +internal static class MigrationEnvironment +{ + public static bool HasPathOverride => + new[] + { + "OPENCLAW_TRAY_DATA_DIR", "OPENCLAW_TRAY_APPDATA_DIR", + "OPENCLAW_TRAY_LOCALAPPDATA_DIR", "OPENCLAW_TRAY_LOCAL_DATA_DIR" + }.Any(name => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(name))); + + public static string? Metadata(string key) => typeof(MigrationEnvironment).Assembly + .GetCustomAttributes().SingleOrDefault(attribute => attribute.Key == key)?.Value; + + public static string? StoreProductId => + Metadata("MigrationStoreProductId") ?? Metadata("MigrationPreviewStoreProductId"); + + public static string? MinimumSourceVersion => + Metadata("MigrationMinimumSourceVersion") ?? Metadata("StoreMigrationPreviewMinimumSourceVersion"); + + public static MigrationBinding CreateBinding() + { + using var identity = WindowsIdentity.GetCurrent(); + return new MigrationBinding + { + InstallDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + AppIdentity.DataDirectoryName), + RoamingDirectory = AppIdentity.ResolveRoamingDataDirectory(), + LocalDirectory = AppIdentity.ResolveSetupLocalDataDirectory(), + Architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), + UserSid = identity.User?.Value ?? "" + }; + } + + public static InnoInstallationDetector CreateDetector() => + new(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), new AppLogger()); +} diff --git a/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationOperations.cs b/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationOperations.cs new file mode 100644 index 000000000..47b4215f3 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationOperations.cs @@ -0,0 +1,186 @@ +using System.Diagnostics; +using OpenClaw.Connection; +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Helpers; + +internal sealed class StoreMigrationOperations(string pipeName) : IStoreMigrationOperations +{ + private readonly AppLogger _logger = new(); + private MigrationBinding? _binding; + private IInnoInstallationDetector? _detector; + + public StoreMigrationStartupDecision Inspect() + { + var identity = global::Windows.ApplicationModel.Package.Current.Id; + if (identity.Name != MigrationRecordCodec.PackageName || + identity.Publisher != MigrationRecordCodec.PackagePublisher || MigrationEnvironment.HasPathOverride) + { + Logger.Error("Store migration requires the production package identity and default data paths."); + return new(StoreMigrationStartupState.UnsupportedInstallation); + } + _binding ??= MigrationEnvironment.CreateBinding(); + _detector ??= MigrationEnvironment.CreateDetector(); + return new StoreMigrationStartupCoordinator(_detector, new MigrationStartupRecordReader(_binding, _logger), _logger) + .Evaluate(true, MigrationEnvironment.MinimumSourceVersion, _binding.Architecture); + } + + /// + /// Deliberately independent of : it is called precisely when inspection + /// threw, so it must not depend on detection, policy, or package identity. + /// + public bool HoldsCompletionReceipt() + { + try + { + _binding ??= MigrationEnvironment.CreateBinding(); + return new MigrationStartupRecordReader(_binding, _logger).Read().CompletionPresent; + } + catch (Exception exception) + { + Logger.Error($"Could not confirm the migration receipt: {exception}"); + return true; + } + } + + public bool HasConsent(InnoInstallation installation) => + new InnoMigrationConsentStore(_binding!, _logger).HasValidConsent(installation.Version.ToString()); + + public void GrantConsent(InnoInstallation installation) => + new InnoMigrationConsentStore(_binding!, _logger).Grant(installation.Version.ToString()); + + public async Task CloseSourceAsync(StoreMigrationStartupDecision admission, CancellationToken cancellationToken) + { + var coordinator = new StoreMigrationConsentCoordinator(new InnoMutexProbe()); + if (coordinator.Begin(admission, true).State == StoreMigrationConsentState.ReadyForAdoption) + return true; + + await using var router = new ActivationRouter(AppIdentity.ProtocolScheme, pipeName); + if (!await router.RequestMigrationShutdownAsync(cancellationToken)) + return false; + + var elapsed = Stopwatch.StartNew(); + while (elapsed.Elapsed < TimeSpan.FromSeconds(10)) + { + await Task.Delay(200, cancellationToken); + if (coordinator.Retry().State == StoreMigrationConsentState.ReadyForAdoption) + return true; + } + Logger.Warn("Migration shutdown did not release the Inno instance. Manual close and Retry are required."); + return false; + } + + public Task PrepareAsync(InnoInstallation installation) => Task.Run(() => + new StoreMigrationAdoptionPreparationCoordinator( + new InnoMutexLeaseProvider(), _detector!, new MigrationPreparation(_binding!), _logger) + .Prepare(installation).State); + + public Task CompleteAsync(InnoInstallation installation) + { + var version = global::Windows.ApplicationModel.Package.Current.Id.Version; + var targetVersion = $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}"; + return Task.Run(() => new StoreMigrationCompletionCoordinator( + new InnoMutexLeaseProvider(), _detector!, _binding!, _logger) + .Complete(installation, targetVersion).State); + } + + public Task FinalizeAsync() + { + var records = new MigrationStartupRecordReader(_binding!, _logger); + return new StoreMigrationFinalizationCoordinator( + _binding!, _detector!, new InnoSourceRemovalVerifier(_binding!, AppIdentity.MutexBaseName), + records, new MigrationInventoryCapture(_binding!), new StoreMigrationAutoStartApplier(), + new MigrationFinalizationRecordCleaner(_binding!), _logger).FinalizeAsync(); + } + + /// + /// Like , independent of policy and package identity: + /// recovery may be showing because inspection itself could not run. + /// + public bool CanDiscardRecords() + { + _binding ??= MigrationEnvironment.CreateBinding(); + _detector ??= MigrationEnvironment.CreateDetector(); + return new StoreMigrationRecoveryDiscard(_binding, _detector, _logger).CanDiscard(); + } + + public StoreMigrationDiscardState DiscardUnreadableRecords() + { + _binding ??= MigrationEnvironment.CreateBinding(); + _detector ??= MigrationEnvironment.CreateDetector(); + return new StoreMigrationRecoveryDiscard(_binding, _detector, _logger).Discard(); + } + + private sealed class StoreMigrationAutoStartApplier : IStoreMigrationAutoStartApplier + { + public async Task ApplyAsync(bool enabled) + { + try + { + // Finalization can run during OnLaunched, before the app's XAML content is + // composed. Calling StartupTask.GetAsync on the UI thread there fails fast + // through Microsoft.UI.Xaml (STATUS_STOWED_EXCEPTION 0xc000027b), which kills + // the process past every managed catch and leaves the receipt behind, so the + // next launch crashes the same way. Marshalling the WinRT call off the UI + // thread keeps the failure catchable. + await Task.Run(() => AutoStartManager.SetAutoStartAsync(enabled)).ConfigureAwait(false); + } + catch (AutoStartRefusedException exception) when (exception.IsDurable) + { + // Only Windows can re-enable a durably refused startup task. Translating it here + // is what lets finalization report StartupPreferenceRefused and clear the receipt + // instead of retrying a preference that can never succeed. + throw new StoreMigrationAutoStartRefusedException(exception.Message, exception); + } + } + } + + private sealed class InnoMutexProbe : IInnoInstanceProbe + { + public bool IsRunning() + { + using var lease = new InnoMutexLeaseProvider().TryAcquire(); + return lease is null; + } + } + + private sealed class InnoMutexLeaseProvider : IMigrationSourceLeaseProvider + { + public IMigrationSourceLease? TryAcquire() + { + try + { + var mutex = new Mutex(true, AppIdentity.MutexBaseName, out var createdNew); + if (createdNew) + return new InnoMutexLease(mutex); + try + { + if (mutex.WaitOne(0)) + return new InnoMutexLease(mutex); + } + catch (AbandonedMutexException) + { + return new InnoMutexLease(mutex); + } + mutex.Dispose(); + return null; + } + catch (UnauthorizedAccessException exception) + { + Logger.Error($"Could not acquire the Inno instance mutex: {exception.Message}"); + return null; + } + } + } + + private sealed class InnoMutexLease(Mutex mutex) : IMigrationSourceLease + { + public void Dispose() + { + mutex.ReleaseMutex(); + mutex.Dispose(); + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationStartupGuard.cs b/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationStartupGuard.cs index f00d167ca..7565a256e 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationStartupGuard.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationStartupGuard.cs @@ -1,282 +1,42 @@ -using System.Reflection; -using System.Runtime.InteropServices; -using System.Security.Principal; -using OpenClaw.Connection; +using Microsoft.UI.Xaml; using OpenClaw.Connection.Migration; -using OpenClaw.Shared; using OpenClawTray.Services; +using OpenClawTray.Windows; namespace OpenClawTray.Helpers; internal static class StoreMigrationStartupGuard { - public static bool ShouldStopLaunch() + public static async Task ShouldStopLaunchAsync(string pipeName) { -#if !STORE_MIGRATION_PREVIEW +#if !STORE_MIGRATION_PREVIEW && !PRODUCTION_MIGRATION return false; #else if (!PackageHelper.IsPackaged || AppIdentity.IsDev) return false; - var identity = global::Windows.ApplicationModel.Package.Current.Id; - if (identity.Name != MigrationRecordCodec.PackageName || - identity.Publisher != MigrationRecordCodec.PackagePublisher || - HasPathOverride()) + var operations = new StoreMigrationOperations(pipeName); + StoreMigrationStartupDecision? admission = null; + try { - Logger.Error("Store migration preview requires the production package identity and default data paths."); - ShowGuidance("Migration_StoreUnsupported"); - return true; - } - - var localRoot = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - var binding = new MigrationBinding - { - InstallDirectory = Path.Combine(localRoot, AppIdentity.DataDirectoryName), - RoamingDirectory = AppIdentity.ResolveRoamingDataDirectory(), - LocalDirectory = AppIdentity.ResolveSetupLocalDataDirectory(), - Architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), - UserSid = WindowsIdentity.GetCurrent().User?.Value ?? "" - }; - var logger = new AppLogger(); - var minimum = typeof(StoreMigrationStartupGuard).Assembly.GetCustomAttributes() - .SingleOrDefault(attribute => attribute.Key == "StoreMigrationPreviewMinimumSourceVersion")?.Value; - var detector = new InnoInstallationDetector(localRoot, logger); - var coordinator = new StoreMigrationStartupCoordinator( - detector, - new MigrationStartupRecordReader(binding, logger), - logger); - var decision = coordinator.Evaluate(true, minimum, binding.Architecture); - if (decision.AllowsNormalStartup) - return false; - - if (decision.State == StoreMigrationStartupState.FinalizationRequired) - { - var finalization = FinalizeCompletedMigration(binding, detector, logger); - if (finalization.AllowsNormalStartup) - { - // A durable refusal is the one finalization outcome the user must act on - // themselves: only Windows can re-enable the startup task. Notify, then - // continue launching, because the migration itself succeeded. - if (finalization.State == StoreMigrationFinalizationState.StartupPreferenceRefused) - ShowGuidance("Migration_StoreStartupRefused"); + admission = operations.Inspect(); + if (admission.AllowsNormalStartup) return false; - } - - ShowGuidance(finalization.State switch - { - StoreMigrationFinalizationState.AwaitingInnoRemoval => "Migration_StoreAwaitingInnoRemoval", - StoreMigrationFinalizationState.InspectionFailed => "Migration_StoreInspectionFailed", - _ => "Migration_StoreFinalizationFailed" - }); - return true; } - - if (decision.State == StoreMigrationStartupState.ConsentRequired) + catch (Exception exception) { - RunConsentWorkflow(decision, binding, detector, logger); - return true; + Logger.Error($"Migration startup inspection failed: {exception}"); + // The workflow retries inspection and presents a blocking error. } - ShowGuidance(decision.State switch - { - StoreMigrationStartupState.UpdateInno => "Migration_StoreUpdateRequired", - StoreMigrationStartupState.UnsupportedInstallation => "Migration_StoreUnsupported", - StoreMigrationStartupState.InspectionFailed => "Migration_StoreInspectionFailed", - StoreMigrationStartupState.AwaitingInnoRemoval => "Migration_StoreAwaitingInnoRemoval", - _ => "Migration_StorePending" - }); - return true; + // Bootstrap owns only migration UI. Closing it must not end the dispatcher + // before a successful finalization resumes ordinary App composition. + Application.Current.DispatcherShutdownMode = DispatcherShutdownMode.OnExplicitShutdown; + // The admission is handed on rather than discarded: if it already observed a completion + // receipt, a later failing inspection inside the workflow must not lose that. + var workflow = new StoreMigrationWorkflow(operations, new AppLogger(), admission); + var window = new StoreMigrationWindow(workflow); + return !await window.ShowAsync(); #endif } - -#if STORE_MIGRATION_PREVIEW - private static bool HasPathOverride() => - new[] - { - "OPENCLAW_TRAY_DATA_DIR", "OPENCLAW_TRAY_APPDATA_DIR", - "OPENCLAW_TRAY_LOCALAPPDATA_DIR", "OPENCLAW_TRAY_LOCAL_DATA_DIR" - }.Any(name => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(name))); - - private static void RunConsentWorkflow( - StoreMigrationStartupDecision admission, - MigrationBinding binding, - IInnoInstallationDetector detector, - IOpenClawLogger logger) - { - var coordinator = new StoreMigrationConsentCoordinator(new InnoMutexProbe()); - var consent = coordinator.Begin(admission, ShowChoice("Migration_StoreConsent")); - - while (consent.State == StoreMigrationConsentState.WaitingForInnoExit) - { - if (!ShowChoice("Migration_StoreCloseInno")) - return; - - consent = coordinator.Retry(); - } - - if (consent.State != StoreMigrationConsentState.ReadyForAdoption || - admission.Installation is null) - return; - - var preparation = new StoreMigrationAdoptionPreparationCoordinator( - new InnoMutexLeaseProvider(), detector, new MigrationPreparation(binding), logger); - while (true) - { - var result = preparation.Prepare(admission.Installation); - switch (result.State) - { - case StoreMigrationPreparationState.Prepared: - CompletePreparedMigration(admission.Installation, binding, detector, logger); - return; - case StoreMigrationPreparationState.InnoRunning: - if (!ShowChoice("Migration_StoreCloseInno")) - return; - continue; - case StoreMigrationPreparationState.SourceChanged: - ShowGuidance("Migration_StoreUnsupported"); - return; - default: - ShowGuidance("Migration_StoreValidationFailed"); - return; - } - } - } - - private static void CompletePreparedMigration( - InnoInstallation installation, - MigrationBinding binding, - IInnoInstallationDetector detector, - IOpenClawLogger logger) - { - var version = global::Windows.ApplicationModel.Package.Current.Id.Version; - var targetVersion = $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}"; - var completion = new StoreMigrationCompletionCoordinator( - new InnoMutexLeaseProvider(), - detector, - binding, - new CredentialResolver(DeviceIdentityFileReader.Instance), - logger); - var result = completion.Complete(installation, targetVersion); - while (result.State == StoreMigrationCompletionState.InnoRunning) - { - if (!ShowChoice("Migration_StoreCloseInno")) - return; - result = completion.Complete(installation, targetVersion); - } - ShowGuidance(result.State switch - { - StoreMigrationCompletionState.Completed => "Migration_StoreAwaitingInnoRemoval", - StoreMigrationCompletionState.NoActiveGateway or StoreMigrationCompletionState.CredentialUnavailable - => "Migration_StoreCredentialUnavailable", - StoreMigrationCompletionState.SourceChanged => "Migration_StoreUnsupported", - _ => "Migration_StoreValidationFailed" - }); - } - - private static StoreMigrationFinalizationDecision FinalizeCompletedMigration( - MigrationBinding binding, - IInnoInstallationDetector detector, - IOpenClawLogger logger) - { - var records = new MigrationStartupRecordReader(binding, logger); - if (records.Read().Status != MigrationStartupRecordStatus.Completed) - return new(StoreMigrationFinalizationState.InspectionFailed); - - return new StoreMigrationFinalizationCoordinator( - binding, - detector, - new InnoSourceRemovalVerifier(binding, AppIdentity.MutexBaseName), - records, - new MigrationInventoryCapture(binding), - new StoreMigrationAutoStartApplier(), - new MigrationFinalizationRecordCleaner(binding), - logger) - .FinalizeAsync() - .GetAwaiter() - .GetResult(); - } - - private static bool ShowChoice(string contentKey) - { - var content = LocalizationHelper.Format(contentKey, - LocalizationHelper.GetString("Migration_StoreYes"), - LocalizationHelper.GetString("Migration_StoreNo")); - return MessageBoxW(IntPtr.Zero, content, LocalizationHelper.GetString("Migration_StorePreviewTitle"), - 0x00000124) == 6; - } - - private static void ShowGuidance(string resourceKey) - { - if (MessageBoxW(IntPtr.Zero, LocalizationHelper.GetString(resourceKey), - LocalizationHelper.GetString("Migration_StorePreviewTitle"), 0x00000040) == 0) - Logger.Error($"Could not show Store migration preview guidance (Win32 {Marshal.GetLastWin32Error()})."); - } - - private sealed class InnoMutexProbe : IInnoInstanceProbe - { - public bool IsRunning() - { - using var lease = new InnoMutexLeaseProvider().TryAcquire(); - return lease is null; - } - } - - private sealed class StoreMigrationAutoStartApplier : IStoreMigrationAutoStartApplier - { - public async Task ApplyAsync(bool enabled) - { - try - { - await Task.Run(() => AutoStartManager.SetAutoStartAsync(enabled)).ConfigureAwait(false); - } - catch (AutoStartRefusedException exception) when (exception.IsDurable) - { - throw new StoreMigrationAutoStartRefusedException(exception.Message, exception); - } - } - } - - private sealed class InnoMutexLeaseProvider : IMigrationSourceLeaseProvider - { - public IMigrationSourceLease? TryAcquire() - { - try - { - var mutex = new Mutex(true, AppIdentity.MutexBaseName, out var createdNew); - if (createdNew) - return new InnoMutexLease(mutex); - - try - { - if (mutex.WaitOne(0)) - return new InnoMutexLease(mutex); - } - catch (AbandonedMutexException) - { - return new InnoMutexLease(mutex); - } - - mutex.Dispose(); - return null; - } - catch (UnauthorizedAccessException exception) - { - Logger.Error($"Could not acquire the Inno instance mutex: {exception.Message}"); - return null; - } - } - } - - private sealed class InnoMutexLease(Mutex mutex) : IMigrationSourceLease - { - public void Dispose() - { - mutex.ReleaseMutex(); - mutex.Dispose(); - } - } - - [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern int MessageBoxW(IntPtr window, string text, string caption, uint type); -#endif } diff --git a/src/OpenClaw.Tray.WinUI/Migration.Build.props b/src/OpenClaw.Tray.WinUI/Migration.Build.props new file mode 100644 index 000000000..577e64e73 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Migration.Build.props @@ -0,0 +1,79 @@ + + + 9NFPR3BGDRR5 + + 2026.9.5.0 + + true + + + + $(DefineConstants);PRODUCTION_MIGRATION + + + + <_Parameter1>MigrationStoreProductId + <_Parameter2>$(MigrationStoreProductId) + + + <_Parameter1>MigrationMinimumSourceVersion + <_Parameter2>$(MigrationMinimumSourceVersion) + + + + + $(DefineConstants);INNO_MIGRATION_PREVIEW + + + + <_Parameter1>MigrationPreviewStoreProductId + <_Parameter2>$(MigrationPreviewStoreProductId) + + + + $(DefineConstants);STORE_MIGRATION_PREVIEW + + + + <_Parameter1>StoreMigrationPreviewMinimumSourceVersion + <_Parameter2>$(StoreMigrationPreviewMinimumSourceVersion) + + + + + + + + + + + + + + <_ValidatedMigrationMinimumSourceVersion>$([System.Version]::Parse('$(MigrationMinimumSourceVersion)').ToString()) + + + + + + + + + + + + diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index c1e158f2b..1068ac0a3 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -58,23 +58,7 @@ win-arm64 - - - $(DefineConstants);STORE_MIGRATION_PREVIEW - - - - <_Parameter1>StoreMigrationPreviewMinimumSourceVersion - <_Parameter2>$(StoreMigrationPreviewMinimumSourceVersion) - - - - - - + diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml index d3cecf4f1..1427704ed 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml @@ -32,6 +32,73 @@ Margin="0,0,0,8"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -418,7 +485,7 @@ IsOpen="False" IsClosable="False" Title="MSIX installation detected" - Message="Removing OpenClaw from Windows Settings → Apps will NOT clean up the Local Gateway (WSL distro, disk image). Use Remove Local Gateway below FIRST, then uninstall OpenClaw."/> + Message="Removing OpenClaw from Windows Settings → Apps will NOT clean up the Local Gateway (WSL distro, disk image). Use Remove Local Gateway below FIRST, then uninstall OpenClaw."/> + AsyncEventHandlerGuard.Run(OnStoreMigrationAsync, new AppLogger(), nameof(OnStoreMigration)); + + private async Task OnStoreMigrationAsync() + { + StoreMigrationAction.IsEnabled = false; + try + { + var confirmation = new ContentDialog + { + XamlRoot = XamlRoot, + Title = LocalizationHelper.GetString("Migration2_InnoConsentTitle"), + Content = LocalizationHelper.GetString("Migration2_InnoConsent"), + PrimaryButtonText = LocalizationHelper.GetString("Migration2_InnoAction"), + CloseButtonText = LocalizationHelper.GetString("Migration_StoreNotNow"), + DefaultButton = ContentDialogButton.Close + }; + if (await confirmation.ShowAsync() != ContentDialogResult.Primary) + return; + var messageKey = await InnoMigrationHandoff.GrantAndLaunchAsync(); + ShowStoreMigrationStatus(messageKey, messageKey == "Migration2_InnoGranted" + ? InfoBarSeverity.Success : InfoBarSeverity.Error); + } + catch (Exception exception) + { + Logger.Error($"Could not show migration consent: {exception.Message}"); + ShowStoreMigrationStatus("Migration2_InnoFailed", InfoBarSeverity.Error); + } + finally + { + StoreMigrationAction.IsEnabled = true; + } + } + + private void ShowStoreMigrationStatus(string messageKey, InfoBarSeverity severity) + { + StoreMigrationStatus.Message = LocalizationHelper.GetString(messageKey); + StoreMigrationStatus.Severity = severity; + // Re-open a bar the user never dismissed so a second result is announced + // rather than silently swapping the text of the first one. + StoreMigrationStatus.IsOpen = false; + StoreMigrationStatus.Visibility = Visibility.Visible; + // The bar is collapsed while idle so it does not consume layout spacing, but a + // collapsed element has no automation peer, and InfoBar announces through the + // existing peer only. Realize it before opening or the announcement is dropped. + FrameworkElementAutomationPeer.CreatePeerForElement(StoreMigrationStatus); + StoreMigrationStatus.IsOpen = true; + } + + private void OnStoreMigrationStatusClosed(InfoBar sender, InfoBarClosedEventArgs args) + { + if (!sender.IsOpen) + sender.Visibility = Visibility.Collapsed; + } + /// /// The Settings view model is assigned as the page DataContext by the navigation activation /// hook. The two-way bindings handle load/persist; the page only subscribes to the view-only diff --git a/src/OpenClaw.Tray.WinUI/Services/ActivationRouter.cs b/src/OpenClaw.Tray.WinUI/Services/ActivationRouter.cs index bc5334038..b26bdc2b1 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ActivationRouter.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ActivationRouter.cs @@ -17,6 +17,8 @@ internal sealed class ActivationRouter : IAsyncDisposable private static readonly TimeSpan ForwardRetryDelay = TimeSpan.FromMilliseconds(100); private readonly string _protocolScheme; private readonly string _pipeName; + private const string MigrationShutdownMessage = "OpenClaw.Migration.Shutdown.v1"; + private readonly Action? _migrationShutdown; private readonly object _lifecycleGate = new(); private readonly object _dispatchGate = new(); private readonly HashSet _pendingDispatches = new(); @@ -32,10 +34,11 @@ private sealed class DispatchOperation(CancellationTokenSource cancellation) public Task? Task { get; set; } } - public ActivationRouter(string protocolScheme, string pipeName) + public ActivationRouter(string protocolScheme, string pipeName, Action? migrationShutdown = null) { _protocolScheme = protocolScheme; _pipeName = pipeName; + _migrationShutdown = migrationShutdown; } public ActivationPlan PlanLaunch(LaunchActivationInput input) @@ -210,6 +213,16 @@ private async Task RunListenerLoopAsync(IActivationPlanSink sink, CancellationTo outBufferSize: 0); await pipe.WaitForConnectionAsync(token); var uri = await ReadIpcPayloadAsync(pipe, token); + if (uri == MigrationShutdownMessage) + { + // This is not a URI route. Only the preview Inno host supplies a + // callback, which revalidates protected consent before graceful exit. + if (_migrationShutdown is null) + Logger.Warn("Migration shutdown is not enabled for this host."); + else + _migrationShutdown(); + continue; + } if (!string.IsNullOrEmpty(uri)) { Logger.Info($"Received deep link via IPC: {DeepLinkSecurityPolicy.RedactForLog(uri)}"); @@ -271,6 +284,28 @@ private static async Task ObserveForwardedDispatchAsync( } } + [SupportedOSPlatform("windows")] + public async Task RequestMigrationShutdownAsync(CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(2)); + try + { + using var pipe = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + await pipe.ConnectAsync(timeout.Token).ConfigureAwait(false); + await pipe.WriteAsync(Encoding.UTF8.GetBytes(MigrationShutdownMessage), timeout.Token).ConfigureAwait(false); + await pipe.FlushAsync(timeout.Token).ConfigureAwait(false); + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or OperationCanceledException) + { + Logger.Warn($"Migration shutdown request unavailable: {exception.Message}"); + cancellationToken.ThrowIfCancellationRequested(); + return false; + } + } + [SupportedOSPlatform("windows")] public async Task ForwardToPrimaryAsync(string uri, CancellationToken cancellationToken) { diff --git a/src/OpenClaw.Tray.WinUI/Services/StoreMigrationListing.cs b/src/OpenClaw.Tray.WinUI/Services/StoreMigrationListing.cs new file mode 100644 index 000000000..b6274e5bb --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/StoreMigrationListing.cs @@ -0,0 +1,14 @@ +namespace OpenClawTray.Services; + +internal static class StoreMigrationListing +{ + public static bool TryCreateUri(string? productId, out Uri? uri) + { + uri = null; + if (productId is not { Length: 12 } || productId.Any(character => + character is not (>= 'A' and <= 'Z') and not (>= '0' and <= '9'))) + return false; + uri = new Uri($"ms-windows-store://pdp/?ProductId={productId}"); + return true; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/StoreMigrationWorkflow.cs b/src/OpenClaw.Tray.WinUI/Services/StoreMigrationWorkflow.cs new file mode 100644 index 000000000..30fa43133 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/StoreMigrationWorkflow.cs @@ -0,0 +1,361 @@ +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; + +namespace OpenClawTray.Services; + +internal enum StoreMigrationStage +{ + Inspecting, Consent, ClosingSource, Preparing, Completing, Finalizing, + CloseSource, AwaitingRemoval, ValidationFailed, + UpdateRequired, Unsupported, InspectionFailed, Recovery, FinalizationFailed, + StartupRefused, Ready +} + +internal interface IStoreMigrationOperations +{ + StoreMigrationStartupDecision Inspect(); + bool HasConsent(InnoInstallation installation); + void GrantConsent(InnoInstallation installation); + Task CloseSourceAsync(StoreMigrationStartupDecision admission, CancellationToken cancellationToken); + Task PrepareAsync(InnoInstallation installation); + Task CompleteAsync(InnoInstallation installation); + Task FinalizeAsync(); + + /// + /// Whether recovery can be cleared from inside the app. Must not throw: it runs while recovery + /// is already showing. See . + /// + bool CanDiscardRecords(); + + /// Removes discardable records. See . + StoreMigrationDiscardState DiscardUnreadableRecords(); + + /// + /// Receipt presence only, for when itself fails and never produces a + /// decision. Must answer without requiring a successful inspection. + /// + bool HoldsCompletionReceipt(); +} + +/// Serializes UI actions without replacing the admission, adoption, or finalization owners. +internal sealed class StoreMigrationWorkflow( + IStoreMigrationOperations operations, + IOpenClawLogger logger, + StoreMigrationStartupDecision? initialAdmission = null) +{ + public StoreMigrationStage Stage { get; private set; } = StoreMigrationStage.Inspecting; + public bool IsBusy { get; private set; } + + /// + /// Whether recovery can be cleared from inside the app. Only undecodable records qualify: + /// every other recovery cause is repaired outside the window, by removing the previous app. + /// + public bool CanDiscardRecords { get; private set; } + + /// + /// The last discard outcome, so the window can say that nothing happened. A running previous + /// app holds the migration lease for its whole lifetime, so + /// is the expected answer there and silence would look like a dead button. + /// + public StoreMigrationDiscardState? LastDiscard { get; private set; } + + /// + /// Closing the window abandons migration, so only a handoff whose completion receipt already + /// exists, or an installed source the Store app must not run beside, may keep the app from + /// starting. This tracks + /// rather than the visible stage, + /// because a receipt can survive a stage that later reports an inspection failure. Every + /// other state has nothing to protect, and refusing to launch would leave the user stuck. + /// + /// is the exception, and it is a product contract + /// rather than a data-safety rule. Issue #1374 specifies that Not now "leaves Inno unchanged + /// and MSIX inactive", so declining, or dismissing the window at the same stage, must not + /// hand the user a running Store app while the previous app is still installed. This stage is + /// only ever reached with a detected installation, so the block cannot strand a user whose + /// previous app is already gone. + /// + /// + /// The remaining informational stages are deliberately not included. An unsupported source + /// without its payload, a failed inspection, or an undecodable record can all occur after the + /// previous app has been removed, where refusing to launch would leave no working app at all. + /// An unsupported source that is genuinely installed does block, decided at admission where + /// the payload evidence lives. + /// + /// + /// _sourceRemoved overrides the receipt hold outright. Finalization sets it once it has + /// verified the previous installation is gone, and from that point a failure has nothing left + /// to protect: the receipt would block an app that no longer exists while this window refuses + /// to release the one that does. The failing stage stays visible, but the user can close it + /// and start. This override deliberately does not reach , + /// which stays blocked as a product contract; the two are ordered so the Consent block wins. + /// Reaching Consent again requires a fresh admission that still detects an installation, so + /// the two rules cannot contradict each other. + /// + /// + /// Before the first admission resolves, nothing is known yet, so closing is treated as + /// blocking. Otherwise a close raced against startup inspection would wave the user through + /// a handoff that had already moved data. + /// + /// + /// is excluded alongside + /// : finalization succeeded and cleared the receipt, + /// so the window is only reporting that Windows refused the startup task. Blocking there + /// would withhold an app whose migration is complete. + /// + /// + public bool BlocksStartup => + Stage is StoreMigrationStage.Consent || + (!_sourceRemoved && + (!_admissionResolved || _holdsStartupBlock || _sourceBlocksStartup) && + Stage is not (StoreMigrationStage.Ready or StoreMigrationStage.StartupRefused)); + public event Action? Changed; + private InnoInstallation? _promptInstallation; + // Seeded from the caller's admission so a receipt observed before this workflow existed is + // not lost when a later inspection pass fails. Latches only durable reasons startup must not + // resume: data that has moved, or is about to. + private bool _holdsStartupBlock = initialAdmission?.HoldsDurableBlock ?? false; + private bool _admissionResolved; + + // Deliberately not latched. This asserts a source is installed right now, and removing that + // source is how the user is meant to end the block. Latching it would keep the Store app + // closed after the previous app was gone, which is the lockout this whole path avoids. + private bool _sourceBlocksStartup = initialAdmission?.SourceBlocksStartup ?? false; + + // Latched, never cleared: once finalization has proven the previous installation is gone, no + // later pass may re-impose a startup block that would leave the user with no usable app. + private bool _sourceRemoved; + + public Task StartAsync(CancellationToken cancellationToken) => RunAsync(false, cancellationToken); + public Task ContinueAsync(CancellationToken cancellationToken) => + RunAsync(Stage == StoreMigrationStage.Consent, cancellationToken); + + /// + /// Deletes records that cannot be decoded, then re-inspects. The receipt hold is released only + /// on success: the hold exists because an undecodable receipt might mean data moved, and after + /// the records are gone there is nothing left for a later pass to read it from. + /// + public async Task DiscardRecordsAsync(CancellationToken cancellationToken) + { + if (IsBusy || Stage != StoreMigrationStage.Recovery) + return; + + IsBusy = true; + var result = StoreMigrationDiscardState.Failed; + try + { + LastDiscard = null; + Changed?.Invoke(); + result = await Task.Run(operations.DiscardUnreadableRecords, cancellationToken); + if (result != StoreMigrationDiscardState.Discarded) + logger.Error($"Could not discard the migration records ({result})."); + } + catch (Exception exception) + { + logger.Error($"Discarding the migration records failed: {exception}"); + } + finally + { + IsBusy = false; + LastDiscard = result; + // A contended or failing discard stays offered so the user can retry. Readable + // records mean the offer was wrong, and repeating it would only mislead. + if (result == StoreMigrationDiscardState.RecordsAreReadable) + CanDiscardRecords = false; + Changed?.Invoke(); + } + + if (result != StoreMigrationDiscardState.Discarded) + return; + + _holdsStartupBlock = false; + await RunAsync(false, cancellationToken); + } + + private async Task RunAsync(bool confirmed, CancellationToken cancellationToken) + { + // Recovery is deliberately retryable. Its usual cause, a receipt whose source version no + // longer matches the installed Inno, clears once the user removes that app, and a pass + // that refused to re-inspect would leave them looking at advice they had already followed. + if (IsBusy || Stage is StoreMigrationStage.Ready) + return; + + IsBusy = true; + // A new pass supersedes whatever the last discard reported. + LastDiscard = null; + try + { + SetStage(StoreMigrationStage.Inspecting); + cancellationToken.ThrowIfCancellationRequested(); + var admission = operations.Inspect(); + // Never cleared by a later pass: Retry re-inspects, and a transient failure there + // must not drop a receipt this session already observed or wrote. + _holdsStartupBlock |= admission.HoldsDurableBlock; + // Replaced, never latched: this pass just measured whether the source is installed. + _sourceBlocksStartup = admission.SourceBlocksStartup; + if (admission.AllowsNormalStartup) + { + SetStage(StoreMigrationStage.Ready); + return; + } + + if (admission.State == StoreMigrationStartupState.FinalizationRequired) + { + SetStage(StoreMigrationStage.Finalizing); + var result = await operations.FinalizeAsync(); + _sourceRemoved |= result.SourceRemoved; + // A durable startup refusal still finalizes the migration, so it must not be + // folded into Ready: the user has to be told that only Windows can re-enable + // the startup task. Launch still proceeds once they close the window. + if (result.State == StoreMigrationFinalizationState.StartupPreferenceRefused) + { + SetStage(StoreMigrationStage.StartupRefused); + return; + } + SetStage(result.AllowsNormalStartup ? StoreMigrationStage.Ready : result.State switch + { + StoreMigrationFinalizationState.AwaitingInnoRemoval => StoreMigrationStage.AwaitingRemoval, + StoreMigrationFinalizationState.InspectionFailed => StoreMigrationStage.InspectionFailed, + _ => StoreMigrationStage.FinalizationFailed + }); + return; + } + + if (admission.State != StoreMigrationStartupState.ConsentRequired || admission.Installation is null) + { + SetStage(admission.State switch + { + StoreMigrationStartupState.AwaitingInnoRemoval => StoreMigrationStage.AwaitingRemoval, + StoreMigrationStartupState.UpdateInno => StoreMigrationStage.UpdateRequired, + StoreMigrationStartupState.UnsupportedInstallation => StoreMigrationStage.Unsupported, + StoreMigrationStartupState.InspectionFailed => StoreMigrationStage.InspectionFailed, + _ => StoreMigrationStage.Recovery + }); + return; + } + + if ((confirmed && _promptInstallation != admission.Installation) || + (!confirmed && !operations.HasConsent(admission.Installation))) + { + _promptInstallation = admission.Installation; + SetStage(StoreMigrationStage.Consent); + return; + } + if (confirmed) + { + try + { + operations.GrantConsent(admission.Installation); + } + catch (Exception exception) when (exception is InvalidDataException or System.Security.Cryptography.CryptographicException) + { + logger.Error($"Migration consent needs recovery; the existing record was preserved: {exception.Message}"); + SetStage(StoreMigrationStage.Recovery); + return; + } + } + + SetStage(StoreMigrationStage.ClosingSource); + if (!await operations.CloseSourceAsync(admission, cancellationToken)) + { + SetStage(StoreMigrationStage.CloseSource); + return; + } + + cancellationToken.ThrowIfCancellationRequested(); + SetStage(StoreMigrationStage.Preparing); + var prepared = await operations.PrepareAsync(admission.Installation); + if (prepared != StoreMigrationPreparationState.Prepared) + { + SetStage(prepared switch + { + StoreMigrationPreparationState.InnoRunning => StoreMigrationStage.CloseSource, + StoreMigrationPreparationState.SourceChanged => StoreMigrationStage.Unsupported, + _ => StoreMigrationStage.ValidationFailed + }); + return; + } + + cancellationToken.ThrowIfCancellationRequested(); + SetStage(StoreMigrationStage.Completing); + var completed = await operations.CompleteAsync(admission.Installation); + // A written receipt means data has moved; the window may no longer be dismissed + // into normal startup until finalization succeeds. + _holdsStartupBlock |= completed == StoreMigrationCompletionState.Completed; + SetStage(completed switch + { + StoreMigrationCompletionState.Completed => StoreMigrationStage.AwaitingRemoval, + StoreMigrationCompletionState.InnoRunning => StoreMigrationStage.CloseSource, + StoreMigrationCompletionState.SourceChanged => StoreMigrationStage.Unsupported, + _ => StoreMigrationStage.ValidationFailed + }); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + SetStage(StoreMigrationStage.CloseSource); + } + catch (Exception exception) + { + // This is the UI error boundary. A failure here is reported as an inspection problem; + // whether it also blocks startup is decided by the receipt, not by the failure. The + // receipt is probed directly because a throwing inspection never observed one, and + // leaving the flag clear would let a close resume startup against moved data. + logger.Error($"Migration workflow failed: {exception}"); + _holdsStartupBlock |= HoldsReceiptWithoutInspection(); + // This pass detected no source at all, so it cannot assert one is installed. Keeping + // a previous pass's answer would block a user who has since removed the source and + // now only has a failing inspection standing between them and a usable app. + _sourceBlocksStartup = false; + SetStage(StoreMigrationStage.InspectionFailed); + } + finally + { + // The pass reached a definite stage, so closing may now be judged on the receipt. + _admissionResolved = true; + IsBusy = false; + Changed?.Invoke(); + } + } + + /// + /// Fails safe: if the receipt cannot be confirmed after the workflow already failed, nothing + /// is known about whether data moved, and the app must not resume normal startup. + /// + private bool HoldsReceiptWithoutInspection() + { + try + { + return operations.HoldsCompletionReceipt(); + } + catch (Exception exception) + { + logger.Error($"Could not confirm the migration receipt after a workflow failure: {exception}"); + return true; + } + } + + private void SetStage(StoreMigrationStage stage) + { + Stage = stage; + if (stage == StoreMigrationStage.Recovery) + CanDiscardRecords = ProbeDiscardOffer(); + Changed?.Invoke(); + } + + /// + /// Recovery is already showing when this runs, so a failure here must not replace it with an + /// error. An unanswerable probe hides the offer rather than promising an escape that is not + /// there; the guidance text still names the remedies that work from outside the app. + /// + private bool ProbeDiscardOffer() + { + try + { + return operations.CanDiscardRecords(); + } + catch (Exception exception) + { + logger.Error($"Could not classify the migration records during recovery: {exception}"); + return false; + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index bde45f74b..e7dbd812e 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -4997,7 +4997,7 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo MSIX installation detected - Removing OpenClaw from Windows Settings &#x2192; Apps will NOT clean up the Local Gateway (WSL distro, disk image). Use Remove Local Gateway below FIRST, then uninstall OpenClaw. + Removing OpenClaw from Windows Settings → Apps will NOT clean up the Local Gateway (WSL distro, disk image). Use Remove Local Gateway below FIRST, then uninstall OpenClaw. Removes the WSL distro (OpenClawGateway), its disk image, autostart entry, and clears gateway credentials. Your MCP token is preserved. Onboarding will reset. @@ -7232,9 +7232,6 @@ Choose {1} to exit this preview without starting migration and continue using th OpenClaw could not safely validate the previous setup for migration. No completion was recorded and the previous app was not changed. Close this preview and try again later. Do not uninstall the previous app, delete migration data, or run gateway cleanup. - - OpenClaw could not resolve a credential for the active gateway. No completion was recorded and the previous app was not changed. Close this preview and restore the active gateway configuration before retrying. Do not uninstall the previous app, delete migration data, or run gateway cleanup. - OpenClaw recorded migration completion and preserved your setup. Close this preview, uninstall the previous OpenClaw Companion app through Windows Settings, then open the Store version. Do not delete migration data or run gateway cleanup. @@ -7262,6 +7259,143 @@ Choose {1} to exit this preview without starting migration and continue using th A previous migration needs recovery or finalization. This test build cannot continue it or start normally. Close this preview. Do not uninstall the previous app, delete migration data, or run gateway cleanup. + + Move to the Store version + + + Keep your settings, gateway, files, and models where they are. + + + We'll prepare your setup + + + We'll safely close the previous app, check your installation and settings, and save protected migration records. + + + You'll remove the previous app + + + When prompted, uninstall it manually. Nothing is uninstalled automatically. + + + Then use the Store version + + + We verify removal before starting the Store version. + + + Choose Not now to leave without starting migration. The Store version stays closed while the previous app is installed. + + + Before you continue + + + If validation succeeds, the previous app can no longer start normally. + + + Asking the previous app to close safely... + + + Validating your existing setup... + + + Recording migration completion... + + + Checking removal and finishing migration... + + + Close OpenClaw Companion from its tray menu, then choose Retry. + + + Close the previous app + + + Don't uninstall yet + + + Don't force-close or uninstall it yet. We'll tell you when it's safe to uninstall. + + + Your setup has been validated and migration completion recorded. + +1. Open Installed apps and uninstall only the previous OpenClaw Companion app. Keep the Store version. + +2. Return here and choose Retry to verify removal and finish. + +Your settings, gateway, files, and models stay in place. + + + Remove the previous app + + + Keep your setup + + + Don't delete migration data or run gateway cleanup. + + + OpenClaw could not safely complete migration. Do not uninstall the previous app or run gateway cleanup. Check the app log, resolve the problem, and retry. + + + Update the previous app to a supported migration-capable version first. Your setup has not been changed. This app stays inactive while the previous app is installed. + + + This installation cannot be migrated safely. Only the default installation for the same Windows user and matching architecture is supported. Your setup has not been changed. This app stays inactive while the previous app is installed. + + + OpenClaw could not safely inspect the installation or migration records. Check the app log before retrying. Do not delete migration data or run gateway cleanup. + + + This migration needs recovery. Check the app log. If the previous app is still installed, remove it from Installed apps and select Retry. Do not delete migration data by hand or run gateway cleanup. + + + OpenClaw could not finish migration. The completion receipt was kept for recovery. Check the app log and retry. Do not delete migration data or run gateway cleanup. + + + Open Installed apps + + + Discard unreadable migration data + + + OpenClaw could not discard the migration data. If the previous OpenClaw app is running, close it and try again. Check the app log for details. + + + Windows could not open the requested page. Try again, or open Windows Settings > Apps > Installed apps manually if migration is complete. + + + Recommended + + + Move to the Microsoft Store version + + + Get future OpenClaw updates through Microsoft Store. Your existing OpenClaw setup will be preserved. + + + Install Store version and migrate + + + Move to the Store version? + + + Microsoft Store opens so you can install the Store version. When you open it, it can close this app and finish the move without asking again. + +Your settings, gateway, files, and models stay where they are. + + + Migration consent was saved. Install and open the Store version to continue. Keep this app installed until migration completion is confirmed. + + + OpenClaw could not save migration consent or open the Store listing. Check the app log and retry. Do not uninstall this app. + + + Close + + + Please wait for this step to finish. + Copy code Code block Current model diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index d563c5136..c9a0a9c96 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 @@ - + @@ -4289,7 +4289,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Port local - Enregistrer &amp; connecter + Enregistrer & connecter Annuler @@ -4550,7 +4550,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Confidentialité Windows - Accès caméra, microphone &amp; écran + Accès caméra, microphone & écran Les autorisations de confidentialité au niveau système sont gérées par Windows. Ouvrez les Paramètres de confidentialité Windows pour accorder ou révoquer l'accès à OpenClaw. @@ -4628,7 +4628,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Lecture seule - Lecture &amp; écriture + Lecture & écriture Téléchargements @@ -4640,7 +4640,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Lecture seule - Lecture &amp; écriture + Lecture & écriture Bureau @@ -4652,7 +4652,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Lecture seule - Lecture &amp; écriture + Lecture & écriture Les répertoires d'outils de votre PATH bénéficient également d'un accès en lecture seule dans le bac à sable, afin que les outils en ligne de commande comme git, node, python et dotnet soient utilisables. Les racines de lecteur ne sont jamais accordées. @@ -4670,7 +4670,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Lecture seule - Lecture &amp; écriture + Lecture & écriture Aucun dossier personnalisé ajouté. @@ -4694,7 +4694,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Écriture: l'agent peut remplacer votre presse-papiers (sans pouvoir le lire) - Lecture &amp; écriture + Lecture & écriture 📡 Réseau @@ -4992,7 +4992,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Installation MSIX détectée - La suppression d'OpenClaw via Paramètres Windows &amp;#x2192; Applications ne nettoiera PAS la passerelle locale (distribution WSL, image disque). Utilisez d'abord Supprimer la passerelle locale ci-dessous, puis désinstallez OpenClaw. + La suppression d'OpenClaw via Paramètres Windows → Applications ne nettoiera PAS la passerelle locale (distribution WSL, image disque). Utilisez d'abord Supprimer la passerelle locale ci-dessous, puis désinstallez OpenClaw. Supprime la distribution WSL (OpenClawGateway), son image disque, l'entrée de démarrage automatique et efface les identifiants de la passerelle. Votre jeton MCP est conservé. L'intégration sera réinitialisée. @@ -7168,9 +7168,6 @@ Choisissez {1} pour quitter cet aperçu sans démarrer la migration et continuer OpenClaw n'a pas pu valider en toute sécurité la configuration précédente pour la migration. Aucune finalisation n'a été enregistrée et l'application précédente n'a pas été modifiée. Fermez cet aperçu et réessayez plus tard. Ne désinstallez pas l'application précédente, ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. - - OpenClaw n'a pas pu résoudre un identifiant pour la passerelle active. Aucune finalisation n'a été enregistrée et l'application précédente n'a pas été modifiée. Fermez cet aperçu et restaurez la configuration de la passerelle active avant de réessayer. Ne désinstallez pas l'application précédente, ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. - OpenClaw a enregistré la fin de la migration et préservé votre configuration. Fermez cet aperçu, désinstallez l'application OpenClaw Companion précédente dans les paramètres Windows, puis ouvrez la version du Store. Ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. @@ -7198,6 +7195,143 @@ Choisissez {1} pour quitter cet aperçu sans démarrer la migration et continuer Une migration précédente nécessite une récupération ou une finalisation. Cette version de test ne peut ni la poursuivre ni démarrer normalement. Fermez cet aperçu. Ne désinstallez pas l'application précédente, ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + Passer à la version du Store + + + Gardez vos paramètres, votre passerelle, vos fichiers et vos modèles en place. + + + Nous préparerons votre configuration + + + Nous fermerons l'application précédente en toute sécurité, vérifierons votre installation et vos paramètres, puis enregistrerons des données de migration protégées. + + + Vous désinstallerez l'application précédente + + + Lorsque vous y serez invité, désinstallez-la manuellement. Rien ne sera désinstallé automatiquement. + + + Utilisez ensuite la version du Store + + + Nous vérifions la désinstallation avant de démarrer la version du Store. + + + Choisissez Pas maintenant pour quitter sans démarrer la migration. La version Store reste fermée tant que l'application précédente est installée. + + + Avant de continuer + + + Si la validation réussit, l'application précédente ne pourra plus démarrer normalement. + + + Demande de fermeture sécurisée de l'application précédente... + + + Validation de votre configuration existante... + + + Enregistrement de la fin de la migration... + + + Vérification de la désinstallation et finalisation de la migration... + + + Fermez OpenClaw Companion depuis son menu dans la zone de notification, puis choisissez Réessayer. + + + Fermez l'application précédente + + + Ne désinstallez pas encore + + + Ne forcez pas sa fermeture et ne la désinstallez pas encore. Nous vous indiquerons quand vous pourrez la désinstaller en toute sécurité. + + + Votre configuration a été validée et la fin de la migration enregistrée. + +1. Ouvrez Applications installées et désinstallez uniquement l'application OpenClaw Companion précédente. Gardez la version du Store. + +2. Revenez ici et choisissez Réessayer pour vérifier la désinstallation et terminer. + +Vos paramètres, votre passerelle, vos fichiers et vos modèles restent en place. + + + Désinstallez l'application précédente + + + Conservez votre configuration + + + Ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + + OpenClaw n'a pas pu terminer la migration en toute sécurité. Ne désinstallez pas l'application précédente et ne lancez pas le nettoyage de la passerelle. Consultez le journal de l'application, résolvez le problème et réessayez. + + + Mettez d'abord l'application précédente à jour vers une version compatible prenant en charge la migration. Votre configuration n'a pas été modifiée. Cette application reste inactive tant que l'application précédente est installée. + + + Cette installation ne peut pas être migrée en toute sécurité. Seule l'installation par défaut pour le même utilisateur Windows et avec une architecture correspondante est prise en charge. Votre configuration n'a pas été modifiée. Cette application reste inactive tant que l'application précédente est installée. + + + OpenClaw n'a pas pu vérifier en toute sécurité l'installation ou les enregistrements de migration. Consultez le journal de l'application avant de réessayer. Ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + + Cette migration nécessite une récupération. Consultez le journal de l'application. Si l'application précédente est toujours installée, supprimez-la dans Applications installées, puis sélectionnez Réessayer. Ne supprimez pas les données de migration manuellement et ne lancez pas le nettoyage de la passerelle. + + + OpenClaw n'a pas pu terminer la migration. Le reçu de fin de migration a été conservé pour permettre une récupération. Consultez le journal de l'application et réessayez. Ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + + Ouvrir Applications installées + + + Supprimer les données de migration illisibles + + + OpenClaw n'a pas pu supprimer les données de migration. Si l'application OpenClaw précédente est en cours d'exécution, fermez-la et réessayez. Consultez le journal de l'application pour plus de détails. + + + Windows n'a pas pu ouvrir la page demandée. Réessayez ou ouvrez manuellement Paramètres Windows > Applications > Applications installées si la migration est terminée. + + + Recommandé + + + Passer à la version du Microsoft Store + + + Recevez les prochaines mises à jour d'OpenClaw via le Microsoft Store. Votre configuration OpenClaw existante sera conservée. + + + Installer la version du Store et migrer + + + Passer à la version du Store ? + + + Le Microsoft Store s'ouvre pour vous permettre d'installer la version du Store. Lorsque vous l'ouvrirez, elle pourra fermer cette application et terminer la migration sans redemander votre accord. + +Vos paramètres, votre passerelle, vos fichiers et vos modèles restent en place. + + + Le consentement à la migration a été enregistré. Installez et ouvrez la version du Store pour continuer. Gardez cette application installée jusqu'à la confirmation de la fin de la migration. + + + OpenClaw n'a pas pu enregistrer le consentement à la migration ou ouvrir la fiche du Store. Consultez le journal de l'application et réessayez. Ne désinstallez pas cette application. + + + Fermer + + + Veuillez patienter jusqu'à la fin de cette étape. + Copier le code Bloc de code Modèle actuel diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index c25d699d3..0a74e43c6 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 @@ - + @@ -4290,7 +4290,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Lokale poort - Opslaan &amp; verbinden + Opslaan & verbinden Annuleren @@ -4551,7 +4551,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Windows-privacy - Toegang tot camera, microfoon &amp; scherm + Toegang tot camera, microfoon & scherm Privacymachtigingen op systeemniveau worden beheerd door Windows. Open Windows-privacyinstellingen om OpenClaw toegang te verlenen of in te trekken. @@ -4629,7 +4629,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Alleen-lezen - Lezen &amp; schrijven + Lezen & schrijven Downloadmap @@ -4641,7 +4641,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Alleen-lezen - Lezen &amp; schrijven + Lezen & schrijven Bureaublad @@ -4653,7 +4653,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Alleen-lezen - Lezen &amp; schrijven + Lezen & schrijven Mapdirectory's op uw PATH krijgen ook alleen-lezen toegang binnen de sandbox, zodat opdrachtregelhulpprogramma's zoals git, node, python en dotnet bruikbaar zijn. Stationswortels worden nooit toegekend. @@ -4671,7 +4671,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Alleen-lezen - Lezen &amp; schrijven + Lezen & schrijven Geen aangepaste mappen toegevoegd. @@ -4695,7 +4695,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Schrijven: agent kan uw klembord vervangen (kan het niet lezen) - Lezen &amp; schrijven + Lezen & schrijven 📡 Netwerk @@ -4993,7 +4993,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik MSIX-installatie gedetecteerd - OpenClaw verwijderen via Windows Instellingen &amp;#x2192; Apps zal de lokale gateway NIET opruimen (WSL-distributie, schijfafbeelding). Gebruik EERST Lokale gateway verwijderen hieronder, en verwijder daarna OpenClaw. + OpenClaw verwijderen via Windows Instellingen → Apps zal de lokale gateway NIET opruimen (WSL-distributie, schijfafbeelding). Gebruik EERST Lokale gateway verwijderen hieronder, en verwijder daarna OpenClaw. Verwijdert de WSL-distributie (OpenClawGateway), de schijfafbeelding, het item voor automatisch starten en wist de gateway-referenties. Uw MCP-token blijft behouden. Onboarding wordt gereset. @@ -7169,9 +7169,6 @@ Kies {1} om dit voorbeeld te sluiten zonder de migratie te starten en de vorige OpenClaw kon de vorige configuratie niet veilig valideren voor migratie. Er is geen voltooiing vastgelegd en de vorige app is niet gewijzigd. Sluit dit voorbeeld en probeer het later opnieuw. Verwijder de vorige app of migratiegegevens niet en voer geen gatewayopruiming uit. - - OpenClaw kon geen referentie voor de actieve gateway oplossen. Er is geen voltooiing vastgelegd en de vorige app is niet gewijzigd. Sluit dit voorbeeld en herstel de configuratie van de actieve gateway voordat u het opnieuw probeert. Verwijder de vorige app of migratiegegevens niet en voer geen gatewayopruiming uit. - OpenClaw heeft de voltooiing van de migratie vastgelegd en uw configuratie behouden. Sluit dit voorbeeld, verwijder de vorige OpenClaw Companion-app via Windows-instellingen en open daarna de Store-versie. Verwijder geen migratiegegevens en voer geen gatewayopruiming uit. @@ -7199,6 +7196,143 @@ Kies {1} om dit voorbeeld te sluiten zonder de migratie te starten en de vorige Een vorige migratie moet worden hersteld of afgerond. Deze testversie kan die migratie niet voortzetten of normaal starten. Sluit dit voorbeeld. Verwijder de vorige app of migratiegegevens niet en voer geen gatewayopruiming uit. + + Overstappen naar de Store-versie + + + Behoud uw instellingen, gateway, bestanden en modellen op hun huidige plek. + + + We bereiden uw configuratie voor + + + We sluiten de vorige app veilig af, controleren uw installatie en instellingen en slaan beveiligde migratiegegevens op. + + + U verwijdert de vorige app + + + Verwijder de app handmatig wanneer daarom wordt gevraagd. Er wordt niets automatisch verwijderd. + + + Gebruik daarna de Store-versie + + + We verifiëren de verwijdering voordat we de Store-versie starten. + + + Kies Nu niet om af te sluiten zonder de migratie te starten. De Store-versie blijft gesloten zolang de vorige app is geïnstalleerd. + + + Voordat u doorgaat + + + Als de validatie slaagt, kan de vorige app niet meer normaal starten. + + + De vorige app wordt gevraagd veilig af te sluiten... + + + Uw bestaande configuratie wordt gevalideerd... + + + De voltooiing van de migratie wordt vastgelegd... + + + De verwijdering wordt gecontroleerd en de migratie wordt afgerond... + + + Sluit OpenClaw Companion via het menu in het systeemvak en kies daarna Opnieuw proberen. + + + Sluit de vorige app + + + Verwijder de app nog niet + + + Forceer het afsluiten niet en verwijder de app nog niet. We laten u weten wanneer u de app veilig kunt verwijderen. + + + Uw configuratie is gevalideerd en de voltooiing van de migratie is vastgelegd. + +1. Open Geïnstalleerde apps en verwijder alleen de vorige OpenClaw Companion-app. Behoud de Store-versie. + +2. Keer hier terug en kies Opnieuw proberen om de verwijdering te verifiëren en af te ronden. + +Uw instellingen, gateway, bestanden en modellen blijven op hun plaats. + + + Verwijder de vorige app + + + Behoud uw configuratie + + + Verwijder geen migratiegegevens en voer geen gatewayopruiming uit. + + + OpenClaw kon de migratie niet veilig voltooien. Verwijder de vorige app niet en voer geen gatewayopruiming uit. Bekijk het applogboek, los het probleem op en probeer het opnieuw. + + + Werk de vorige app eerst bij naar een ondersteunde versie met migratieondersteuning. Uw configuratie is niet gewijzigd. Deze app blijft inactief zolang de vorige app is geïnstalleerd. + + + Deze installatie kan niet veilig worden gemigreerd. Alleen de standaardinstallatie voor dezelfde Windows-gebruiker met een overeenkomende architectuur wordt ondersteund. Uw configuratie is niet gewijzigd. Deze app blijft inactief zolang de vorige app is geïnstalleerd. + + + OpenClaw kon de installatie of migratierecords niet veilig controleren. Bekijk het applogboek voordat u het opnieuw probeert. Verwijder geen migratiegegevens en voer geen gatewayopruiming uit. + + + Deze migratie moet worden hersteld. Bekijk het applogboek. Als de vorige app nog is geïnstalleerd, verwijder deze via Geïnstalleerde apps en kies Opnieuw proberen. Verwijder migratiegegevens niet handmatig en voer geen gatewayopruiming uit. + + + OpenClaw kon de migratie niet afronden. Het voltooiingsbewijs is bewaard voor herstel. Bekijk het applogboek en probeer het opnieuw. Verwijder geen migratiegegevens en voer geen gatewayopruiming uit. + + + Geïnstalleerde apps openen + + + Onleesbare migratiegegevens verwijderen + + + OpenClaw kon de migratiegegevens niet verwijderen. Als de vorige OpenClaw-app actief is, sluit deze dan en probeer het opnieuw. Bekijk het applogboek voor meer informatie. + + + Windows kon de gevraagde pagina niet openen. Probeer het opnieuw of open Windows-instellingen > Apps > Geïnstalleerde apps handmatig als de migratie is voltooid. + + + Aanbevolen + + + Overstappen naar de Microsoft Store-versie + + + Ontvang toekomstige OpenClaw-updates via de Microsoft Store. Uw bestaande OpenClaw-configuratie blijft behouden. + + + Store-versie installeren en migreren + + + Overstappen naar de Store-versie? + + + De Microsoft Store wordt geopend zodat u de Store-versie kunt installeren. Wanneer u die opent, kan deze app worden gesloten en de overstap worden voltooid zonder opnieuw om toestemming te vragen. + +Uw instellingen, gateway, bestanden en modellen blijven op hun plaats. + + + De toestemming voor migratie is opgeslagen. Installeer en open de Store-versie om door te gaan. Laat deze app geïnstalleerd totdat de voltooiing van de migratie is bevestigd. + + + OpenClaw kon de toestemming voor migratie niet opslaan of de Store-pagina niet openen. Bekijk het applogboek en probeer het opnieuw. Verwijder deze app niet. + + + Sluiten + + + Wacht totdat deze stap is voltooid. + Code kopiëren Codeblok Huidig model diff --git a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw index 9d7b57c76..a856b863f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -1,4 +1,4 @@ - + @@ -4971,7 +4971,7 @@ Os controles de arquivo, rede, área de transferência, tempo limite e aprovaç Instalação MSIX detectada - Remover o OpenClaw pelas Configurações do Windows &#x2192; Aplicativos NÃO removerá o Gateway local (distro WSL, imagem de disco). Use Remover Gateway local abaixo PRIMEIRO e só depois desinstale o OpenClaw. + Remover o OpenClaw pelas Configurações do Windows → Aplicativos NÃO removerá o Gateway local (distro WSL, imagem de disco). Use Remover Gateway local abaixo PRIMEIRO e só depois desinstale o OpenClaw. Remove a distro WSL (OpenClawGateway), sua imagem de disco, a entrada de inicialização automática, e apaga as credenciais do Gateway. Seu token MCP é preservado. A integração será reiniciada. @@ -7222,9 +7222,6 @@ Escolha {1} para sair desta prévia sem iniciar a migração e continuar usando O OpenClaw não pôde validar com segurança a configuração anterior para a migração. Nenhuma conclusão foi registrada e o aplicativo anterior não foi alterado. Feche esta prévia e tente novamente mais tarde. Não desinstale o aplicativo anterior, não exclua os dados de migração nem execute a limpeza do gateway. - - O OpenClaw não conseguiu resolver uma credencial para o gateway ativo. Nenhuma conclusão foi registrada e o aplicativo anterior não foi alterado. Feche esta prévia e restaure a configuração do gateway ativo antes de tentar novamente. Não desinstale o aplicativo anterior, não exclua os dados de migração nem execute a limpeza do gateway. - O OpenClaw registrou a conclusão da migração e preservou sua configuração. Feche esta prévia, desinstale o aplicativo OpenClaw Companion anterior nas Configurações do Windows e abra a versão da Store. Não exclua os dados de migração nem execute a limpeza do gateway. @@ -7252,6 +7249,143 @@ Escolha {1} para sair desta prévia sem iniciar a migração e continuar usando Uma migração anterior precisa de recuperação ou finalização. Esta versão de teste não pode continuá-la nem iniciar normalmente. Feche esta prévia. Não desinstale o aplicativo anterior, não exclua os dados de migração nem execute a limpeza do gateway. + + Mudar para a versão da Store + + + Mantenha suas configurações, gateway, arquivos e modelos onde estão. + + + Vamos preparar sua configuração + + + Vamos fechar o aplicativo anterior com segurança, verificar sua instalação e configurações e salvar registros protegidos de migração. + + + Você removerá o aplicativo anterior + + + Quando solicitado, desinstale-o manualmente. Nada será desinstalado automaticamente. + + + Depois, use a versão da Store + + + Verificamos a remoção antes de iniciar a versão da Store. + + + Selecione Agora não para sair sem iniciar a migração. A versão da Store permanece fechada enquanto o aplicativo anterior estiver instalado. + + + Antes de continuar + + + Se a validação for bem-sucedida, o aplicativo anterior não poderá mais iniciar normalmente. + + + Solicitando o encerramento seguro do aplicativo anterior... + + + Validando sua configuração existente... + + + Registrando a conclusão da migração... + + + Verificando a desinstalação e finalizando a migração... + + + Feche o OpenClaw Companion pelo menu na bandeja do sistema e selecione Tentar novamente. + + + Feche o aplicativo anterior + + + Não desinstale ainda + + + Não force o encerramento nem desinstale o aplicativo ainda. Avisaremos quando for seguro desinstalar. + + + Sua configuração foi validada e a conclusão da migração foi registrada. + +1. Abra Aplicativos instalados e desinstale apenas o aplicativo OpenClaw Companion anterior. Mantenha a versão da Store. + +2. Volte aqui e selecione Tentar novamente para verificar a remoção e finalizar. + +Suas configurações, gateway, arquivos e modelos permanecem no mesmo local. + + + Remova o aplicativo anterior + + + Mantenha sua configuração + + + Não exclua os dados de migração nem execute a limpeza do gateway. + + + O OpenClaw não conseguiu concluir a migração com segurança. Não desinstale o aplicativo anterior nem execute a limpeza do gateway. Consulte o log do aplicativo, resolva o problema e tente novamente. + + + Primeiro, atualize o aplicativo anterior para uma versão compatível com suporte à migração. Sua configuração não foi alterada. Este aplicativo permanece inativo enquanto o aplicativo anterior estiver instalado. + + + Esta instalação não pode ser migrada com segurança. Apenas a instalação padrão para o mesmo usuário do Windows e com arquitetura correspondente é compatível. Sua configuração não foi alterada. Este aplicativo permanece inativo enquanto o aplicativo anterior estiver instalado. + + + O OpenClaw não conseguiu verificar com segurança a instalação ou os registros de migração. Consulte o log do aplicativo antes de tentar novamente. Não exclua os dados de migração nem execute a limpeza do gateway. + + + Esta migração precisa de recuperação. Consulte o log do aplicativo. Se o aplicativo anterior ainda estiver instalado, remova-o em Aplicativos instalados e selecione Tentar novamente. Não exclua os dados de migração manualmente nem execute a limpeza do gateway. + + + O OpenClaw não conseguiu finalizar a migração. O recibo de conclusão foi preservado para recuperação. Consulte o log do aplicativo e tente novamente. Não exclua os dados de migração nem execute a limpeza do gateway. + + + Abrir Aplicativos instalados + + + Descartar dados de migração ilegíveis + + + O OpenClaw não conseguiu descartar os dados de migração. Se o aplicativo OpenClaw anterior estiver em execução, feche-o e tente novamente. Consulte o log do aplicativo para obter detalhes. + + + O Windows não conseguiu abrir a página solicitada. Tente novamente ou abra Configurações do Windows > Aplicativos > Aplicativos instalados manualmente se a migração estiver concluída. + + + Recomendado + + + Mudar para a versão da Microsoft Store + + + Receba futuras atualizações do OpenClaw pela Microsoft Store. Sua configuração atual do OpenClaw será preservada. + + + Instalar a versão da Store e migrar + + + Mudar para a versão da Store? + + + A Microsoft Store será aberta para você instalar a versão da Store. Quando você abri-la, ela poderá fechar este aplicativo e concluir a mudança sem pedir autorização novamente. + +Suas configurações, gateway, arquivos e modelos permanecem onde estão. + + + O consentimento para migração foi salvo. Instale e abra a versão da Store para continuar. Mantenha este aplicativo instalado até que a conclusão da migração seja confirmada. + + + O OpenClaw não conseguiu salvar o consentimento para migração ou abrir a página do aplicativo na Store. Consulte o log do aplicativo e tente novamente. Não desinstale este aplicativo. + + + Fechar + + + Aguarde a conclusão desta etapa. + Copiar código Bloco de código Modelo atual diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 01cdc8f1a..aa88dcabe 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 @@ - + @@ -4992,7 +4992,7 @@ 检测到 MSIX 安装 - 通过 Windows 设置 &amp;#x2192; 应用删除 OpenClaw 不会清理本地网关(WSL 发行版、磁盘映像)。请先使用下面的“删除本地网关”,然后再卸载 OpenClaw。 + 通过 Windows 设置 → 应用删除 OpenClaw 不会清理本地网关(WSL 发行版、磁盘映像)。请先使用下面的“删除本地网关”,然后再卸载 OpenClaw。 删除 WSL 发行版 (OpenClawGateway)、其磁盘映像、自动启动项,并清除网关凭据。您的 MCP 令牌将被保留。引导将重置。 @@ -7168,9 +7168,6 @@ OpenClaw 无法安全验证旧版配置是否可迁移。未记录完成状态,旧版应用也未被更改。请关闭此预览,稍后重试。请勿卸载旧版应用、删除迁移数据或运行网关清理。 - - OpenClaw 无法解析活动网关的凭据。未记录完成状态,旧版应用也未被更改。请关闭此预览,恢复活动网关配置后再重试。请勿卸载旧版应用、删除迁移数据或运行网关清理。 - OpenClaw 已记录迁移完成并保留了您的配置。请关闭此预览,通过 Windows 设置卸载旧版 OpenClaw Companion 应用,然后打开 Store 版本。请勿删除迁移数据或运行网关清理。 @@ -7198,6 +7195,143 @@ 之前的迁移需要恢复或完成。此测试版本无法继续该迁移或正常启动。请关闭此预览。请勿卸载旧版应用、删除迁移数据或运行网关清理。 + + 迁移到 Store 版本 + + + 将您的设置、网关、文件和模型保留在原处。 + + + 我们将准备您的配置 + + + 我们将安全关闭旧版应用,检查您的安装和设置,并保存受保护的迁移记录。 + + + 您将卸载旧版应用 + + + 收到提示后,请手动卸载。不会自动卸载任何应用。 + + + 然后使用 Store 版本 + + + 我们会验证移除状态后再启动 Store 版本。 + + + 选择“暂不”即可退出,不会开始迁移。旧版应用仍安装时,商店版本不会启动。 + + + 继续之前 + + + 如果验证成功,旧版应用将无法正常启动。 + + + 正在请求旧版应用安全关闭... + + + 正在验证您的现有配置... + + + 正在记录迁移完成状态... + + + 正在检查卸载状态并完成迁移... + + + 请通过托盘菜单关闭 OpenClaw Companion,然后选择“重试”。 + + + 关闭旧版应用 + + + 暂时不要卸载 + + + 暂时不要强制关闭或卸载该应用。我们会在可以安全卸载时通知您。 + + + 您的配置已通过验证,迁移完成状态已记录。 + +1. 打开“已安装的应用”,仅卸载旧版 OpenClaw Companion 应用。请保留 Store 版本。 + +2. 返回此处并选择“重试”以验证移除状态并完成迁移。 + +您的设置、网关、文件和模型将保留在原处。 + + + 卸载旧版应用 + + + 保留您的配置 + + + 请勿删除迁移数据或运行网关清理。 + + + OpenClaw 无法安全完成迁移。请勿卸载旧版应用或运行网关清理。请查看应用日志,解决问题后重试。 + + + 请先将旧版应用更新到受支持且具备迁移功能的版本。您的配置未被更改。在旧版应用仍安装的情况下,此应用将保持未激活状态。 + + + 无法安全迁移此安装。仅支持同一 Windows 用户且体系结构匹配的默认安装。您的配置未被更改。在旧版应用仍安装的情况下,此应用将保持未激活状态。 + + + OpenClaw 无法安全检查安装或迁移记录。请先查看应用日志再重试。请勿删除迁移数据或运行网关清理。 + + + 此迁移需要恢复。请查看应用日志。如果旧版应用仍已安装,请在“已安装的应用”中将其卸载,然后选择“重试”。请勿手动删除迁移数据或运行网关清理。 + + + OpenClaw 无法完成迁移。完成收据已保留以供恢复。请查看应用日志并重试。请勿删除迁移数据或运行网关清理。 + + + 打开“已安装的应用” + + + 放弃无法读取的迁移数据 + + + OpenClaw 无法放弃迁移数据。如果旧版 OpenClaw 应用正在运行,请关闭它并重试。详情请查看应用日志。 + + + Windows 无法打开请求的页面。请重试,或在迁移完成后手动打开 Windows 设置 > 应用 > 已安装的应用。 + + + 推荐 + + + 迁移到 Microsoft Store 版本 + + + 通过 Microsoft Store 获取 OpenClaw 的后续更新。您现有的 OpenClaw 配置将会保留。 + + + 安装 Store 版本并迁移 + + + 迁移到 Store 版本? + + + 将打开 Microsoft Store,供您安装 Store 版本。您打开该版本后,它可以关闭此应用并完成迁移,无需再次征求您的同意。 + +您的设置、网关、文件和模型将保留在原处。 + + + 已保存迁移授权。请安装并打开 Store 版本以继续。在确认迁移完成之前,请保留此应用。 + + + OpenClaw 无法保存迁移授权或打开 Store 中的应用页面。请查看应用日志并重试。请勿卸载此应用。 + + + 关闭 + + + 请等待此步骤完成。 + 复制代码 代码块 当前模型 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 6f4f74b00..e472718da 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 @@ - + @@ -4992,7 +4992,7 @@ 偵測到 MSIX 安裝 - 透過 Windows 設定 &amp;#x2192; 應用程式移除 OpenClaw 不會清除本機閘道(WSL 散發版、磁碟映像)。請先使用下方的「移除本機閘道」,然後再解除安裝 OpenClaw。 + 透過 Windows 設定 → 應用程式移除 OpenClaw 不會清除本機閘道(WSL 散發版、磁碟映像)。請先使用下方的「移除本機閘道」,然後再解除安裝 OpenClaw。 移除 WSL 散發版 (OpenClawGateway)、其磁碟映像、自動啟動項目,並清除閘道認證。您的 MCP 權杖會保留。導覽流程將重設。 @@ -7168,9 +7168,6 @@ OpenClaw 無法安全驗證舊版設定是否可移轉。未記錄完成狀態,舊版應用程式也未被變更。請關閉此預覽,稍後再試。請勿解除安裝舊版應用程式、刪除移轉資料或執行閘道清理。 - - OpenClaw 無法解析作用中閘道的認證。未記錄完成狀態,舊版應用程式也未被變更。請關閉此預覽,還原作用中閘道設定後再試。請勿解除安裝舊版應用程式、刪除移轉資料或執行閘道清理。 - OpenClaw 已記錄移轉完成並保留您的設定。請關閉此預覽,透過 Windows 設定解除安裝舊版 OpenClaw Companion 應用程式,然後開啟 Store 版本。請勿刪除移轉資料或執行閘道清理。 @@ -7198,6 +7195,143 @@ 先前的移轉需要復原或完成。此測試版本無法繼續該移轉或正常啟動。請關閉此預覽。請勿解除安裝舊版應用程式、刪除移轉資料或執行閘道清理。 + + 移轉至 Store 版本 + + + 將您的設定、閘道、檔案和模型保留在原處。 + + + 我們將準備您的設定 + + + 我們將安全關閉舊版應用程式,檢查您的安裝和設定,並儲存受保護的移轉記錄。 + + + 您將解除安裝舊版應用程式 + + + 收到提示後,請手動解除安裝。不會自動解除安裝任何應用程式。 + + + 然後使用 Store 版本 + + + 我們會驗證移除狀態後再啟動 Store 版本。 + + + 選擇「現在不要」即可離開,不會開始移轉。舊版應用程式仍安裝時,商店版本不會啟動。 + + + 繼續之前 + + + 如果驗證成功,舊版應用程式將無法正常啟動。 + + + 正在要求舊版應用程式安全關閉... + + + 正在驗證您的現有設定... + + + 正在記錄移轉完成狀態... + + + 正在檢查解除安裝狀態並完成移轉... + + + 請透過系統匣選單關閉 OpenClaw Companion,然後選擇「重試」。 + + + 關閉舊版應用程式 + + + 暫時不要解除安裝 + + + 暫時不要強制關閉或解除安裝該應用程式。我們會在可以安全解除安裝時通知您。 + + + 您的設定已通過驗證,移轉完成狀態已記錄。 + +1. 開啟「已安裝的應用程式」,僅解除安裝舊版 OpenClaw Companion 應用程式。請保留 Store 版本。 + +2. 返回此處並選擇「重試」以驗證移除狀態並完成移轉。 + +您的設定、閘道、檔案和模型將保留在原處。 + + + 解除安裝舊版應用程式 + + + 保留您的設定 + + + 請勿刪除移轉資料或執行閘道清理。 + + + OpenClaw 無法安全完成移轉。請勿解除安裝舊版應用程式或執行閘道清理。請查看應用程式記錄,解決問題後重試。 + + + 請先將舊版應用程式更新至受支援且具備移轉功能的版本。您的設定未被變更。在舊版應用程式仍安裝的情況下,此應用程式將保持未啟用狀態。 + + + 無法安全移轉此安裝。僅支援相同 Windows 使用者且架構相符的預設安裝。您的設定未被變更。在舊版應用程式仍安裝的情況下,此應用程式將保持未啟用狀態。 + + + OpenClaw 無法安全檢查安裝或移轉記錄。請先查看應用程式記錄再重試。請勿刪除移轉資料或執行閘道清理。 + + + 此移轉需要復原。請查看應用程式記錄。如果舊版應用程式仍已安裝,請在「已安裝的應用程式」中將其解除安裝,然後選擇「重試」。請勿手動刪除移轉資料或執行閘道清理。 + + + OpenClaw 無法完成移轉。完成收據已保留以供復原。請查看應用程式記錄並重試。請勿刪除移轉資料或執行閘道清理。 + + + 開啟「已安裝的應用程式」 + + + 捨棄無法讀取的移轉資料 + + + OpenClaw 無法捨棄移轉資料。如果舊版 OpenClaw 應用程式正在執行,請關閉它並重試。詳情請查看應用程式記錄。 + + + Windows 無法開啟要求的頁面。請再試一次,或在移轉完成後手動開啟 Windows 設定 > 應用程式 > 已安裝的應用程式。 + + + 建議 + + + 移轉至 Microsoft Store 版本 + + + 透過 Microsoft Store 取得 OpenClaw 的後續更新。您現有的 OpenClaw 設定將會保留。 + + + 安裝 Store 版本並移轉 + + + 移轉至 Store 版本? + + + 將開啟 Microsoft Store,讓您安裝 Store 版本。您開啟該版本後,它可以關閉此應用程式並完成移轉,無需再次徵求您的同意。 + +您的設定、閘道、檔案和模型將保留在原處。 + + + 已儲存移轉授權。請安裝並開啟 Store 版本以繼續。在確認移轉完成之前,請保留此應用程式。 + + + OpenClaw 無法儲存移轉授權或開啟 Store 中的應用程式頁面。請查看應用程式記錄並重試。請勿解除安裝此應用程式。 + + + 關閉 + + + 請等待此步驟完成。 + 複製程式碼 程式碼區塊 目前的模型 diff --git a/src/OpenClaw.Tray.WinUI/Windows/StoreMigrationWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/StoreMigrationWindow.xaml new file mode 100644 index 000000000..13073d151 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Windows/StoreMigrationWindow.xaml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [Fact] public void StartupRefusal_NotifiesTheUserWithoutBlockingLaunch() { - var helper = Read("src", "OpenClaw.Tray.WinUI", "Helpers", "StoreMigrationStartupGuard.cs"); - var branch = helper[helper.IndexOf("if (finalization.AllowsNormalStartup)", StringComparison.Ordinal)..]; - var notice = branch.IndexOf(@"ShowGuidance(""Migration_StoreStartupRefused"")", StringComparison.Ordinal); - Assert.True(notice > 0, "The refusal notice must be raised inside the allows-normal-startup branch."); - Assert.Contains( - "finalization.State == StoreMigrationFinalizationState.StartupPreferenceRefused", - branch[..notice]); - Assert.True(branch.IndexOf("return false;", StringComparison.Ordinal) > notice, - "The refusal notice must be followed by return false so startup still proceeds."); - Assert.DoesNotContain("StartupPreferenceFailed", helper); + var workflow = Read("src", "OpenClaw.Tray.WinUI", "Services", "StoreMigrationWorkflow.cs"); + var refusal = workflow.IndexOf( + "result.State == StoreMigrationFinalizationState.StartupPreferenceRefused", StringComparison.Ordinal); + Assert.True(refusal > 0, "Finalization must special-case a durable startup refusal."); + Assert.True(refusal < workflow.IndexOf("result.AllowsNormalStartup ?", StringComparison.Ordinal), + "The refusal must be handled before it is folded into Ready."); + Assert.Contains("StoreMigrationStage.Ready or StoreMigrationStage.StartupRefused", workflow); + + var window = Read("src", "OpenClaw.Tray.WinUI", "Windows", "StoreMigrationWindow.xaml.cs"); + Assert.Contains(@"LocalizationHelper.GetString(""Migration_StoreStartupRefused"")", window); + // A refused startup preference is the one state with nothing to retry, so it is now the + // only state that collapses the primary action. + Assert.Contains("_workflow.Stage is StoreMigrationStage.StartupRefused", window); + Assert.Contains("or StoreMigrationStage.StartupRefused ? Dismiss : Primary", window); } [Theory] @@ -239,17 +334,18 @@ string Value(string key) => resources.Root!.Elements("data") } [Fact] - public void StorePreviewChoices_UseNativeYesNoWithoutActionLegends() + public void StoreMigrationChoices_UseDirectButtonLabelsWithoutNativeActionLegends() { var helper = Read("src", "OpenClaw.Tray.WinUI", "Helpers", "StoreMigrationStartupGuard.cs"); - var choice = helper[helper.IndexOf("private static bool ShowChoice(", StringComparison.Ordinal).. - helper.IndexOf("private static void ShowGuidance(", StringComparison.Ordinal)]; - Assert.Contains("ShowChoice(string contentKey)", choice); - Assert.Contains("LocalizationHelper.Format(contentKey,", choice); - Assert.DoesNotContain("primaryKey", choice); - Assert.DoesNotContain("secondaryKey", choice); - Assert.DoesNotContain("\\r\\n", choice); - Assert.Contains("0x00000124) == 6", choice); + Assert.DoesNotContain("ShowChoice(", helper); + Assert.DoesNotContain("MessageBoxW", helper); + var window = Read("src", "OpenClaw.Tray.WinUI", "Windows", "StoreMigrationWindow.xaml.cs"); + Assert.Contains("Primary.Content = LocalizationHelper.GetString(", window); + Assert.Contains("Dismiss.Content = LocalizationHelper.GetString(", window); + Assert.Contains("\"Migration_StoreMigrate\" : \"Migration_StoreRetry\"", window); + Assert.Contains("\"Migration_StoreNotNow\" : \"Migration2_Close\"", window); + Assert.DoesNotContain("Migration_StoreYes", window); + Assert.DoesNotContain("Migration_StoreNo\"", window); } [Fact] @@ -265,6 +361,7 @@ public void CompletedMigrationGuard_PrecedesSettingsAndActivation() Assert.True(guard < launch.IndexOf("new SettingsManager()", StringComparison.Ordinal)); Assert.DoesNotContain("MigrationRecordCodec", app); var helper = Read("src", "OpenClaw.Tray.WinUI", "Helpers", "InnoMigrationStartupGuard.cs"); + Assert.Contains("#if !INNO_MIGRATION_PREVIEW && !PRODUCTION_MIGRATION", helper); Assert.Contains("AppIdentity.IsDev || PackageHelper.IsPackaged", helper); Assert.Contains("MigrationRecordCodec.ReadCompletion", helper); Assert.Contains("Migration_InnoCompleted", helper); @@ -274,6 +371,8 @@ public void CompletedMigrationGuard_PrecedesSettingsAndActivation() var lockFailure = helper[acquire..helper.IndexOf("var path =", StringComparison.Ordinal)]; Assert.Contains("InvalidDataException", lockFailure); Assert.Contains("return ShowGuidance(\"Migration_InnoCheckFailed\")", lockFailure); + var receiptRejected = helper[helper.IndexOf("Migration completion receipt rejected", StringComparison.Ordinal)..]; + Assert.Contains("return false;", receiptRejected); Assert.DoesNotContain("_innoMigrationLease", Read("src", "OpenClaw.Tray.WinUI", "App.AppShutdownCoordinator.cs")); Assert.DoesNotContain("_innoMigrationLease?.Dispose", app); } @@ -380,6 +479,44 @@ public void CleanupScript_RechecksReceiptBeforeAnyDestructiveWork() Assert.DoesNotContain("Test-InnoMigration", logger); } + [Fact] + public void CleanupScript_RefusesToDeleteThroughAReparsePoint() + { + var script = Read("scripts", "Uninstall-LocalGateway.ps1"); + var cleanup = script[script.IndexOf("$identityDir = Join-Path $gatewaysDir $id", StringComparison.Ordinal)..]; + var guard = cleanup.IndexOf("[System.IO.FileAttributes]::ReparsePoint", StringComparison.Ordinal); + var delete = cleanup.IndexOf("Remove-Item -LiteralPath $identityDir -Recurse", StringComparison.Ordinal); + + // Remove-Item -Recurse follows junctions on Windows PowerShell 5.1, so the guard has + // to run first or the recursive delete destroys whatever the junction targets. + Assert.True(guard > 0, "Identity cleanup must reject reparse points."); + Assert.True(guard < delete, "The reparse-point guard must precede the recursive delete."); + Assert.Contains(@"\A[A-Za-z0-9._-]+\z", cleanup); + + // Checking only the leaf is not enough: a junction at 'gateways' redirects the whole + // subtree while the identity directory itself still looks ordinary. + var walk = cleanup.IndexOf("$probe = $identityDir", StringComparison.Ordinal); + Assert.True(walk > 0 && walk < delete, "The guard must walk ancestors, not just the leaf."); + Assert.Contains("Split-Path -Path $probe -Parent", cleanup); + } + + [Fact] + public void Installer_StopsTheReparseWalkAtAUncShareRoot() + { + var installer = Read("installer.iss"); + var walk = installer[installer.IndexOf("function MigrationPathIsOrdinary", StringComparison.Ordinal).. + installer.IndexOf("function InitializeUninstall:", StringComparison.Ordinal)]; + + // ExtractFileDir has no fixed point on a UNC path: it yields '\\server', which is not + // a filesystem object. Probing it fails with a code that is neither 2 nor 3, so the + // guard would Exit False and refuse the uninstall on redirected AppData. + Assert.Contains("MigrationPathIsUncRoot", installer); + var stop = walk.IndexOf("if MigrationPathIsUncRoot(Path) then", StringComparison.Ordinal); + var ascend = walk.IndexOf("Parent := ExtractFileDir(Path);", StringComparison.Ordinal); + Assert.True(stop > 0, "The walk must recognise a UNC share root."); + Assert.True(stop < ascend, "The walk must stop at the share root before ascending past it."); + } + [Fact] public void CleanupScript_BoundsEveryChildProcessWait() { @@ -647,8 +784,7 @@ private static void AssertBoundedProcessHelper(string script) @"\$stderr = \$process\.StandardError\.ReadToEndAsync\(\)\s+\r?\n?\s*" + @"if \(-not \$process\.WaitForExit\(\$TimeoutMilliseconds\)\)", script); - Assert.Contains("ExitCode = [int]$process.ExitCode", script); - } + Assert.Contains("ExitCode = [int]$process.ExitCode", script); } [Fact] public void Installer_HoldsMigrationLockThroughEntireUninstall() @@ -674,6 +810,22 @@ public void Installer_HoldsMigrationLockThroughEntireUninstall() Assert.Single(Regex.Matches(installer, @"CloseMigrationOperationFile\(MigrationOperationHandle\)")); } + [Fact] + public void Installer_ReadsAttributeFailureAsSignedSentinel() + { + var installer = Read("installer.iss"); + var scan = installer[installer.IndexOf("function MigrationPathIsOrdinary", StringComparison.Ordinal).. + installer.IndexOf("function InitializeUninstall:", StringComparison.Ordinal)]; + + // Pascal Script resolves the type of an unsigned $FFFFFFFF literal + // inconsistently, so the failure sentinel is compared as a signed -1. + Assert.Contains("function MigrationPathAttributes(FileName: String): Integer;", installer); + Assert.Contains("Attributes: Integer;", scan); + Assert.Contains("if Attributes = -1 then", scan); + Assert.DoesNotContain("= $FFFFFFFF", scan); + Assert.Contains("else if (Attributes and $400) <> 0 then", scan); + } + [Fact] public void RecordPackageIdentity_MatchesStoreManifest() { @@ -683,6 +835,164 @@ public void RecordPackageIdentity_MatchesStoreManifest() Assert.Equal(OpenClaw.Connection.Migration.MigrationRecordCodec.PackagePublisher, identity.Attribute("Publisher")!.Value); } + [Theory] + [InlineData("gateways")] + [InlineData("identity")] + [InlineData("child")] + public void CleanupScript_DoesNotDeleteThroughAJunction(string junctionAt) + { + // The shipped guard text is executed against a real junction. The source-order assertions + // above passed while the guard still checked only the leaf, so they cannot stand alone. + var temp = Path.Combine(Path.GetTempPath(), "oc-junction-" + Guid.NewGuid().ToString("N")); + var sentinel = Path.Combine(temp, "valuable"); + var dataDir = Path.Combine(temp, "data"); + var gateways = Path.Combine(dataDir, "gateways"); + var identity = Path.Combine(gateways, "victim"); + try + { + Directory.CreateDirectory(dataDir); + if (junctionAt == "gateways") + { + Directory.CreateDirectory(Path.Combine(sentinel, "victim")); + File.WriteAllText(Path.Combine(sentinel, "victim", "keep.txt"), "keep"); + Junction(gateways, sentinel); + } + else if (junctionAt == "child") + { + // A junction planted inside the identity directory. The ancestor walk cannot see + // it, and Remove-Item -Recurse on 5.1 deletes straight through it. + Directory.CreateDirectory(sentinel); + File.WriteAllText(Path.Combine(sentinel, "keep.txt"), "keep"); + Directory.CreateDirectory(identity); + Junction(Path.Combine(identity, "link"), sentinel); + } + else + { + Directory.CreateDirectory(sentinel); + File.WriteAllText(Path.Combine(sentinel, "keep.txt"), "keep"); + Directory.CreateDirectory(gateways); + Junction(identity, sentinel); + } + + var output = RunGuard(dataDir, identity); + + Assert.StartsWith("WARNED", output); + Assert.True(File.Exists(Path.Combine(sentinel, junctionAt == "gateways" ? "victim" : "", "keep.txt")), + $"The junction target was deleted through '{junctionAt}'."); + } + finally + { + TryRemove(gateways); + TryRemove(identity); + TryRemove(temp); + } + } + + [Fact] + public void CleanupScript_StillDeletesAnOrdinaryIdentityDirectory() + { + var temp = Path.Combine(Path.GetTempPath(), "oc-junction-" + Guid.NewGuid().ToString("N")); + var dataDir = Path.Combine(temp, "data"); + var identity = Path.Combine(dataDir, "gateways", "victim"); + try + { + Directory.CreateDirectory(identity); + File.WriteAllText(Path.Combine(identity, "device-key.json"), "{}"); + + var output = RunGuard(dataDir, identity); + + // The guard must not become so strict that ordinary cleanup stops working. + Assert.StartsWith("NOWARN", output); + Assert.False(Directory.Exists(identity)); + } + finally + { + TryRemove(temp); + } + } + + /// + /// Runs the guard exactly as shipped, lifted out of the surrounding uninstall so the test does + /// not touch WSL, scheduled tasks, or the registry. + /// + private static string RunGuard(string dataDir, string identityDir) + { + var script = Read("scripts", "Uninstall-LocalGateway.ps1"); + const string start = "if (Test-Path -LiteralPath $identityDir -PathType Container) {"; + const string end = "Write-GatewayLog \"Deleted identity directory for local gateway record $id.\""; + var from = script.IndexOf(start, StringComparison.Ordinal); + var to = script.IndexOf(end, StringComparison.Ordinal); + Assert.True(from > 0 && to > from, "The identity cleanup guard could not be located."); + var guard = script[from..(to + end.Length)] + "\n}"; + + var harness = $$""" + $ErrorActionPreference = 'Stop' + $DataDir = '{{dataDir}}' + $identityDir = '{{identityDir}}' + $id = 'victim' + $script:warnings = @() + function Add-CleanupWarning { param($Message) $script:warnings += $Message } + function Write-GatewayLog { param($Message) } + foreach ($once in @(1)) { + {{guard}} + } + if ($script:warnings) { "WARNED: $($script:warnings -join '; ')" } else { 'NOWARN' } + """; + + var file = Path.Combine(Path.GetTempPath(), "oc-guard-" + Guid.NewGuid().ToString("N") + ".ps1"); + File.WriteAllText(file, harness); + try + { + // Windows PowerShell 5.1 specifically: that is what Inno runs during uninstall, and + // its Remove-Item is the one that follows junctions. + var info = new System.Diagnostics.ProcessStartInfo("powershell.exe") + { + Arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var process = System.Diagnostics.Process.Start(info)!; + var output = process.StandardOutput.ReadToEnd().Trim(); + var error = process.StandardError.ReadToEnd().Trim(); + process.WaitForExit(); + Assert.True(process.ExitCode == 0, $"Guard harness failed: {error}"); + return output; + } + finally + { + File.Delete(file); + } + } + + private static void Junction(string link, string target) + { + var info = new System.Diagnostics.ProcessStartInfo("cmd.exe") + { + Arguments = $"/d /c mklink /J \"{link}\" \"{target}\"", + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var process = System.Diagnostics.Process.Start(info)!; + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + Assert.True(process.ExitCode == 0, $"Could not create junction: {error}"); + } + + private static void TryRemove(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + [Fact] public async Task GatewayUninstall_PassesWslControlFlagsUnquoted() { @@ -831,7 +1141,6 @@ function New-Utf16Bleed { Write-Output 'CONTRACT-OK' "; - private static string Read(params string[] segments) => File.ReadAllText(Path.Combine(new[] { TestRepositoryPaths.GetRepositoryRoot() }.Concat(segments).ToArray())); } diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs index 0488b4649..20447079a 100644 --- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs @@ -837,6 +837,49 @@ public void AllLocales_ContainOnboardingKeys() } } + /// + /// Matches an XML entity or numeric character reference that survived XML + /// decoding. A .resw value is stored escaped and decoded once when loaded, + /// so a well-formed resource can never yield one of these after decoding. + /// Seeing one means the literal text was escaped an extra time. + /// + private static readonly Regex SurvivingXmlEntity = new( + @"&(?:#x?[0-9A-Fa-f]+|amp|lt|gt|quot|apos);", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + + [Fact] + public void Resources_DoNotContainOverEscapedXmlEntities() + { + var stringsDir = GetStringsDirectory(); + var failures = new List(); + + foreach (var localeDir in Directory.GetDirectories(stringsDir).OrderBy(d => d, StringComparer.Ordinal)) + { + var reswPath = Path.Combine(localeDir, "Resources.resw"); + if (!File.Exists(reswPath)) continue; + + var locale = Path.GetFileName(localeDir); + foreach (var (key, value) in LoadResw(reswPath)) + { + var match = SurvivingXmlEntity.Match(value); + if (match.Success) + { + failures.Add($"{locale} :: {key} :: renders literal '{match.Value}'"); + } + } + } + + Assert.True( + failures.Count == 0, + "Resource values must not contain XML entities that survive decoding; users see the raw " + + "escape sequence instead of the intended character. This happens when already-escaped text " + + "is escaped again, for example lifting a XAML attribute into .resw ('→' becomes " + + "'&#x2192;') or re-escaping a translated value ('&' becomes '&amp;'). " + + "Store the literal character instead: write an arrow as '\u2192' and an ampersand as a " + + "single '&' in the source text, which the .resw writer escapes exactly once." + + Environment.NewLine + string.Join(Environment.NewLine, failures)); + } + [Fact] public void AllLocales_ContainRuntimeOnboardingKeys() { diff --git a/tests/OpenClaw.Tray.Tests/Migration2LocalizationTests.cs b/tests/OpenClaw.Tray.Tests/Migration2LocalizationTests.cs new file mode 100644 index 000000000..8c9f50a75 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/Migration2LocalizationTests.cs @@ -0,0 +1,308 @@ +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace OpenClaw.Tray.Tests; + +public sealed class Migration2LocalizationTests +{ + private static readonly string[] StoreConsentBodyKeys = + { + "Migration2_Consent", "Migration2_PrepareStepBody", "Migration2_RemoveStepBody", + "Migration2_FinishStepBody", "Migration2_ConsentHint", "Migration2_ConsentWarning" + }; + + private static readonly string[] WizardHeadingKeys = + { + "Migration2_PrepareStepTitle", "Migration2_RemoveStepTitle", "Migration2_FinishStepTitle", + "Migration2_ConsentWarningTitle", "Migration2_CloseInnoTitle", + "Migration2_CloseInnoWarningTitle", "Migration2_RemovalTitle", "Migration2_RemovalWarningTitle" + }; + + private static readonly string[] InnoPromotionKeys = + { + "Migration2_InnoRecommendation.Text", "Migration2_InnoCardTitle.Text", + "Migration2_InnoCardDescription.Text", "Migration2_InnoAction", + "Migration2_InnoConsentTitle" + }; + + [Theory] + [InlineData("en-us", "Microsoft Store opens", "close this app", + "without asking again", "settings, gateway, files, and models stay where they are")] + [InlineData("fr-fr", "Le Microsoft Store s'ouvre", "fermer cette application", + "sans redemander votre accord", + "paramètres, votre passerelle, vos fichiers et vos modèles restent en place")] + [InlineData("nl-nl", "De Microsoft Store wordt geopend", "deze app worden gesloten", + "zonder opnieuw om toestemming te vragen", + "instellingen, gateway, bestanden en modellen blijven op hun plaats")] + [InlineData("pt-br", "A Microsoft Store será aberta", "fechar este aplicativo", + "sem pedir autorização novamente", + "configurações, gateway, arquivos e modelos permanecem onde estão")] + [InlineData("zh-cn", "将打开 Microsoft Store", "关闭此应用", + "无需再次征求您的同意", "您的设置、网关、文件和模型将保留在原处")] + [InlineData("zh-tw", "將開啟 Microsoft Store", "關閉此應用程式", + "無需再次徵求您的同意", "您的設定、閘道、檔案和模型將保留在原處")] + public void InnoDialog_DisclosesSafetyBoundariesInEveryLocale( + string locale, string storeOpens, string closesThisApp, + string noRepeatedPrompt, string retainedData) + { + var resources = ReadResources(locale); + var consent = resources["Migration2_InnoConsent"]; + // The dialog discloses the handoff grant and what it does not touch. Detailed + // removal and finalization guidance is delivered by the Store wizard at the step + // where the user can act on it, so it is deliberately absent here. + foreach (var disclosure in new[] { storeOpens, closesThisApp, noRepeatedPrompt, retainedData }) + Assert.Contains(disclosure, consent); + + Assert.False(string.IsNullOrWhiteSpace(resources["Migration2_InnoConsentTitle"])); + // Chinese locales use the fullwidth question mark. + Assert.Contains(resources["Migration2_InnoConsentTitle"], + title => title is '?' or '\uFF1F'); + Assert.NotEqual(resources["Migration2_InnoAction"], resources["Migration2_InnoConsentTitle"]); + foreach (var nativeChoice in new[] { resources["Migration_StoreYes"], resources["Migration_StoreNo"] }) + { + Assert.DoesNotContain(nativeChoice + ":", consent); + Assert.DoesNotContain(nativeChoice + "\uFF1A", consent); + } + Assert.DoesNotContain("{0}", consent); + Assert.DoesNotContain("{1}", consent); + Assert.DoesNotContain("\u2014", consent); + } + + [Theory] + [InlineData("en-us", "settings, gateway, files, and models where they are", + "safely close the previous app", "check your installation and settings", + "protected migration records", "When prompted, uninstall it manually", + "Nothing is uninstalled automatically", "verify removal before starting the Store version", + "without starting migration", "If validation succeeds", "can no longer start normally")] + [InlineData("fr-fr", "paramètres, votre passerelle, vos fichiers et vos modèles en place", + "fermerons l'application précédente en toute sécurité", + "vérifierons votre installation et vos paramètres", + "données de migration protégées", "Lorsque vous y serez invité, désinstallez-la manuellement", + "Rien ne sera désinstallé automatiquement", "vérifions la désinstallation avant de démarrer la version du Store", + "sans démarrer la migration", "Si la validation réussit", "ne pourra plus démarrer normalement")] + [InlineData("nl-nl", "instellingen, gateway, bestanden en modellen op hun huidige plek", + "sluiten de vorige app veilig af", "controleren uw installatie en instellingen", + "beveiligde migratiegegevens", "Verwijder de app handmatig wanneer daarom wordt gevraagd", + "Er wordt niets automatisch verwijderd", "verifiëren de verwijdering voordat we de Store-versie starten", + "zonder de migratie te starten", "Als de validatie slaagt", "niet meer normaal starten")] + [InlineData("pt-br", "configurações, gateway, arquivos e modelos onde estão", + "fechar o aplicativo anterior com segurança", "verificar sua instalação e configurações", + "registros protegidos de migração", "Quando solicitado, desinstale-o manualmente", + "Nada será desinstalado automaticamente", "Verificamos a remoção antes de iniciar a versão da Store", + "sem iniciar a migração", "Se a validação for bem-sucedida", "não poderá mais iniciar normalmente")] + [InlineData("zh-cn", "设置、网关、文件和模型保留在原处", "安全关闭旧版应用", "检查您的安装和设置", + "受保护的迁移记录", "收到提示后,请手动卸载", "不会自动卸载任何应用", + "验证移除状态后再启动 Store 版本", "不会开始迁移", "如果验证成功", "将无法正常启动")] + [InlineData("zh-tw", "設定、閘道、檔案和模型保留在原處", "安全關閉舊版應用程式", "檢查您的安裝和設定", + "受保護的移轉記錄", "收到提示後,請手動解除安裝", "不會自動解除安裝任何應用程式", + "驗證移除狀態後再啟動 Store 版本", "不會開始移轉", "如果驗證成功", "將無法正常啟動")] + public void StoreComposedConsent_DisclosesSafetyAtTheRelevantStep( + string locale, string retainedData, string gracefulClose, string validation, + string protectedRecords, string promptedManualRemoval, string noAutomaticUninstall, + string verifiedRemoval, string cancelWithoutMigration, string validationCondition, string startupBlock) + { + var resources = ReadResources(locale); + Assert.Contains(retainedData, resources["Migration2_Consent"]); + Assert.Contains(gracefulClose, resources["Migration2_PrepareStepBody"]); + Assert.Contains(validation, resources["Migration2_PrepareStepBody"]); + Assert.Contains(protectedRecords, resources["Migration2_PrepareStepBody"]); + Assert.Contains(promptedManualRemoval, resources["Migration2_RemoveStepBody"]); + Assert.Contains(noAutomaticUninstall, resources["Migration2_RemoveStepBody"]); + Assert.Contains(verifiedRemoval, resources["Migration2_FinishStepBody"]); + Assert.Contains(resources["Migration_StoreNotNow"], resources["Migration2_ConsentHint"]); + Assert.Contains(cancelWithoutMigration, resources["Migration2_ConsentHint"]); + Assert.Contains(validationCondition, resources["Migration2_ConsentWarning"]); + Assert.Contains(startupBlock, resources["Migration2_ConsentWarning"]); + } + + [Theory] + [InlineData("en-us", "tray menu", "Don't force-close or uninstall it yet", + "We'll tell you when it's safe to uninstall", "uninstall", "verify", "finish")] + [InlineData("fr-fr", "menu dans la zone de notification", + "Ne forcez pas sa fermeture et ne la désinstallez pas encore", + "Nous vous indiquerons quand vous pourrez la désinstaller en toute sécurité", + "désinstall", "vérifi", "terminer")] + [InlineData("nl-nl", "menu in het systeemvak", + "Forceer het afsluiten niet en verwijder de app nog niet", + "We laten u weten wanneer u de app veilig kunt verwijderen", "verwijder", "verifi", "ronden")] + [InlineData("pt-br", "menu na bandeja do sistema", + "Não force o encerramento nem desinstale o aplicativo ainda", + "Avisaremos quando for seguro desinstalar", "desinstal", "verificar", "finalizar")] + [InlineData("zh-cn", "托盘菜单", "暂时不要强制关闭或卸载该应用", + "我们会在可以安全卸载时通知您", "卸载", "验证", "完成")] + [InlineData("zh-tw", "系統匣選單", "暫時不要強制關閉或解除安裝該應用程式", + "我們會在可以安全解除安裝時通知您", "解除安裝", "驗證", "完成")] + public void CloseStep_OnlyRequestsGracefulCloseAndRetry( + string locale, string trayMenu, string noForcedExit, string waitForPermission, + string uninstall, string verify, string finish) + { + var resources = ReadResources(locale); + var close = resources["Migration2_CloseInno"]; + var warning = resources["Migration2_CloseInnoWarning"]; + Assert.Contains("OpenClaw Companion", close); + Assert.Contains(trayMenu, close); + Assert.Contains(resources["Migration_StoreRetry"], close); + Assert.Contains(noForcedExit, warning); + Assert.Contains(waitForPermission, warning); + Assert.DoesNotContain(uninstall, close, StringComparison.OrdinalIgnoreCase); + foreach (var futureAction in new[] { verify, finish }) + { + Assert.DoesNotContain(futureAction, close, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(futureAction, warning, StringComparison.OrdinalIgnoreCase); + } + } + + [Theory] + [InlineData("en-us", "setup has been validated", "migration completion recorded", + "Installed apps", "uninstall only the previous OpenClaw Companion app", "Keep the Store version", + "Return here", "verify removal and finish", "settings, gateway, files, and models stay in place", + "Don't delete migration data or run gateway cleanup")] + [InlineData("fr-fr", "configuration a été validée", "fin de la migration enregistrée", + "Applications installées", "désinstallez uniquement l'application OpenClaw Companion précédente", + "Gardez la version du Store", "Revenez ici", "vérifier la désinstallation et terminer", + "paramètres, votre passerelle, vos fichiers et vos modèles restent en place", + "Ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle")] + [InlineData("nl-nl", "configuratie is gevalideerd", "voltooiing van de migratie is vastgelegd", + "Geïnstalleerde apps", "verwijder alleen de vorige OpenClaw Companion-app", "Behoud de Store-versie", + "Keer hier terug", "de verwijdering te verifiëren en af te ronden", + "instellingen, gateway, bestanden en modellen blijven op hun plaats", + "Verwijder geen migratiegegevens en voer geen gatewayopruiming uit")] + [InlineData("pt-br", "configuração foi validada", "conclusão da migração foi registrada", + "Aplicativos instalados", "desinstale apenas o aplicativo OpenClaw Companion anterior", + "Mantenha a versão da Store", "Volte aqui", "verificar a remoção e finalizar", + "configurações, gateway, arquivos e modelos permanecem no mesmo local", + "Não exclua os dados de migração nem execute a limpeza do gateway")] + [InlineData("zh-cn", "配置已通过验证", "迁移完成状态已记录", + "已安装的应用", "仅卸载旧版 OpenClaw Companion 应用", "请保留 Store 版本", + "返回此处", "验证移除状态并完成迁移", "设置、网关、文件和模型将保留在原处", + "请勿删除迁移数据或运行网关清理")] + [InlineData("zh-tw", "設定已通過驗證", "移轉完成狀態已記錄", + "已安裝的應用程式", "僅解除安裝舊版 OpenClaw Companion 應用程式", "請保留 Store 版本", + "返回此處", "驗證移除狀態並完成移轉", "設定、閘道、檔案和模型將保留在原處", + "請勿刪除移轉資料或執行閘道清理")] + public void RemovalStep_HasTwoOrderedActionsAfterValidationAndReceipt( + string locale, string validated, string receipt, string installedApps, + string onlyPreviousApp, string keepStore, string returnHere, + string verifyAndFinish, string retainedData, string noCleanup) + { + var resources = ReadResources(locale); + var paragraphs = Regex.Split(resources["Migration2_AwaitingRemoval"].Trim(), @"\r?\n\s*\r?\n"); + Assert.Equal(4, paragraphs.Length); + Assert.Contains(validated, paragraphs[0]); + Assert.Contains(receipt, paragraphs[0]); + Assert.StartsWith("1. ", paragraphs[1]); + Assert.Contains(installedApps, paragraphs[1]); + Assert.Contains(onlyPreviousApp, paragraphs[1]); + Assert.Contains(keepStore, paragraphs[1]); + Assert.StartsWith("2. ", paragraphs[2]); + Assert.Contains(returnHere, paragraphs[2]); + Assert.Contains(resources["Migration_StoreRetry"], paragraphs[2]); + Assert.Contains(verifyAndFinish, paragraphs[2]); + Assert.Contains(retainedData, paragraphs[3]); + Assert.Equal(2, Regex.Matches(resources["Migration2_AwaitingRemoval"], @"(?m)^\d+\. ").Count); + Assert.Contains(noCleanup, resources["Migration2_RemovalWarning"]); + } + + [Theory] + [InlineData("en-us")] + [InlineData("fr-fr")] + [InlineData("nl-nl")] + [InlineData("pt-br")] + [InlineData("zh-cn")] + [InlineData("zh-tw")] + public void WizardHeadingsBodiesAndWarnings_ArePresentAndLocalized(string locale) + { + var resources = ReadResources(locale); + var english = ReadResources("en-us"); + foreach (var key in WizardHeadingKeys.Concat(StoreConsentBodyKeys).Concat(new[] + { + "Migration2_CloseInno", "Migration2_CloseInnoWarning", + "Migration2_AwaitingRemoval", "Migration2_RemovalWarning" + })) + { + Assert.False(string.IsNullOrWhiteSpace(resources[key]), key); + Assert.DoesNotContain("\u2014", resources[key]); + Assert.DoesNotContain("{0}", resources[key]); + Assert.DoesNotContain("{1}", resources[key]); + if (locale != "en-us") + Assert.NotEqual(english[key], resources[key]); + } + } + + [Theory] + [InlineData("en-us")] + [InlineData("fr-fr")] + [InlineData("nl-nl")] + [InlineData("pt-br")] + [InlineData("zh-cn")] + [InlineData("zh-tw")] + public void InnoPromotion_IsPresentAndLocalized(string locale) + { + var resources = ReadResources(locale); + var english = ReadResources("en-us"); + foreach (var key in InnoPromotionKeys) + { + Assert.False(string.IsNullOrWhiteSpace(resources[key]), key); + Assert.DoesNotContain("\u2014", resources[key]); + if (locale != "en-us") + Assert.NotEqual(english[key], resources[key]); + } + } + + [Fact] + public void EnglishWizardCopy_StaysWithinWordBudgetsExcludingHeadings() + { + var resources = ReadResources("en-us"); + AssertWordBudget(110, StoreConsentBodyKeys); + AssertWordBudget(40, "Migration2_CloseInno", "Migration2_CloseInnoWarning"); + AssertWordBudget(75, "Migration2_AwaitingRemoval", "Migration2_RemovalWarning"); + + void AssertWordBudget(int maximum, params string[] keys) + { + var words = Regex.Matches(string.Join(" ", keys.Select(key => resources[key])), @"\S+").Count; + Assert.InRange(words, 1, maximum); + } + } + + [Fact] + public void EnglishShippingGuidance_DoesNotRequireReopeningStoreOrUsePreviewCopy() + { + var resources = ReadResources("en-us"); + foreach (var key in StoreConsentBodyKeys.Concat(WizardHeadingKeys).Concat(new[] + { + "Migration2_InnoConsent", "Migration2_CloseInno", "Migration2_CloseInnoWarning", + "Migration2_AwaitingRemoval", "Migration2_RemovalWarning" + })) + { + Assert.DoesNotContain("reopen", resources[key], StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("preview", resources[key], StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void RecoveryGuidance_NamesTheRemedyInsteadOfForbiddingIt() + { + var recovery = ReadResources("en-us")["Migration2_Recovery"]; + // The previous copy forbade the only remedy that works, which left recovery a dead end. + Assert.DoesNotContain("Do not uninstall the previous app", recovery); + Assert.Contains("Installed apps", recovery); + Assert.Contains("Retry", recovery); + } + + [Theory] + [InlineData("en-us")] + [InlineData("fr-fr")] + [InlineData("nl-nl")] + [InlineData("pt-br")] + [InlineData("zh-cn")] + [InlineData("zh-tw")] + public void DiscardAction_IsNamedInEveryLocale(string locale) => + Assert.False(string.IsNullOrWhiteSpace(ReadResources(locale)["Migration2_DiscardRecords"])); + + private static Dictionary ReadResources(string locale) => XDocument.Load(Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Strings", locale, "Resources.resw")) + .Root!.Elements("data").ToDictionary( + element => element.Attribute("name")!.Value, + element => element.Element("value")!.Value, + StringComparer.Ordinal); +} diff --git a/tests/OpenClaw.Tray.Tests/MigrationBuildConfigurationTests.cs b/tests/OpenClaw.Tray.Tests/MigrationBuildConfigurationTests.cs new file mode 100644 index 000000000..73655623a --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/MigrationBuildConfigurationTests.cs @@ -0,0 +1,184 @@ +using System.Diagnostics; +using System.Xml.Linq; +using OpenClaw.TestSupport; + +namespace OpenClaw.Tray.Tests; + +public sealed class MigrationBuildConfigurationTests +{ + [Theory] + [InlineData("win-x64", false)] + [InlineData("win-x64", true)] + [InlineData("win-arm64", false)] + [InlineData("win-arm64", true)] + public async Task Production_InnoAndStoreShareThePinnedReleasePolicy(string runtime, bool packaged) + { + var result = await EvaluateAsync( + ("MigrationProductionEnabled", "true"), + ("Version", "2027.1.1"), + ("RuntimeIdentifier", runtime), + ("PackageMsix", packaged.ToString())); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("PRODUCTION_MIGRATION", result.Output); + Assert.Contains("MigrationStoreProductId=9NFPR3BGDRR5", result.Output); + Assert.Contains("MigrationMinimumSourceVersion=2026.9.5.0", result.Output); + Assert.DoesNotContain("MigrationMinimumSourceVersion=2027", result.Output); + Assert.DoesNotContain("MIGRATION_PREVIEW", result.Output); + } + + [Fact] + public async Task PinnedRelease_DefaultsEnableTheShippingPolicy() + { + var result = await EvaluateAsync(("Version", "2027.1.1")); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("PRODUCTION_MIGRATION", result.Output); + Assert.Contains("MigrationStoreProductId=9NFPR3BGDRR5", result.Output); + Assert.Contains("MigrationMinimumSourceVersion=2026.9.5.0", result.Output); + Assert.DoesNotContain("MigrationMinimumSourceVersion=2027", result.Output); + Assert.DoesNotContain("MIGRATION_PREVIEW", result.Output); + } + + [Theory] + [InlineData("MigrationMinimumSourceVersion", "", "MigrationMinimumSourceVersion")] + [InlineData("MigrationMinimumSourceVersion", "2026.9", "MigrationMinimumSourceVersion")] + [InlineData("MigrationMinimumSourceVersion", "2026.9.5-alpha.1", "MigrationMinimumSourceVersion")] + [InlineData("MigrationMinimumSourceVersion", "0.0.0", "zero version")] + [InlineData("MigrationMinimumSourceVersion", "0.0.0.0", "zero version")] + [InlineData("MigrationMinimumSourceVersion", "2147483648.1.1", "Version")] + [InlineData("MigrationStoreProductId", "not-a-product", "MigrationStoreProductId")] + [InlineData("RuntimeIdentifier", "win-x86", "win-x64 or win-arm64")] + [InlineData("MigrationProductionEnabled", "yes", "true or false")] + public async Task Production_RejectsIncompleteOrInvalidPolicy(string key, string value, string error) + { + var properties = new Dictionary + { + ["MigrationProductionEnabled"] = "true", + ["MigrationMinimumSourceVersion"] = "2026.9.5.0", + [key] = value + }; + var result = await EvaluateAsync(properties.Select(pair => (pair.Key, pair.Value)).ToArray()); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains(error, result.Output); + } + + [Theory] + [InlineData("Debug", "false", "true")] + [InlineData("Debug", "true", "true")] + [InlineData("Release", "true", "true")] + [InlineData("Release", "false", "false")] + public async Task Production_ExcludesDebugDevAndDisabledBuilds( + string configuration, string devBuild, string enabled) + { + var result = await EvaluateAsync( + ("Configuration", configuration), ("DevBuild", devBuild), + ("MigrationProductionEnabled", enabled)); + + Assert.Equal(0, result.ExitCode); + Assert.DoesNotContain("PRODUCTION_MIGRATION", result.Output); + Assert.DoesNotContain("MigrationStoreProductId=", result.Output); + Assert.DoesNotContain("MigrationMinimumSourceVersion=", result.Output); + } + + [Theory] + [InlineData(false, "InnoMigrationPreview", "INNO_MIGRATION_PREVIEW")] + [InlineData(true, "StoreMigrationPreview", "STORE_MIGRATION_PREVIEW")] + public async Task ExplicitDebugPreviewsRemainAvailable(bool packaged, string flag, string symbol) + { + var result = await EvaluateAsync( + ("Configuration", "Debug"), ("PackageMsix", packaged.ToString()), + ("MigrationProductionEnabled", "false"), (flag, "true"), + ("MigrationPreviewStoreProductId", "9NFPR3BGDRR5"), + ("StoreMigrationPreviewMinimumSourceVersion", "2026.9.5.0")); + + Assert.Equal(0, result.ExitCode); + Assert.Contains(symbol, result.Output); + Assert.DoesNotContain("PRODUCTION_MIGRATION", result.Output); + } + + [Theory] + [InlineData(false, "InnoMigrationPreview")] + [InlineData(true, "StoreMigrationPreview")] + public async Task PreviewFlagsCannotShipInRelease(bool packaged, string flag) + { + var result = await EvaluateAsync( + ("MigrationProductionEnabled", "false"), ("PackageMsix", packaged.ToString()), + (flag, "true"), ("StoreMigrationPreviewMinimumSourceVersion", "2026.9.5.0")); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("restricted to Debug", result.Output); + } + + [Fact] + public void TrayImportsOneCanonicalMigrationBuildPolicy() + { + var directory = Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI"); + var project = XDocument.Load(Path.Combine(directory, "OpenClaw.Tray.WinUI.csproj")); + Assert.Single(project.Descendants("Import"), element => + (string?)element.Attribute("Project") == "Migration.Build.props"); + var policy = XDocument.Load(Path.Combine(directory, "Migration.Build.props")); + Assert.Equal("9NFPR3BGDRR5", policy.Descendants("MigrationStoreProductId").Single().Value); + var minimum = policy.Descendants("MigrationMinimumSourceVersion").Single().Value; + Assert.Equal("2026.9.5.0", minimum); + Assert.DoesNotContain("$(Version)", minimum); + Assert.DoesNotContain("GitVersion", minimum); + Assert.Equal("true", policy.Descendants("MigrationProductionEnabled").Single().Value); + Assert.Equal("CoreCompile", policy.Descendants("Target") + .Single(element => (string?)element.Attribute("Name") == "ValidateMigrationBuild") + .Attribute("BeforeTargets")!.Value); + } + + private static async Task<(int ExitCode, string Output)> EvaluateAsync(params (string Key, string Value)[] overrides) + { + using var temp = new TempDirectory(); + var root = TestRepositoryPaths.GetRepositoryRoot(); + var properties = new Dictionary + { + ["Configuration"] = "Release", + ["RuntimeIdentifier"] = "win-x64", + ["DevBuild"] = "false", + ["PackageMsix"] = "false" + }; + var path = temp.Combine("migration-build.proj"); + new XDocument(new XElement("Project", + new XElement("PropertyGroup", properties.Select(pair => new XElement(pair.Key, pair.Value))), + new XElement("Import", new XAttribute("Project", + Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Migration.Build.props"))), + new XElement("Target", new XAttribute("Name", "Probe"), + new XAttribute("DependsOnTargets", "ValidateMigrationBuild"), + new XElement("Message", new XAttribute("Importance", "high"), + new XAttribute("Text", "MIGRATION-DEFINES=$(DefineConstants)")), + new XElement("Message", new XAttribute("Importance", "high"), + new XAttribute("Text", "@(AssemblyAttribute->'%(_Parameter1)=%(_Parameter2)', '|')"))))) + .Save(path); + var start = new ProcessStartInfo("dotnet") + { + WorkingDirectory = root, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + foreach (var argument in new[] { "msbuild", path, "-t:Probe", "-nologo", "-v:minimal" }) + start.ArgumentList.Add(argument); + foreach (var (key, value) in overrides) + start.ArgumentList.Add($"-p:{key}={value}"); + using var process = Process.Start(start) ?? throw new InvalidOperationException("Could not start migration build validation."); + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + throw new TimeoutException("Migration build validation timed out."); + } + return (process.ExitCode, $"{await stdout}\n{await stderr}"); + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 3812cd5a2..a22423de8 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -93,6 +93,8 @@ + + diff --git a/tests/OpenClaw.Tray.Tests/StoreMigrationWorkflowTests.cs b/tests/OpenClaw.Tray.Tests/StoreMigrationWorkflowTests.cs new file mode 100644 index 000000000..0f3a5b256 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/StoreMigrationWorkflowTests.cs @@ -0,0 +1,785 @@ +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +public sealed class StoreMigrationWorkflowTests +{ + [Fact] + public async Task WithoutExplicitConsent_DoesNotCloseOrMutateSource() + { + var operations = new Operations(); + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.Consent, workflow.Stage); + Assert.Equal(new[] { "inspect", "consent?" }, operations.Calls); + Assert.False(workflow.IsBusy); + } + + [Fact] + public async Task ExplicitConfirmation_RecordsConsentBeforeGracefulShutdownAndCompletion() + { + var operations = new Operations(); + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + operations.Calls.Clear(); + await workflow.ContinueAsync(CancellationToken.None); + Assert.Equal(new[] { "inspect", "grant", "close", "prepare", "complete" }, operations.Calls); + Assert.Equal(StoreMigrationStage.AwaitingRemoval, workflow.Stage); + Assert.DoesNotContain("finalize", operations.Calls); + } + + [Fact] + public async Task ValidHandoff_SkipsSecondPromptButNotAdoptionValidation() + { + var operations = new Operations { Consent = true }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(new[] { "inspect", "consent?", "close", "prepare", "complete" }, operations.Calls); + Assert.Equal(StoreMigrationStage.AwaitingRemoval, workflow.Stage); + } + + [Fact] + public async Task ChangedSourceAfterPrompt_RequiresNewConfirmation() + { + var operations = new Operations(); + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + operations.Admission = operations.Admission with + { + Installation = operations.Admission.Installation! with { Version = new Version(2026, 10, 1) } + }; + operations.Calls.Clear(); + await workflow.ContinueAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.Consent, workflow.Stage); + Assert.Equal(new[] { "inspect" }, operations.Calls); + } + + [Fact] + public async Task ShutdownUnavailable_ManualRetryReinspectsAndThenCompletes() + { + var operations = new Operations { Consent = true, Closed = false }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.CloseSource, workflow.Stage); + Assert.DoesNotContain("prepare", operations.Calls); + operations.Closed = true; + await workflow.ContinueAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.AwaitingRemoval, workflow.Stage); + Assert.Equal(2, operations.Calls.Count(call => call == "inspect")); + } + + [Theory] + [InlineData(StoreMigrationPreparationState.InnoRunning, StoreMigrationStage.CloseSource)] + [InlineData(StoreMigrationPreparationState.SourceChanged, StoreMigrationStage.Unsupported)] + [InlineData(StoreMigrationPreparationState.ValidationFailed, StoreMigrationStage.ValidationFailed)] + public async Task FailedPreparation_NeverCommits(StoreMigrationPreparationState result, object stage) + { + var operations = new Operations { Consent = true, Prepared = result }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal((StoreMigrationStage)stage, workflow.Stage); + Assert.DoesNotContain("complete", operations.Calls); + } + + [Theory] + [InlineData(StoreMigrationCompletionState.InnoRunning, StoreMigrationStage.CloseSource)] + [InlineData(StoreMigrationCompletionState.SourceChanged, StoreMigrationStage.Unsupported)] + public async Task FailedCompletion_DoesNotOfferRemoval(StoreMigrationCompletionState result, object stage) + { + var operations = new Operations { Consent = true, Completed = result }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal((StoreMigrationStage)stage, workflow.Stage); + } + + [Fact] + public async Task SourceRestartsBeforeCompletion_RetryReinspectsAndPreservesConsent() + { + var operations = new Operations { Consent = true, Completed = StoreMigrationCompletionState.InnoRunning }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.CloseSource, workflow.Stage); + operations.Completed = StoreMigrationCompletionState.Completed; + operations.Calls.Clear(); + + await workflow.ContinueAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.AwaitingRemoval, workflow.Stage); + Assert.Equal(new[] { "inspect", "consent?", "close", "prepare", "complete" }, operations.Calls); + } + + [Theory] + [InlineData(StoreMigrationStartupState.UpdateInno, StoreMigrationStage.UpdateRequired)] + [InlineData(StoreMigrationStartupState.UnsupportedInstallation, StoreMigrationStage.Unsupported)] + [InlineData(StoreMigrationStartupState.InspectionFailed, StoreMigrationStage.InspectionFailed)] + [InlineData(StoreMigrationStartupState.RecoveryRequired, StoreMigrationStage.Recovery)] + [InlineData(StoreMigrationStartupState.AwaitingInnoRemoval, StoreMigrationStage.AwaitingRemoval)] + public async Task BlockedAdmission_CannotBeOverriddenByConsent( + StoreMigrationStartupState admission, object stage) + { + var operations = new Operations { Admission = new(admission), Consent = true }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal((StoreMigrationStage)stage, workflow.Stage); + Assert.Equal(new[] { "inspect" }, operations.Calls); + } + + /// + /// Admission decides whether an unsupported source is really installed; the workflow must + /// carry that through to the startup block rather than treating the stage as informational. + /// + [Theory] + [InlineData(StoreMigrationStartupState.UnsupportedInstallation)] + [InlineData(StoreMigrationStartupState.UpdateInno)] + public async Task AnInstalledUnsupportedSource_KeepsTheStoreAppInactive( + StoreMigrationStartupState admission) + { + var expected = admission == StoreMigrationStartupState.UpdateInno + ? StoreMigrationStage.UpdateRequired + : StoreMigrationStage.Unsupported; + var operations = new Operations + { + Admission = new(admission, null, HoldsCompletionReceipt: false, SourcePayloadPresent: true) + }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(expected, workflow.Stage); + Assert.True(workflow.BlocksStartup); + } + + /// + /// The exact lockout this design exists to avoid. A source block is live evidence, so once the + /// user removes the source it must not survive into a later pass that can no longer measure + /// it. Latching it would leave a user who did what the window asked with no usable app. + /// + [Fact] + public async Task ASourceBlock_DoesNotSurviveARemovalFollowedByAFailedInspection() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.UnsupportedInstallation, null, + HoldsCompletionReceipt: false, SourcePayloadPresent: true) + }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.True(workflow.BlocksStartup); + + // The user uninstalls the previous app, and the next pass cannot complete. + operations.InspectError = new IOException("locked"); + await workflow.ContinueAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.InspectionFailed, workflow.Stage); + Assert.False(workflow.BlocksStartup); + } + + /// + /// The receipt half of the block must keep its old durability: it records that data moved, + /// and failing to re-read that fact does not unmake it. + /// + [Fact] + public async Task AReceiptBlock_StillSurvivesAFailedInspection() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.UnsupportedInstallation, null, + HoldsCompletionReceipt: true) + }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.True(workflow.BlocksStartup); + + operations.InspectError = new IOException("locked"); + await workflow.ContinueAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.InspectionFailed, workflow.Stage); + Assert.True(workflow.BlocksStartup); + } + + [Theory] + [InlineData(StoreMigrationStartupState.UpdateInno, false)] + [InlineData(StoreMigrationStartupState.UnsupportedInstallation, false)] + [InlineData(StoreMigrationStartupState.InspectionFailed, false)] + [InlineData(StoreMigrationStartupState.RecoveryRequired, false)] + [InlineData(StoreMigrationStartupState.AwaitingInnoRemoval, true)] + public async Task InformationalAdmission_DoesNotKeepTheAppFromStarting( + StoreMigrationStartupState admission, bool blocks) + { + var operations = new Operations { Admission = new(admission), Consent = true }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(blocks, workflow.BlocksStartup); + } + + [Fact] + public async Task ReceiptWrittenThisSession_BlocksStartupEvenAfterAFailedStage() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.FinalizationRequired), + Finalized = StoreMigrationFinalizationState.InspectionFailed + }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + // The stage reports an inspection failure, but the completion receipt still exists. + Assert.Equal(StoreMigrationStage.InspectionFailed, workflow.Stage); + Assert.True(workflow.BlocksStartup); + } + + [Fact] + public void BeforeAdmissionResolves_ClosingIsTreatedAsBlocking() + { + // Dismiss is reachable while the first inspection is still in flight. Nothing is known + // yet, so closing must not wave the user past a handoff that already moved data. + var workflow = Create(new Operations()); + + Assert.True(workflow.BlocksStartup); + } + + [Fact] + public async Task ARetryThatNoLongerSeesTheReceipt_DoesNotReleaseTheBlock() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.FinalizationRequired), + Finalized = StoreMigrationFinalizationState.InspectionFailed + }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.True(workflow.BlocksStartup); + + // Retry re-inspects. A transient failure there must not drop what we already know. + operations.Admission = new(StoreMigrationStartupState.InspectionFailed); + await workflow.ContinueAsync(CancellationToken.None); + + Assert.True(workflow.BlocksStartup); + } + + [Fact] + public async Task AdmissionCarryingAReceiptUnderAnInformationalState_Blocks() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.UnsupportedInstallation, null, true) + }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.Unsupported, workflow.Stage); + Assert.True(workflow.BlocksStartup); + } + + [Fact] + public async Task SuccessfulFinalization_ReleasesTheStartupBlock() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.FinalizationRequired), + Finalized = StoreMigrationFinalizationState.Finalized + }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.Ready, workflow.Stage); + Assert.False(workflow.BlocksStartup); + } + + [Fact] + public async Task CompletionWrittenDuringMigration_BlocksStartup() + { + var operations = new Operations { Consent = true }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.AwaitingRemoval, workflow.Stage); + Assert.True(workflow.BlocksStartup); + } + + /// + /// The previous app is uninstalled, so its receipt now blocks an app that no longer exists. + /// If this window also refused to hand over, the user would have no working app and no way + /// back. The failure stays visible, but it must not be a dead end. + /// + [Theory] + [InlineData(StoreMigrationFinalizationState.RecordCleanupFailed, StoreMigrationStage.FinalizationFailed)] + [InlineData(StoreMigrationFinalizationState.InspectionFailed, StoreMigrationStage.InspectionFailed)] + public async Task FinalizationFailureAfterTheSourceIsGone_IsVisibleButNotADeadEnd( + StoreMigrationFinalizationState result, object stage) + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.FinalizationRequired), + Finalized = result, + FinalizedSourceRemoved = true + }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal((StoreMigrationStage)stage, workflow.Stage); + Assert.False(workflow.BlocksStartup); + } + + /// + /// The mirror image: the same failures before removal is proven must keep blocking, because + /// the previous app may still be running against the same data. + /// + [Theory] + [InlineData(StoreMigrationFinalizationState.RecordCleanupFailed)] + [InlineData(StoreMigrationFinalizationState.InspectionFailed)] + public async Task FinalizationFailureBeforeRemovalIsProven_KeepsBlocking( + StoreMigrationFinalizationState result) + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.FinalizationRequired), + Finalized = result + }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.True(workflow.BlocksStartup); + } + + /// + /// Removal is proven once, then a retry re-inspects and fails transiently. The release must + /// survive that, or the dead end simply returns one pass later. + /// + [Fact] + public async Task ARetryAfterAProvenRemoval_DoesNotReimposeTheBlock() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.FinalizationRequired), + Finalized = StoreMigrationFinalizationState.RecordCleanupFailed, + FinalizedSourceRemoved = true + }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.False(workflow.BlocksStartup); + + operations.Admission = new(StoreMigrationStartupState.InspectionFailed, null, true); + await workflow.ContinueAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.InspectionFailed, workflow.Stage); + Assert.False(workflow.BlocksStartup); + } + + /// + /// Issue #1374 specifies that Not now leaves the previous installation unchanged and the Store + /// app inactive. Consent is only ever reached with a detected installation, so declining, or + /// dismissing the window without answering, must not hand the user a second running client. + /// + [Fact] + public async Task DecliningConsent_LeavesTheStoreAppInactive() + { + var operations = new Operations(); + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.Consent, workflow.Stage); + // No consent was granted, so nothing was prepared and no receipt exists. The block here is + // the product contract, not data protection. + Assert.Equal(new[] { "inspect", "consent?" }, operations.Calls); + Assert.True(workflow.BlocksStartup); + } + + [Theory] + [InlineData(StoreMigrationFinalizationState.AwaitingInnoRemoval, StoreMigrationStage.AwaitingRemoval)] + // A transient startup-preference failure still finalizes the migration, so the app proceeds. + // A durable refusal is the only one the user must be told about. + [InlineData(StoreMigrationFinalizationState.StartupPreferenceFailed, StoreMigrationStage.Ready)] + [InlineData(StoreMigrationFinalizationState.StartupPreferenceRefused, StoreMigrationStage.StartupRefused)] + [InlineData(StoreMigrationFinalizationState.RecordCleanupFailed, StoreMigrationStage.FinalizationFailed)] + [InlineData(StoreMigrationFinalizationState.InspectionFailed, StoreMigrationStage.InspectionFailed)] + public async Task RemovalRetry_ResumesOnlyAfterSuccessfulFinalization( + StoreMigrationFinalizationState result, object stage) + { + var operations = new Operations { Admission = new(StoreMigrationStartupState.AwaitingInnoRemoval), Finalized = result }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + operations.Admission = new(StoreMigrationStartupState.FinalizationRequired); + operations.Calls.Clear(); + await workflow.ContinueAsync(CancellationToken.None); + Assert.Equal(new[] { "inspect", "finalize" }, operations.Calls); + Assert.Equal((StoreMigrationStage)stage, workflow.Stage); + } + + /// + /// The refusal notice exists to be read, not to trap the user: Windows alone can re-enable the + /// startup task, so the window must surface the stage while still letting the app launch. + /// + [Fact] + public async Task DurableStartupRefusal_SurfacesTheNoticeWithoutBlockingStartup() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.FinalizationRequired), + Finalized = StoreMigrationFinalizationState.StartupPreferenceRefused + }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.StartupRefused, workflow.Stage); + Assert.False(workflow.BlocksStartup); + } + + [Fact] + public async Task InspectionFailure_IsVisibleAndNeverStartsNormally() + { + var workflow = Create(new Operations { InspectError = new IOException("locked") }); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.InspectionFailed, workflow.Stage); + Assert.False(workflow.IsBusy); + Assert.False(workflow.BlocksStartup); + } + + [Fact] + public async Task InspectionFailure_KeepsBlockingWhenAReceiptIsOnDisk() + { + // Inspection threw before producing a decision, so the receipt was never observed. + // Closing the window here would resume startup against data that already moved. + var operations = new Operations { InspectError = new IOException("locked"), Receipt = true }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.InspectionFailed, workflow.Stage); + Assert.Contains("receipt?", operations.Calls); + Assert.True(workflow.BlocksStartup); + } + + [Fact] + public async Task InspectionFailure_BlocksWhenTheReceiptCannotBeConfirmed() + { + var workflow = Create(new Operations + { + InspectError = new IOException("locked"), + ReceiptError = new UnauthorizedAccessException("denied") + }); + await workflow.StartAsync(CancellationToken.None); + Assert.True(workflow.BlocksStartup); + } + + [Fact] + public async Task InspectionFailure_CannotDropAReceiptTheCallerAlreadyObserved() + { + // The startup guard inspects first; a failing second pass inside the workflow must not + // discard what that first pass proved. + var seed = new StoreMigrationStartupDecision( + StoreMigrationStartupState.InspectionFailed, null, HoldsCompletionReceipt: true); + var workflow = new StoreMigrationWorkflow( + new Operations { InspectError = new IOException("locked") }, NullLogger.Instance, seed); + await workflow.StartAsync(CancellationToken.None); + Assert.True(workflow.BlocksStartup); + } + + [Fact] + public async Task CorruptConsent_IsPreservedAndCannotLoopThroughConfirmation() + { + var operations = new Operations { GrantError = new InvalidDataException("corrupt consent") }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + await workflow.ContinueAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.Recovery, workflow.Stage); + operations.Calls.Clear(); + + // Recovery is retryable, so a retry re-inspects. What it must never do is treat the + // earlier confirmation as still standing and grant again over the preserved record. + await workflow.ContinueAsync(CancellationToken.None); + + Assert.DoesNotContain("grant", operations.Calls); + Assert.Equal(StoreMigrationStage.Consent, workflow.Stage); + } + + [Fact] + public async Task Recovery_RetriesInsteadOfStranding() + { + var operations = new Operations { Admission = new(StoreMigrationStartupState.RecoveryRequired) }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.Recovery, workflow.Stage); + // The remedy for the usual cause is removing the previous app, which only a fresh pass sees. + operations.Admission = new(StoreMigrationStartupState.NotRequired); + operations.Calls.Clear(); + + await workflow.ContinueAsync(CancellationToken.None); + + Assert.Contains("inspect", operations.Calls); + Assert.Equal(StoreMigrationStage.Ready, workflow.Stage); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DiscardIsOfferedOnlyForRecordsThatCannotBeRead(bool unreadable) + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.RecoveryRequired), + Unreadable = unreadable + }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.Recovery, workflow.Stage); + Assert.Equal(unreadable, workflow.CanDiscardRecords); + } + + [Fact] + public async Task FailingToClassifyRecords_HidesTheDiscardOfferInsteadOfFailing() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.RecoveryRequired), + UnreadableError = new UnauthorizedAccessException("denied") + }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.Recovery, workflow.Stage); + Assert.False(workflow.CanDiscardRecords); + } + + [Fact] + public async Task DiscardingUnreadableRecords_ReleasesTheStartupBlockAndReinspects() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.RecoveryRequired, null, true), + Unreadable = true + }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.True(workflow.BlocksStartup); + operations.Calls.Clear(); + + await workflow.DiscardRecordsAsync(CancellationToken.None); + + Assert.Equal(new[] { "discard", "inspect" }, operations.Calls); + Assert.Equal(StoreMigrationStage.Ready, workflow.Stage); + Assert.False(workflow.BlocksStartup); + } + + [Fact] + public async Task AFailedDiscard_KeepsTheReceiptHoldAndStaysInRecovery() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.RecoveryRequired, null, true), + Unreadable = true, + Discarded = StoreMigrationDiscardState.Busy + }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + operations.Calls.Clear(); + + await workflow.DiscardRecordsAsync(CancellationToken.None); + + Assert.Equal(new[] { "discard" }, operations.Calls); + Assert.Equal(StoreMigrationStage.Recovery, workflow.Stage); + Assert.True(workflow.BlocksStartup); + // A contended discard is worth retrying, so the offer stays. + Assert.True(workflow.CanDiscardRecords); + // The usual cause is the previous app still holding the lease, which the user can act on + // only if the window says so. A silent no-op makes the button look dead. + Assert.Equal(StoreMigrationDiscardState.Busy, workflow.LastDiscard); + } + + [Fact] + public async Task ARetryAfterAContendedDiscard_ClearsTheReport() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.RecoveryRequired, null, true), + Unreadable = true, + Discarded = StoreMigrationDiscardState.Busy + }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + await workflow.DiscardRecordsAsync(CancellationToken.None); + Assert.Equal(StoreMigrationDiscardState.Busy, workflow.LastDiscard); + + await workflow.ContinueAsync(CancellationToken.None); + + // Otherwise the discard message outlives the screen its button belongs to. + Assert.Null(workflow.LastDiscard); + } + + [Fact] + public async Task ASuccessfulDiscard_LeavesNothingToReport() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.RecoveryRequired, null, true), + Unreadable = true + }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Null(workflow.LastDiscard); + + await workflow.DiscardRecordsAsync(CancellationToken.None); + + // Nothing for the window to report: the re-inspection that follows a successful discard + // supersedes the result. + Assert.Null(workflow.LastDiscard); + Assert.Equal(StoreMigrationStage.Ready, workflow.Stage); + } + + [Fact] + public async Task DiscardingReadableRecords_IsRefusedAndWithdrawsTheOffer() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.RecoveryRequired, null, true), + Unreadable = true, + Discarded = StoreMigrationDiscardState.RecordsAreReadable + }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + + await workflow.DiscardRecordsAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.Recovery, workflow.Stage); + Assert.True(workflow.BlocksStartup); + Assert.False(workflow.CanDiscardRecords); + } + + [Fact] + public async Task DiscardIsRefusedOutsideRecovery() + { + var operations = new Operations { Unreadable = true }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.Consent, workflow.Stage); + operations.Calls.Clear(); + + await workflow.DiscardRecordsAsync(CancellationToken.None); + + Assert.Empty(operations.Calls); + Assert.Equal(StoreMigrationStage.Consent, workflow.Stage); + } + + [Fact] + public async Task ConcurrentClicks_DoNotDuplicatePreparation() + { + var pause = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var operations = new Operations { Consent = true, CloseTask = pause.Task }; + var workflow = Create(operations); + var first = workflow.StartAsync(CancellationToken.None); + await workflow.ContinueAsync(CancellationToken.None); + Assert.Equal(1, operations.Calls.Count(call => call == "inspect")); + pause.SetResult(true); + await first; + Assert.Equal(1, operations.Calls.Count(call => call == "complete")); + } + + [Fact] + public async Task CancelledBeforePreparation_DoesNotCommit() + { + using var cancellation = new CancellationTokenSource(); + var pause = new TaskCompletionSource(); + var operations = new Operations { Consent = true, CloseTask = pause.Task }; + var workflow = Create(operations); + var work = workflow.StartAsync(cancellation.Token); + cancellation.Cancel(); + pause.SetResult(true); + await work; + Assert.DoesNotContain("prepare", operations.Calls); + Assert.NotEqual(StoreMigrationStage.Ready, workflow.Stage); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("9EXAMPLE00000&x=1")] + [InlineData("https://example.com")] + [InlineData("9example00000")] + public void StoreListing_RejectsAbsentOrUntrustedIdentifiers(string? productId) => + Assert.False(StoreMigrationListing.TryCreateUri(productId, out _)); + + [Fact] + public void StoreListing_UsesOnlyExplicitProductId() + { + Assert.True(StoreMigrationListing.TryCreateUri("9EXAMPLE0000", out var uri)); + Assert.Equal("ms-windows-store://pdp/?ProductId=9EXAMPLE0000", uri!.AbsoluteUri); + } + + private static StoreMigrationWorkflow Create(Operations operations) => new(operations, NullLogger.Instance); + + private sealed class Operations : IStoreMigrationOperations + { + public List Calls { get; } = []; + public StoreMigrationStartupDecision Admission { get; set; } = new(StoreMigrationStartupState.ConsentRequired, + new InnoInstallation(@"C:\fixture", @"C:\fixture\app.exe", @"C:\fixture\unins000.exe", "x64", new Version(2026, 9, 5))); + public bool Consent { get; set; } + public bool Closed { get; set; } = true; + public Task? CloseTask { get; set; } + public Exception? InspectError { get; set; } + public bool Receipt { get; set; } + public Exception? ReceiptError { get; set; } + public Exception? GrantError { get; set; } + public StoreMigrationPreparationState Prepared { get; set; } = StoreMigrationPreparationState.Prepared; + public StoreMigrationCompletionState Completed { get; set; } = StoreMigrationCompletionState.Completed; + public StoreMigrationFinalizationState Finalized { get; set; } = StoreMigrationFinalizationState.Finalized; + public bool FinalizedSourceRemoved { get; set; } + public StoreMigrationStartupDecision Inspect() + { + Calls.Add("inspect"); + if (InspectError is not null) throw InspectError; + return Admission; + } + public bool HasConsent(InnoInstallation installation) { Calls.Add("consent?"); return Consent; } + public bool HoldsCompletionReceipt() + { + Calls.Add("receipt?"); + if (ReceiptError is not null) throw ReceiptError; + return Receipt; + } + public void GrantConsent(InnoInstallation installation) + { + Calls.Add("grant"); + if (GrantError is not null) throw GrantError; + Consent = true; + } + public Task CloseSourceAsync(StoreMigrationStartupDecision admission, CancellationToken cancellationToken) + { Calls.Add("close"); return CloseTask ?? Task.FromResult(Closed); } + public Task PrepareAsync(InnoInstallation installation) + { Calls.Add("prepare"); return Task.FromResult(Prepared); } + public Task CompleteAsync(InnoInstallation installation) + { Calls.Add("complete"); return Task.FromResult(Completed); } + public Task FinalizeAsync() + { Calls.Add("finalize"); return Task.FromResult(new StoreMigrationFinalizationDecision(Finalized, FinalizedSourceRemoved)); } + public bool Unreadable { get; set; } + public Exception? UnreadableError { get; set; } + public StoreMigrationDiscardState Discarded { get; set; } = StoreMigrationDiscardState.Discarded; + public bool CanDiscardRecords() + { + if (UnreadableError is not null) throw UnreadableError; + return Unreadable; + } + public StoreMigrationDiscardState DiscardUnreadableRecords() + { + Calls.Add("discard"); + if (Discarded == StoreMigrationDiscardState.Discarded) + { + Unreadable = false; + Admission = new(StoreMigrationStartupState.NotRequired); + } + return Discarded; + } + } +} diff --git a/tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj b/tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj index 185e5e045..95278d4ba 100644 --- a/tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj +++ b/tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj @@ -57,4 +57,19 @@ + + + + <_TrayTestResourceRoot Include="@(ReferenceCopyLocalPaths)" + Condition="'%(Filename)' == 'OpenClaw.Tray.WinUI' and '%(Extension)' == '.dll'" /> + + + + + + diff --git a/tests/OpenClaw.Tray.UITests/StoreMigrationWindowProofTests.cs b/tests/OpenClaw.Tray.UITests/StoreMigrationWindowProofTests.cs new file mode 100644 index 000000000..3a7448f33 --- /dev/null +++ b/tests/OpenClaw.Tray.UITests/StoreMigrationWindowProofTests.cs @@ -0,0 +1,749 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Automation.Peers; +using Microsoft.UI.Xaml.Automation.Provider; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; +using OpenClawTray.Helpers; +using OpenClawTray.Services; +using OpenClawTray.Windows; +using WinUIEx; +using Xunit.Abstractions; + +namespace OpenClaw.Tray.UITests; + +/// +/// Mounted production-window proof with in-memory operations only, not migration/package proof. +/// Set OPENCLAW_VISUAL_TEST=1 and OPENCLAW_VISUAL_TEST_DIR to capture rendered states. +/// Never invokes Installed apps or constructs the production migration operations. +/// +[Collection(UICollection.Name)] +public sealed class StoreMigrationWindowProofTests(UIThreadFixture ui, ITestOutputHelper output) +{ + [Fact] + public Task Consent_AssignsNativeIconsForTaskbarAndWindowSwitcher() + { + return WithWindowAsync(new Operations(), async (window, workflow, completion) => + { + await WaitForStageAsync(workflow, StoreMigrationStage.Consent); + await ui.RunOnUIAsync(() => + { + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(window); + const uint wmGetIcon = 0x007f; + foreach (var iconType in new nuint[] { 0, 1 }) + { + var icon = SendMessage(hwnd, wmGetIcon, iconType, 0); + Assert.NotEqual(nint.Zero, icon); + output.WriteLine($"Native window icon type {iconType}: nonzero handle."); + } + }); + await InvokeAsync(window, "Dismiss"); + // Not now leaves the previous app installed, so the Store app must stay inactive. + Assert.False(await completion.WaitAsync(TimeSpan.FromSeconds(10))); + }); + } + + [DllImport("user32.dll", EntryPoint = "SendMessageW", ExactSpelling = true)] + private static extern nint SendMessage(nint hwnd, uint message, nuint wParam, nint lParam); + + [Fact] + public Task Consent_DefaultsToNotNow_AndDismissesWithoutMigration() + { + var operations = new Operations(); + return WithWindowAsync(operations, async (window, workflow, completion) => + { + await WaitForStageAsync(workflow, StoreMigrationStage.Consent); + await ui.RunOnUIAsync(() => + { + AssertState(window, "Consent", consent: true); + var root = Root(window); + Assert.Same(Control internal sealed class TestApp : Application, IXamlMetadataProvider { - // Native control templates need the same WinUI metadata delegation as an - // app generated by the XAML compiler, even in this unpackaged test host. - private readonly Microsoft.UI.Xaml.XamlTypeInfo.XamlControlsXamlMetaDataProvider _metadata = new(); + // Compiled production XAML needs its generated metadata even though tests + // do not construct App. This provider also delegates native WinUI templates. + private readonly OpenClawTray.OpenClaw_Tray_WinUI_XamlTypeInfo.XamlMetaDataProvider _metadata = new(); public IXamlType GetXamlType(Type type) => _metadata.GetXamlType(type); + public IXamlType GetXamlType(string fullName) => _metadata.GetXamlType(fullName); + public XmlnsDefinition[] GetXmlnsDefinitions() => _metadata.GetXmlnsDefinitions(); private static readonly (string Key, Windows.UI.Color Color)[] FluentBrushFallbacks = diff --git a/tests/OpenClaw.Tray.UITests/UIThreadFixture.cs b/tests/OpenClaw.Tray.UITests/UIThreadFixture.cs index a3f45e19e..24f70314b 100644 --- a/tests/OpenClaw.Tray.UITests/UIThreadFixture.cs +++ b/tests/OpenClaw.Tray.UITests/UIThreadFixture.cs @@ -220,6 +220,10 @@ private void UIThreadProc() _startupPhase = "getting dispatcher"; Dispatcher = DispatcherQueue.GetForCurrentThread(); + // Match generated WinUI startup so awaited event handlers and + // rendering helpers resume on this UI thread, not the thread pool. + SynchronizationContext.SetSynchronizationContext( + new DispatcherQueueSynchronizationContext(Dispatcher)); _startupPhase = "creating hidden test window"; Container = new Grid { Padding = new Microsoft.UI.Xaml.Thickness(24) };