diff --git a/.github/workflows/windows-portable-e2e.yml b/.github/workflows/windows-portable-e2e.yml new file mode 100644 index 0000000000..5cce3408a3 --- /dev/null +++ b/.github/workflows/windows-portable-e2e.yml @@ -0,0 +1,155 @@ +name: Windows portable E2E + +on: + workflow_dispatch: + inputs: + require_taskbar_pin: + description: "Fail unless the selected Windows runner can pin the app" + required: false + default: false + type: boolean + pull_request: + paths: + - "apps/desktop/package.json" + - "apps/desktop/test/auto-update.test.mjs" + - "scripts/windows-portable-e2e.ps1" + - ".github/workflows/windows-portable-e2e.yml" + +permissions: + contents: read + +concurrency: + group: windows-portable-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + portable: + name: Windows portable package and taskbar smoke + runs-on: windows-latest + timeout-minutes: 60 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Build package inputs + run: | + set -euo pipefail + cargo build --release --locked -p host-core & + host_build_pid=$! + trap 'kill "$host_build_pid" 2>/dev/null || true' EXIT + + pnpm install --frozen-lockfile + pnpm --filter '@pi-desktop/desktop^...' --fail-if-no-match build + + wait "$host_build_pid" + trap - EXIT + + - name: Build Windows packages + run: pnpm --filter @pi-desktop/desktop run dist:win -- --x64 + + - name: Verify Windows release artifacts + shell: pwsh + run: | + $release = "apps/desktop/release" + $nsis = @(Get-ChildItem -LiteralPath $release -Filter "PI-Desktop-Setup-*.exe" -File) + $portable = @(Get-ChildItem -LiteralPath $release -Filter "PI-Desktop-Portable-*.exe" -File) + $feedPath = Join-Path $release "latest.yml" + if ($nsis.Count -ne 1) { + throw "Expected exactly one NSIS installer, found $($nsis.Count)" + } + if ($portable.Count -ne 1) { + throw "Expected exactly one portable executable, found $($portable.Count)" + } + if (-not (Test-Path -LiteralPath $feedPath -PathType Leaf)) { + throw "NSIS update feed was not produced: $feedPath" + } + foreach ($artifact in @($nsis + $portable)) { + if ($artifact.Name -match "\s") { + throw "Windows artifact name contains whitespace: $($artifact.Name)" + } + } + $feed = Get-Content -LiteralPath $feedPath -Raw + if ($feed -notmatch "PI-Desktop-Setup-") { + throw "latest.yml does not reference the NSIS installer" + } + if ($feed -match "PI-Desktop-Portable-") { + throw "latest.yml must not reference the portable executable" + } + Write-Host "NSIS_ARTIFACT=$($nsis[0].Name)" + Write-Host "PORTABLE_ARTIFACT=$($portable[0].Name)" + Write-Host "UPDATE_FEED=$feedPath" + + - name: Preserve first portable build + run: | + set -euo pipefail + mkdir -p apps/desktop/release/build-a + mv apps/desktop/release/PI-Desktop-Portable-*.exe apps/desktop/release/build-a/ + + - name: Build second portable version from the same source + working-directory: apps/desktop + run: >- + pnpm exec electron-builder --win portable --x64 --publish never + -c.extraMetadata.version=0.15.2-test-b + -c.directories.output=release/build-b + + - name: Run portable extraction and taskbar smoke + shell: pwsh + env: + REQUIRE_TASKBAR_PIN: ${{ inputs.require_taskbar_pin }} + run: | + $portable = Get-ChildItem "apps/desktop/release/build-a/PI-Desktop-Portable-*.exe" | + Select-Object -First 1 + $secondPortable = Get-ChildItem "apps/desktop/release/build-b/PI-Desktop-Portable-*.exe" | + Select-Object -First 1 + if ($null -eq $portable -or $null -eq $secondPortable) { + throw "Both Windows portable artifacts were not produced" + } + $arguments = @{ + PortableExe = $portable.FullName + SecondPortableExe = $secondPortable.FullName + } + if ([string]::Equals([string]$env:REQUIRE_TASKBAR_PIN, "true", [StringComparison]::OrdinalIgnoreCase)) { + $arguments.RequireTaskbarPin = $true + } + $output = @() + $scriptSucceeded = $false + try { + $output = @(& ./scripts/windows-portable-e2e.ps1 @arguments *>&1) + $scriptSucceeded = $? + } catch { + $output += $_ + } + $outputText = @($output | ForEach-Object { "$_" }) + $outputText | + Tee-Object -FilePath windows-portable-e2e.log + if (-not $scriptSucceeded -or $outputText -notcontains "WINDOWS_PORTABLE_E2E=PASS") { + exit 1 + } + + - name: Upload portable smoke logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-portable-e2e-release + if-no-files-found: warn + retention-days: 7 + path: | + apps/desktop/release/build-a/PI-Desktop-Portable-*.exe + apps/desktop/release/build-b/PI-Desktop-Portable-*.exe + apps/desktop/release/PI-Desktop-Setup-*.exe + apps/desktop/release/PI-Desktop-Setup-*.exe.blockmap + apps/desktop/release/latest.yml + windows-portable-e2e.log diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a62d25f21e..e9fa743c31 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -229,7 +229,8 @@ }, "portable": { "artifactName": "PI-Desktop-Portable-${version}.${ext}", - "requestExecutionLevel": "user" + "requestExecutionLevel": "user", + "unpackDirName": "PI-Desktop-Portable" }, "deb": { "packageName": "pi-desktop", diff --git a/apps/desktop/test/auto-update.test.mjs b/apps/desktop/test/auto-update.test.mjs index 272082182c..73da39beaf 100644 --- a/apps/desktop/test/auto-update.test.mjs +++ b/apps/desktop/test/auto-update.test.mjs @@ -264,6 +264,11 @@ test("packaging publishes an electron-updater feed for GitHub Releases", () => { "PI-Desktop-Portable-${version}.${ext}", ); assert.equal(pkg.build.portable.requestExecutionLevel, "user"); + assert.equal( + pkg.build.portable.unpackDirName, + "PI-Desktop-Portable", + "portable extraction path stays stable for Windows taskbar identity", + ); // The upload step must carry every updater feed, and the release publishes // all platforms unfiltered (D126/D285). assert.match(releaseWorkflowSource, /release\/\*\.zip/); diff --git a/docs/adr/0197-windows-portable-exe.md b/docs/adr/0197-windows-portable-exe.md index 1cb17c1d5c..c3ff05c0bb 100644 --- a/docs/adr/0197-windows-portable-exe.md +++ b/docs/adr/0197-windows-portable-exe.md @@ -16,7 +16,10 @@ ownership or to the NSIS in-app update lane. electron-builder's `portable` target produces a user-level self-extracting executable. It does not write `latest.yml`. Applying the NSIS updater to a portable run would launch the installer and convert the no-install copy into -an installed one. +an installed one. In electron-builder 26, the default extraction directory is +a build-time ksuid: it is stable for one artifact, but changes between builds. +The taskbar fix therefore needs an explicit name, while also accounting for +the portable launcher's cleanup and shared-directory behavior. ## Decision @@ -32,6 +35,12 @@ an installed one. quit-and-install. 6. User data, logs, and secrets stay in the existing application data directory. This decision does not introduce a beside-the-exe profile. +7. The portable target uses the fixed per-user temp directory name + `PI-Desktop-Portable` for extraction. The packaged executable therefore + resolves to `%TEMP%\PI-Desktop-Portable\PI-Desktop.exe` while running and + on the next launch, including across portable builds. The launcher still + removes that directory before extraction and after the app exits; this is a + stable runtime identity, not a persistent installation or pin target. ## Consequences @@ -41,9 +50,18 @@ an installed one. replace the portable file themselves. - NSIS in-app updates, hashes, and feed ownership are unchanged. - The portable process still unpacks application files under the Windows temp - directory for that launch. Whitelisting applies to the downloaded portable + directory for that launch, using the stable `PI-Desktop-Portable` directory + name. The launcher deletes the directory on exit, so a taskbar pin that + targets the unpacked executable may be missing or show a blank icon while + the app is stopped; the next launch recreates the path and restores the + running-window identity. Whitelisting applies to the downloaded portable executable; a policy that also blocks temp-directory execution may still require the NSIS install. +- All portable wrappers for one user, including different versions, share the + extraction directory. Do not launch two portable wrappers concurrently or + replace one while another is running: the second launcher can remove or + overwrite the first launch's files before Electron's single-instance lock + is acquired. An installed NSIS copy uses a separate tree. ## Alternatives considered diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index bb5ad75e32..d1b64f6a52 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -11279,28 +11279,49 @@ are withdrawn with ADR 0165. #### E2E-211: Windows portable exe launches without an installer (D364) -- **Preconditions**: A Windows x64 tag or `dist:win` package has produced both - `PI-Desktop-Setup-.exe` and `PI-Desktop-Portable-.exe` from - the shared electron-builder config; a clean user profile is available; the - account is a standard user without administrator elevation. -- **Steps**: 1) Inspect the release directory and `latest.yml`. 2) Launch the - portable exe without running the NSIS installer. 3) Confirm the process - environment includes `PORTABLE_EXECUTABLE_FILE`. 4) Invoke Check for Updates. - 5) Confirm Settings → Info offers the releases page rather than Restart to - update. 6) Quit and relaunch the same portable file. +- **Preconditions**: Windows x64 portable packages from two builds are + available (A and B), alongside the matching NSIS package and `latest.yml`; + a clean user profile is available; the account is a standard user without + administrator elevation. +- **Steps**: 1) Inspect the release directory and `latest.yml`. 2) Launch + portable package A without running the NSIS installer. 3) Confirm the process + environment includes `PORTABLE_EXECUTABLE_FILE` and the unpacked app path is + `%TEMP%\PI-Desktop-Portable\PI-Desktop.exe`. 4) Pin the running app to the + taskbar and record the pinned target and icon. 5) Quit A before launching + anything else; confirm the launcher removes the extraction directory and + record whether the stopped pin is missing or blank. 6) Relaunch A and confirm + the same path is recreated, the running icon is correct, and the pin resolves + again. 7) After A has exited, launch package B and confirm it uses the same + fixed path. 8) In an isolated run, start B while A is still running and + capture the shared-directory removal/overwrite risk; terminate both and mark + concurrent portable wrappers unsupported. 9) Invoke Check for Updates. 10) + Confirm Settings → Info offers the releases page rather than Restart to update. - **Expected**: Both Windows artifacts are space-free and uploaded. `latest.yml` - points at the NSIS installer only. The portable exe starts without a setup + points at the NSIS installer only. Each portable exe starts without a setup wizard or administrator prompt, uses the existing application data directory, and reports update mode `manual`. An available update does not download or - run `PI-Desktop-Setup-.exe`. Relaunch restores sessions from that - same profile. + run `PI-Desktop-Setup-.exe`. The explicit unpack path is stable + across builds and launches, so the running taskbar grouping and icon lookup + use the same stable path rather than a new build-time ksuid; running-window + grouping remains owned by the app user model ID. The launcher removes + that path on exit, so the stopped pin may be unavailable or blank; relaunch + recreates the path and restores the running icon/pin target. A second portable + wrapper must not run concurrently because it can remove or overwrite the + first wrapper's shared extraction directory. Relaunch restores sessions from + the same profile. - **Specs linked**: `01-product/01-product-scope.md`, `06-delivery/06-release-runbook.md`, `03-runtime/07-process-model.md`, ADR 0197 / D364 - **Acceptance**: Quality (release packaging) - **Milestone**: M6+ -- **Status**: Unit/source-contract covered (`auto-update.test.mjs`); native - Windows launch remains runner validation (run only in a capable environment when this surface changes) +- **Status**: Packaging contract is unit-covered; `Windows portable E2E` workflow + automates the native NSIS/portable artifact checks, extraction, cleanup, + relaunch, cross-build, and concurrency smoke. The hosted-runner default records + taskbar pin qualification as `NOT_RUN` when no Explorer pin verb is available. + Provision an interactive Windows runner for this workflow (or run the script + there) and enable `require_taskbar_pin=true` to complete native taskbar + qualification; the default `windows-latest` runner does not provide that + capability reliably. #### E2E-213: The first Composer model menu paint keeps configured aliases diff --git a/docs/spec/06-delivery/06-release-runbook.md b/docs/spec/06-delivery/06-release-runbook.md index 9d3cf83731..3cebc75126 100644 --- a/docs/spec/06-delivery/06-release-runbook.md +++ b/docs/spec/06-delivery/06-release-runbook.md @@ -594,7 +594,14 @@ The portable Windows target does not write `latest.yml`. Packaged portable runs use notify-and-link delivery (`PORTABLE_EXECUTABLE_FILE`); NSIS keeps the in-app download and quit-and-install lane. Data stays in the existing application data directory. Portable requests user execution level, so launch -does not require administrator rights. +does not require administrator rights. Its self-extracted app uses the fixed +per-user temp path `%TEMP%\PI-Desktop-Portable\PI-Desktop.exe`, so the running +executable identity is stable across builds and launches. The launcher removes +that directory before extraction and after exit; a pin targeting the unpacked +file can therefore be missing or blank while the app is stopped and is only +re-established when the app launches again. All portable wrappers for one user +share the path, so do not run two versions concurrently; an installed NSIS copy +uses a separate tree. RPM targets pass `_build_id_links none` to FPM. Bundled Electron binaries live under `/opt/PI-Desktop`; omitting global `/usr/lib/.build-id` links prevents diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index ede070476b..a57d51e4c5 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -463,7 +463,7 @@ section mirrors only marketplace/catalog items still blocking nothing. | ID | Topic | Decision | Rationale | |---|---|---|---| | D126 | Three-platform release delivery (lifts D010) | *(amended by D364)* **Tag builds publish every artifact the matrix produces to the GitHub Release: macOS dmg/zip (arm64), Windows NSIS x64, Linux AppImage + deb (x64), each with blockmaps and the platform's `latest*.yml` electron-updater feed. Publishing the feeds activates D120's in-app update lanes for Windows NSIS and Linux AppImage; macOS stays in notify-and-link mode until a signed channel is qualified. The NSIS artifact name is pinned space-free (`PI-Desktop-Setup-${version}.${ext}`) because GitHub asset URLs mangle spaces. D010's macOS-only scope is lifted per the baseline-bump rule (baseline `0.4.7`); the release pipeline itself was qualified end-to-end on v0.1.1-rc.1/v0.1.1.** | The pipeline builds and validates all three platforms on every tag anyway; keeping installers as expiring Actions artifacts (90-day retention) withheld them from users without adding safety. Publishing the update feeds is the point of shipping: platforms with in-app lanes update silently, and future platform regressions surface through real installs instead of unused artifacts. | -| D364 | Windows portable exe without installer | **Amend D120 / D126 / ADR 0022: tag builds publish a Windows x64 portable exe `PI-Desktop-Portable-${version}.exe` alongside the NSIS installer `PI-Desktop-Setup-${version}.exe`. The portable target does not write `latest.yml`. Packaged portable runs (`PORTABLE_EXECUTABLE_FILE`) use notify-and-link delivery. NSIS installs keep in-app download and quit-and-install. Data stays in the existing application data directory. Portable requests user execution level.** | Company environments that whitelist a single executable cannot run the NSIS installer. Applying the NSIS updater to a portable run would install the app, so portable stays manual (ADR 0197, E2E-211). | +| D364 | Windows portable exe without installer | **Amend D120 / D126 / ADR 0022: tag builds publish a Windows x64 portable exe `PI-Desktop-Portable-${version}.exe` alongside the NSIS installer `PI-Desktop-Setup-${version}.exe`. The portable target does not write `latest.yml`. Packaged portable runs (`PORTABLE_EXECUTABLE_FILE`) use notify-and-link delivery. NSIS installs keep in-app download and quit-and-install. Data stays in the existing application data directory. Portable requests user execution level. The target sets `unpackDirName` to `PI-Desktop-Portable`, so the running executable resolves to `%TEMP%\PI-Desktop-Portable\PI-Desktop.exe` across builds and launches. The launcher deletes that directory before extraction and after exit, so a stopped pin may be blank until relaunch. All portable wrappers for one user, including different versions, share the directory and must not run concurrently.** | Company environments that whitelist a single executable cannot run the NSIS installer. Applying the NSIS updater to a portable run would install the app, so portable stays manual (ADR 0197, E2E-211). The explicit path keeps the running taskbar identity consistent across builds, while the launcher cleanup and shared directory make stopped pins and concurrent wrappers known limitations. | | D260 | Release documentation is a version surface | **A stable version bump must update every version-bearing surface before the tag: the shipped-locale in-app changelog and its test list, every workspace `package.json` including `docs/package.json` (a third workspace root `scripts/release.mjs` previously skipped), the Cargo workspace version and `host-core` lockfile entry, `APP_VERSION`, and the `..x` release line stated in `README.md` and `README.zh-CN.md`. `scripts/check-release-docs.mjs` verifies all of them; `scripts/release.mjs` runs it after bumping and refuses to commit or tag while any surface disagrees, with `--skip-docs-check` reserved for deliberate non-release bumps. Extends D164.** | The in-app changelog gate alone left published documentation behind: the READMEs still advertised the `0.5.x` line at `0.10.8`, and `docs/package.json` sat at `0.5.8`. A tag is irreversible, so the check runs before the tag exists rather than as review etiquette. | | D371 | Explicit unsigned macOS first-launch helper | *(amended by D406 and D443)* **Every macOS distribution ships an executable `PI-Desktop-macOS-open.command`, placed on the DMG in a visible first-launch row below the drag-to-Applications gesture. It searches only `/Applications/PI-Desktop.app` and `~/Applications/PI-Desktop.app`, verifies `CFBundleIdentifier` is `net.aiuo.pi-desktop`, removes only `com.apple.quarantine` when present, and opens the app. It never uses `sudo`, accepts no arbitrary path, and does not replace Developer ID signing or notarization.** | The unsigned macOS lane can be blocked by quarantine with a misleading damaged-app message, and the terminal-only `xattr -cr` note was broader than the launch failure requires (ADR 0204, E2E-196b) | | D443 | Canonical application ID and macOS codesign identifier | **Amend D141 / D371 / ADR 0204: the application ID is `net.aiuo.pi-desktop` (`APP_ID`, electron-builder `appId`, macOS `CFBundleIdentifier`, Windows AppUserModelID). Development macOS hosts use `net.aiuo.pi-desktop.dev`. Do not adhoc-sign the unsigned pack in `afterPack`/`afterSign`: nested Electron helpers are still unsigned and `codesign` fails with `code object is not signed at all`. See ADR 0278 and issue #524.** | The owner domain is `net.aiuo.pi-desktop`. Binding the unsigned outer identifier is deferred until a helper-safe pack path exists. | @@ -4218,7 +4218,13 @@ D193, and D194. portable target does not write `latest.yml`. Packaged portable runs (`PORTABLE_EXECUTABLE_FILE`) use notify-and-link delivery; NSIS keeps in-app download and quit-and-install. Data stays in the existing application - data directory. Portable requests user execution level. + data directory. Portable requests user execution level. The target sets + `unpackDirName` to `PI-Desktop-Portable`, so the running executable resolves + to `%TEMP%\PI-Desktop-Portable\PI-Desktop.exe` across builds and launches. + The launcher deletes that directory before extraction and after exit, so a + stopped pin may be blank until relaunch. All portable wrappers for one user, + including different versions, share the directory and must not run + concurrently. - See ADR 0197 and E2E-211. ## 2026-09-09 — Name every quiet interval on the live activity row (D365) @@ -6456,4 +6462,3 @@ that was sitting at the bottom — including after the turn had finished. `agent` log channels. Nothing is uploaded. See `03-runtime/07-process-model.md` §4, `03-runtime/09-logging-and-observability.md`, and `03-runtime/04-data-storage.md`. - diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index f2260c45ef..5026ce6254 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -6903,23 +6903,32 @@ IPC 请求无法关闭。 - **状态**:源码契约已覆盖;全新 Windows x64 与 ARM64 资格验证仍需运行器验证(适用变更合入前需在具备条件的环境中运行 E2E) #### E2E-211:Windows 便携版 exe 无需安装即可启动(D364) -- **前提条件**:Windows x64 标签或 `dist:win` 包已从共享 electron-builder 配置 - 产出 `PI-Desktop-Setup-.exe` 和 `PI-Desktop-Portable-.exe`; - 有干净用户配置;账户是无需管理员提升的标准用户。 -- **步骤**:1) 检查发布目录和 `latest.yml`。2) 不运行 NSIS 安装程序,直接启动 - 便携版 exe。3) 确认进程环境包含 `PORTABLE_EXECUTABLE_FILE`。4) 调用检查更新。 - 5) 确认设置 → 信息提供发布页而不是“重启以更新”。6) 退出并再次启动同一便携文件。 +- **前提条件**:有来自两个构建的 Windows x64 便携包(A、B),以及对应的 NSIS + 安装程序和 `latest.yml`;有干净用户配置;账户是无需管理员提升的标准用户。 +- **步骤**:1) 检查发布目录和 `latest.yml`。2) 不运行 NSIS 安装程序,直接启动便携包 A。 + 3) 确认进程环境包含 `PORTABLE_EXECUTABLE_FILE`,解压后的应用路径为 + `%TEMP%\PI-Desktop-Portable\PI-Desktop.exe`。4) 将运行中的应用固定到任务栏并记录固定目标和图标。 + 5) 退出 A 后再启动其它程序;确认启动器删除解压目录,并记录停止期间固定项是否消失或显示空白。 + 6) 再次启动 A,确认同一路径被重新创建、运行中图标正确且固定目标恢复。7) A 退出后启动包 B, + 确认它使用同一个固定路径。8) 在隔离运行中让 A 仍运行时启动 B,记录共享目录被删除/覆盖的风险;终止两者并将 + 同时启动便携启动器标记为不支持。9) 调用检查更新。10) 确认设置 → 信息提供发布页而不是“重启以更新”。 - **预期**:两个 Windows 工件都无空格并已上传。`latest.yml` 只指向 NSIS 安装程序。 - 便携版 exe 无需安装向导或管理员提示即可启动,使用现有应用数据目录, - 并报告更新模式 `manual`。可用更新不会下载或运行 - `PI-Desktop-Setup-.exe`。再次启动从同一配置恢复会话。 + 每个便携包无需安装向导或管理员提示即可启动,使用现有应用数据目录,并报告更新模式 `manual`。 + 可用更新不会下载或运行 `PI-Desktop-Setup-.exe`。显式解压路径在不同构建和多次启动间保持稳定, + pin 目标和图标查找使用同一个固定路径,而不是新的构建 ksuid;运行窗口分组仍由应用程序用户模型 ID 负责。启动器会在解压前和退出后删除该路径, + 因此停止期间固定项可能不可用或为空白;重新启动会重建路径并恢复运行中图标/固定目标。同一用户的第二个便携 + 启动器不得并发运行,否则可能删除或覆盖第一个启动器的共享解压目录。再次启动从同一配置恢复会话。 - **链接规格**:`01-product/01-product-scope.md`、 `06-delivery/06-release-runbook.md`、`03-runtime/07-process-model.md`、 ADR 0197 / D364 - **验收**:质量(发布打包) - **里程碑**:M6+ -- **状态**:单元/源合同已覆盖(`auto-update.test.mjs`);本机 Windows 启动仍为 - 运行器验证(除非明确要求,否则不要在本地跑 E2E) +- **状态**:打包契约由单元测试覆盖;`Windows portable E2E` 工作流自动执行 + NSIS/便携版工件检查、解压、清理、重新启动、跨构建和并发 smoke。若 hosted + runner 没有 Explorer 固定命令,默认运行会将任务栏固定资格记录为 + `NOT_RUN`。要完成原生任务栏资格验证,需要为该工作流配置具备交互式桌面会话的 + Windows 运行器(或直接在那里运行脚本),并设置 `require_taskbar_pin=true`;默认的 + `windows-latest` 运行器不能可靠地提供这一能力。 #### E2E-201:为已配置模型设置别名并复制模型 id diff --git a/docs/zh-CN/spec/06-delivery/06-release-runbook.md b/docs/zh-CN/spec/06-delivery/06-release-runbook.md index 6a81eab7d6..3628933974 100644 --- a/docs/zh-CN/spec/06-delivery/06-release-runbook.md +++ b/docs/zh-CN/spec/06-delivery/06-release-runbook.md @@ -507,7 +507,10 @@ Native-runner 输出矩阵: 便携版 Windows 目标不会写入 `latest.yml`。已打包的便携版运行使用通知加链接 交付(`PORTABLE_EXECUTABLE_FILE`);NSIS 仍走应用内下载并在退出时安装。 -数据仍在现有应用数据目录。便携版请求 user 执行级别,因此启动不需要管理员权限。 +数据仍在现有应用数据目录。便携版请求 user 执行级别,因此启动不需要管理员权限。自解压应用运行时使用固定路径 +`%TEMP%\PI-Desktop-Portable\PI-Desktop.exe`,使不同构建和多次启动的运行中身份保持稳定。启动器会在解压前和退出后 +删除该目录;因此指向解压文件的任务栏固定项在应用停止期间可能消失或显示空白,重新启动后才会恢复。相同用户的所有 +便携启动器(包括不同版本)共享该路径,不得并发运行;已安装的 NSIS 副本使用独立目录。 RPM 目标会向 FPM 传入 `_build_id_links none`。捆绑的 Electron 二进制文件位于 `/opt/PI-Desktop` 下;省略全局 `/usr/lib/.build-id` 链接,可以避免与其他捆绑相同 diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index d3400b922c..bfc68d0524 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -465,7 +465,7 @@ | 身份证号 | 主题 | 决定 | 基本原理 | |---|---|---|---| | D126 | 三平台发布交付(电梯D010) | *(amended by D364)* **标签构建将矩阵生成的每个工件发布到 GitHub 版本:macOS dmg/zip (arm64)、Windows NSIS x64、Linux AppImage + deb (x64),每个都有块图和平台的 `latest*.yml` 电子更新器提要。发布提要会激活 D120 的 Windows NSIS 和 Linux AppImage 的应用内更新通道; macOS 保持通知和链接模式,直到签名通道合格为止。 NSIS 工件名称固定为无空格 (`PI-Desktop-Setup-${version}.${ext}`),因为 GitHub 资产 URL 会损坏空格。根据基线凹凸规则(基线 `0.4.7`),D010 的仅限 macOS 范围被提升;发布管道本身在 v0.1.1-rc.1/v0.1.1 上通过了端到端合格。** | 无论如何,管道都会在每个标签上构建并验证所有三个平台;将安装程序保留为过期的 Actions 工件(保留 90 天)会阻止用户安装它们,而不会增加安全性。发布更新源是交付的重点:具有应用程序内通道的平台会静默更新,并且未来的平台回归会通过实际安装而不是未使用的工件来呈现。 | -| D364 | Windows portable exe without installer | **Amend D120 / D126 / ADR 0022: tag builds publish a Windows x64 portable exe `PI-Desktop-Portable-${version}.exe` alongside the NSIS installer `PI-Desktop-Setup-${version}.exe`. The portable target does not write `latest.yml`. Packaged portable runs (`PORTABLE_EXECUTABLE_FILE`) use notify-and-link delivery. NSIS installs keep in-app download and quit-and-install. Data stays in the existing application data directory. Portable requests user execution level.** | Company environments that whitelist a single executable cannot run the NSIS installer. Applying the NSIS updater to a portable run would install the app, so portable stays manual (ADR 0197, E2E-211). | +| D364 | Windows portable exe without installer | **Amend D120 / D126 / ADR 0022: tag builds publish a Windows x64 portable exe `PI-Desktop-Portable-${version}.exe` alongside the NSIS installer `PI-Desktop-Setup-${version}.exe`. The portable target does not write `latest.yml`. Packaged portable runs (`PORTABLE_EXECUTABLE_FILE`) use notify-and-link delivery. NSIS installs keep in-app download and quit-and-install. Data stays in the existing application data directory. Portable requests user execution level. The target sets `unpackDirName` to `PI-Desktop-Portable`, so the running executable resolves to `%TEMP%\PI-Desktop-Portable\PI-Desktop.exe` across builds and launches. The launcher deletes that directory before extraction and after exit, so a stopped pin may be blank until relaunch. All portable wrappers for one user, including different versions, share the directory and must not run concurrently.** | Company environments that whitelist a single executable cannot run the NSIS installer. Applying the NSIS updater to a portable run would install the app, so portable stays manual (ADR 0197, E2E-211). The explicit path keeps the running taskbar identity consistent across builds, while the launcher cleanup and shared directory make stopped pins and concurrent wrappers known limitations. | | D260 | 发布文档是一个版本载体 | **稳定版本号提升必须在打标签之前更新每一处带版本号的载体:双语言的应用内变更日志及其测试清单、每个工作区的 `package.json`(包括之前被第三个工作区根 `scripts/release.mjs` 跳过的 `docs/package.json`)、Cargo 工作区版本与 `host-core` 的锁文件条目、`APP_VERSION`,以及 `README.md` 和 `README.zh-CN.md` 中声明的 `..x` 发布线。`scripts/check-release-docs.mjs` 校验全部这些;`scripts/release.mjs` 在提升版本号之后运行它,只要任一载体不一致就拒绝提交或打标签,`--skip-docs-check` 仅保留给刻意的非发布性版本提升。扩展 D164。** | 仅有应用内变更日志这道闸门,导致已发布的文档落后:版本已到 `0.10.8`,两个 README 仍在宣传 `0.5.x` 线,而 `docs/package.json` 停在 `0.5.8`。标签是不可逆的,所以这项检查在标签存在之前运行,而不是作为评审礼节。 | | D371 | 显式的未签名 macOS 首次启动助手 | *(由 D406 和 D443 修订)* **每个 macOS 分发都附带可执行的 `PI-Desktop-macOS-open.command`,放在 DMG 上拖入 Applications 手势下方的可见首启行。它只查找 `/Applications/PI-Desktop.app` 与 `~/Applications/PI-Desktop.app`,校验 `CFBundleIdentifier` 为 `net.aiuo.pi-desktop`,仅在存在时移除 `com.apple.quarantine`,然后打开应用。永不使用 `sudo`,不接受任意路径,也不替代 Developer ID 签名或公证。** | 未签名的 macOS 通道可能被 quarantine 拦截并显示误导性的\"已损坏\"提示,而仅限终端的 `xattr -cr` 说明比启动失败所需的范围更宽(ADR 0204,E2E-196b) | | D443 | 规范应用 ID 与 macOS 代码签名标识 | **修订 D141 / D371 / ADR 0204:应用 ID 为 `net.aiuo.pi-desktop`(`APP_ID`、electron-builder `appId`、macOS `CFBundleIdentifier`、Windows AppUserModelID)。开发用 macOS 宿主为 `net.aiuo.pi-desktop.dev`。不要在 `afterPack`/`afterSign` 对未签名包做 adhoc 签名:嵌套 Electron helper 此时尚未签名,`codesign` 会报 `code object is not signed at all`。见 ADR 0278、issue #524。** | 所有者域名为 `net.aiuo.pi-desktop`。未签名包的 Identifier 绑定推迟到有安全的 helper 签名路径之后。 | @@ -3543,7 +3543,10 @@ D193 和 D194。 发布 `PI-Desktop-Portable-.exe`。便携版目标不写入 `latest.yml`。 已打包的便携版运行(`PORTABLE_EXECUTABLE_FILE`)使用通知加链接交付;NSIS 仍走应用内下载并在退出时安装。数据仍在现有应用数据目录。便携版请求 - user 执行级别。 + user 执行级别。目标将 `unpackDirName` 固定为 `PI-Desktop-Portable`,使运行中 + 可执行文件在不同构建和多次启动间都解析到 `%TEMP%\PI-Desktop-Portable\PI-Desktop.exe`。 + 启动器会在解压前和退出后删除该目录,因此停止期间固定项可能为空白,重新启动后才恢复。 + 同一用户的所有便携启动器(包括不同版本)共享该目录,不得并发运行。 - 参见 ADR 0197 与 E2E-211。 ## 2026-09-09 —— 为每个安静间隔命名活动行(D365) @@ -4588,4 +4591,3 @@ that amendment are retired by ADR 0268; the upstream work-panel lifecycle stays. - host-core 与 sidecar 崩溃仍走监督器路径以及 `host` / `agent` 日志通道。 什么都不上传。见 `03-runtime/07-process-model.md` §4、 `03-runtime/09-logging-and-observability.md` 与 `03-runtime/04-data-storage.md`。 - diff --git a/scripts/README.md b/scripts/README.md index 1074b94a40..8e39bf3e6f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -32,6 +32,7 @@ disagrees, so a green `check:release-docs` is a precondition, not a substitute. | `check-linux-host-glibc.mjs` | `node scripts/check-linux-host-glibc.mjs [bin]` | Fail a Linux host-core binary whose needed glibc is above 2.35 | | `make-icon.py` | `python3 scripts/make-icon.py` | Derive the package PNG, the macOS tray template, and the iconset/ICNS from the canonical PNG | | `publish-screenshots.py` | `python3 scripts/publish-screenshots.py` | Publish documentation screenshots | +| `windows-portable-e2e.ps1` | `pwsh scripts/windows-portable-e2e.ps1 -PortableExe -SecondPortableExe [-RequireTaskbarPin]` | Windows-runner smoke for portable extraction, cleanup, relaunch, cross-build path reuse, and shared-directory concurrency; taskbar pin qualification is opt-in on an interactive runner | ## Development @@ -95,3 +96,13 @@ runbook](../docs/spec/06-delivery/06-release-runbook.md). component, localized guidance and detail disclosure. Requires built desktop styles. Optional `PI_CERTIFICATE_EVIDENCE_DIR` saves a review screenshot; `--baseline` renders the `origin/main` error component for comparison. + +`.github/workflows/windows-portable-e2e.yml` is a manually dispatchable +Windows x64 packaging and native-shell smoke. It builds the NSIS and portable +artifacts, verifies the NSIS feed, checks extraction/cleanup and the known +shared-directory concurrency limitation described by E2E-211. The default +hosted-runner path records taskbar pin qualification as `NOT_RUN` when Explorer +does not expose the pin verb. To complete that qualification, provision an +interactive Windows runner for this workflow (or run the script there) and +enable `require_taskbar_pin=true`; the repository's default `windows-latest` +runner does not provide that capability reliably. diff --git a/scripts/windows-portable-e2e.ps1 b/scripts/windows-portable-e2e.ps1 new file mode 100644 index 0000000000..65654b50d3 --- /dev/null +++ b/scripts/windows-portable-e2e.ps1 @@ -0,0 +1,384 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$PortableExe, + [string]$SecondPortableExe, + [string]$UnpackDirName = "PI-Desktop-Portable", + [switch]$RequireTaskbarPin +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +function Wait-Until { + param( + [Parameter(Mandatory = $true)] [scriptblock]$Condition, + [int]$TimeoutSeconds = 60, + [int]$PollMilliseconds = 500 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + $value = & $Condition + if ($value) { return $value } + Start-Sleep -Milliseconds $PollMilliseconds + } while ((Get-Date) -lt $deadline) + return $null +} + +function Get-PortableProcess { + param([Parameter(Mandatory = $true)] [string]$ExecutablePath) + + Get-CimInstance Win32_Process -Filter "Name = 'PI-Desktop.exe'" | + Where-Object { $_.ExecutablePath -eq $ExecutablePath } | + Select-Object -First 1 +} + +function Get-TaskbarLinks { + $pinDirectory = Join-Path $env:APPDATA "Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar" + if (-not (Test-Path -LiteralPath $pinDirectory)) { return @() } + + $wsh = New-Object -ComObject WScript.Shell + try { + @( + Get-ChildItem -LiteralPath $pinDirectory -Filter "*.lnk" -File -ErrorAction SilentlyContinue | + ForEach-Object { + $shortcut = $null + try { + $shortcut = $wsh.CreateShortcut($_.FullName) + [pscustomobject]@{ + Path = $_.FullName + TargetPath = $shortcut.TargetPath + IconLocation = $shortcut.IconLocation + } + } finally { + if ($null -ne $shortcut) { + [Runtime.InteropServices.Marshal]::ReleaseComObject($shortcut) | Out-Null + } + } + } + ) + } finally { + [Runtime.InteropServices.Marshal]::ReleaseComObject($wsh) | Out-Null + } +} + +function Invoke-PinToTaskbar { + param([Parameter(Mandatory = $true)] [string]$ExecutablePath) + + $shell = New-Object -ComObject Shell.Application + $folder = $null + $item = $null + $verb = $null + try { + $folder = $shell.Namespace((Split-Path -Parent $ExecutablePath)) + if ($null -eq $folder) { return $false } + $item = $folder.ParseName((Split-Path -Leaf $ExecutablePath)) + if ($null -eq $item) { return $false } + + $verb = @($item.Verbs()) | + Where-Object { + $name = ($_.Name -replace "&", "").Trim() + $name -match "(?i)(pin.*taskbar|taskbar.*pin)" -and $name -notmatch "(?i)unpin" + } | + Select-Object -First 1 + if ($null -eq $verb) { return $false } + $verb.DoIt() + return $true + } finally { + foreach ($comObject in @($verb, $item, $folder, $shell)) { + if ($null -ne $comObject) { + [Runtime.InteropServices.Marshal]::ReleaseComObject($comObject) | Out-Null + } + } + } +} + +function Stop-PortableRun { + param( + [Parameter(Mandatory = $false)] $Wrapper, + [Parameter(Mandatory = $false)] $Child + ) + + if ($null -ne $Child) { + Stop-Process -Id $Child.ProcessId -Force -ErrorAction SilentlyContinue + } + if ($null -ne $Wrapper) { + Wait-Process -Id $Wrapper.Id -Timeout 30 -ErrorAction SilentlyContinue + Stop-Process -Id $Wrapper.Id -Force -ErrorAction SilentlyContinue + } +} + +function Stop-PortableProcesses { + param([Parameter(Mandatory = $true)] [string]$Root) + + $canonicalRoot = [IO.Path]::GetFullPath($Root) + $rootPrefix = $canonicalRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar + for ($attempt = 0; $attempt -lt 10; $attempt++) { + $processes = @( + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + $path = $_.ExecutablePath + if ($null -eq $path) { return $false } + try { + $canonicalPath = [IO.Path]::GetFullPath($path) + } catch { + return $false + } + $canonicalPath.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase) + } + ) + foreach ($process in $processes) { + Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue + } + if ($processes.Count -eq 0) { return $true } + Start-Sleep -Milliseconds 500 + } + return $false +} + +$portablePath = (Resolve-Path -LiteralPath $PortableExe).Path +$secondPortablePath = if ($SecondPortableExe) { + (Resolve-Path -LiteralPath $SecondPortableExe).Path +} else { + $portablePath +} +$tempRoot = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $env:TEMP).Path) +$unpackRoot = [IO.Path]::GetFullPath((Join-Path $tempRoot $UnpackDirName)) +$appPath = [IO.Path]::GetFullPath((Join-Path $unpackRoot "PI-Desktop.exe")) +$tempPrefix = $tempRoot.TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar + +if ([string]::Equals($unpackRoot, $tempRoot, [StringComparison]::OrdinalIgnoreCase) -or + -not $unpackRoot.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to use an unpack path outside TEMP: $unpackRoot" +} + +if (@(Get-PortableProcess $appPath).Count -gt 0) { + throw "A stale portable app is already running at $appPath" +} +Remove-Item -LiteralPath $unpackRoot -Recurse -Force -ErrorAction SilentlyContinue +if (Test-Path -LiteralPath $unpackRoot) { + throw "Portable extraction directory was not removed before the test: $unpackRoot" +} +$pinCreated = $false +$pinWasPresent = $false +$baselinePinPaths = @() +$baselinePinsCaptured = $false +$createdPinPaths = @() +$childPids = @() +$runs = @() + +try { + Write-Host "PORTABLE_EXE=$portablePath" + Write-Host "SECOND_PORTABLE_EXE=$secondPortablePath" + Write-Host "EXPECTED_APP_PATH=$appPath" + + # One launch verifies extraction, the taskbar pin target, and cleanup. + $firstWrapper = Start-Process -FilePath $portablePath -PassThru + $runs += $firstWrapper + $firstChild = Wait-Until { Get-PortableProcess $appPath } + if ($null -eq $firstChild) { throw "Portable app did not start at $appPath" } + $childPids += $firstChild.ProcessId + Write-Host "RUNNING_APP_PATH=$($firstChild.ExecutablePath)" + + $pinnedBefore = @( + Get-TaskbarLinks | Where-Object { $_.TargetPath -eq $appPath } + ) + $baselinePinPaths = @($pinnedBefore | ForEach-Object { $_.Path }) + $baselinePinsCaptured = $true + $pinWasPresent = $pinnedBefore.Count -gt 0 + $pinCreated = $pinWasPresent -or (Invoke-PinToTaskbar $appPath) + Write-Host "TASKBAR_PIN_CREATED=$pinCreated" + if (-not $pinCreated) { + Write-Host "TASKBAR_PIN_E2E=NOT_RUN" + Write-Host "TASKBAR_PIN_NOT_RUN_REASON=Windows shell did not expose a Pin to taskbar verb" + } + if (-not $pinCreated -and $RequireTaskbarPin) { + throw "Windows shell did not expose a Pin to taskbar verb for $appPath" + } + + $pinnedWhileRunning = @() + if ($pinCreated) { + $pinnedWhileRunning = Wait-Until { + $matches = @(Get-TaskbarLinks | Where-Object { $_.TargetPath -eq $appPath }) + if ($matches.Count -gt 0) { return $matches } + return $null + } + $pinnedWhileRunning = @($pinnedWhileRunning) + } + Write-Host "TASKBAR_PIN_TARGET_COUNT_RUNNING=$($pinnedWhileRunning.Count)" + if ($pinnedWhileRunning.Count -gt 0) { + Write-Host "TASKBAR_PIN_ICON_RUNNING=$($pinnedWhileRunning[0].IconLocation)" + if ([string]::IsNullOrWhiteSpace([string]$pinnedWhileRunning[0].IconLocation)) { + throw "Running taskbar pin has no icon location" + } + if (-not $pinWasPresent) { + $createdPinPaths = @($pinnedWhileRunning | ForEach-Object { $_.Path }) + } + } + if ($pinCreated -and $pinnedWhileRunning.Count -eq 0) { + Write-Host "TASKBAR_PIN_E2E=FAIL" + throw "Pin to taskbar did not create a shortcut targeting $appPath" + } + if ($pinnedWhileRunning.Count -gt 0) { + Write-Host "TASKBAR_PIN_E2E=PASS" + } + + Stop-PortableRun -Wrapper $firstWrapper -Child $firstChild + $removed = Wait-Until { -not (Test-Path -LiteralPath $appPath) } + Write-Host "EXTRACTION_REMOVED_AFTER_EXIT=$([bool]$removed)" + if (-not $removed) { throw "Portable launcher did not remove $appPath after exit" } + + $pinnedWhileStopped = @( + Get-TaskbarLinks | Where-Object { $_.TargetPath -eq $appPath } + ) + Write-Host "TASKBAR_PIN_TARGET_COUNT_STOPPED=$($pinnedWhileStopped.Count)" + if ($pinnedWhileStopped.Count -gt 0) { + Write-Host "TASKBAR_PIN_ICON_STOPPED=$($pinnedWhileStopped[0].IconLocation)" + } + Write-Host "TASKBAR_PIN_MAY_BE_BLANK_WHILE_STOPPED=true" + + # A relaunch verifies the same build; the second package below verifies that + # a later build also resolves to the explicit unpack path. + $secondWrapper = Start-Process -FilePath $portablePath -PassThru + $runs += $secondWrapper + $secondChild = Wait-Until { Get-PortableProcess $appPath } + if ($null -eq $secondChild) { throw "Portable app did not recreate $appPath" } + $childPids += $secondChild.ProcessId + Write-Host "RELAUNCH_APP_PATH=$($secondChild.ExecutablePath)" + if ($secondChild.ExecutablePath -ne $appPath) { + throw "Portable relaunch used an unexpected path: $($secondChild.ExecutablePath)" + } + + $pinnedAfterRelaunch = @() + if ($pinCreated) { + $pinnedAfterRelaunch = Wait-Until { + $matches = @(Get-TaskbarLinks | Where-Object { $_.TargetPath -eq $appPath }) + if ($matches.Count -gt 0) { return $matches } + return $null + } + $pinnedAfterRelaunch = @($pinnedAfterRelaunch) + } + Write-Host "TASKBAR_PIN_TARGET_COUNT_RELAUNCH=$($pinnedAfterRelaunch.Count)" + if ($pinnedAfterRelaunch.Count -gt 0) { + Write-Host "TASKBAR_PIN_ICON_RELAUNCH=$($pinnedAfterRelaunch[0].IconLocation)" + } + if ($pinCreated -and $pinnedAfterRelaunch.Count -eq 0) { + Write-Host "TASKBAR_PIN_E2E=FAIL" + throw "Taskbar pin did not resolve after relaunch" + } + if ($RequireTaskbarPin) { + if ([string]::IsNullOrWhiteSpace([string]$pinnedAfterRelaunch[0].IconLocation)) { + throw "Taskbar pin has no icon location after relaunch" + } + } + Stop-PortableRun -Wrapper $secondWrapper -Child $secondChild + + $buildBWrapper = Start-Process -FilePath $secondPortablePath -PassThru + $runs += $buildBWrapper + $buildBChild = Wait-Until { Get-PortableProcess $appPath } + if ($null -eq $buildBChild) { throw "Second portable build did not start at $appPath" } + $childPids += $buildBChild.ProcessId + Write-Host "SECOND_BUILD_APP_PATH=$($buildBChild.ExecutablePath)" + if ($buildBChild.ExecutablePath -ne $appPath) { + throw "Second portable build used an unexpected path: $($buildBChild.ExecutablePath)" + } + Stop-PortableRun -Wrapper $buildBWrapper -Child $buildBChild + + # A second wrapper while the first one is alive is intentionally unsupported: + # both wrappers use the same directory and the second one removes it first. + $concurrencyWrapper = Start-Process -FilePath $portablePath -PassThru + $runs += $concurrencyWrapper + $concurrencyChild = Wait-Until { Get-PortableProcess $appPath } + if ($null -eq $concurrencyChild) { throw "Concurrency probe could not start the first wrapper" } + $childPids += $concurrencyChild.ProcessId + $concurrencyPid = $concurrencyChild.ProcessId + $concurrencyBeforeHash = (Get-FileHash -LiteralPath $appPath -Algorithm SHA256).Hash + $probeWrapper = Start-Process -FilePath $secondPortablePath -PassThru + $runs += $probeWrapper + $probeExit = Wait-Process -Id $probeWrapper.Id -Timeout 15 -ErrorAction SilentlyContinue + Start-Sleep -Seconds 3 + $concurrencyAfterPath = Test-Path -LiteralPath $appPath + $concurrencyAfterHash = "" + if ($concurrencyAfterPath) { + try { + $concurrencyAfterHash = (Get-FileHash -LiteralPath $appPath -Algorithm SHA256).Hash + } catch { + Write-Host "CONCURRENT_WRAPPERS_HASH_ERROR=$($_.Exception.Message)" + } + } + $firstProcessAfter = Get-Process -Id $concurrencyPid -ErrorAction SilentlyContinue + $concurrencyChildren = @( + Get-CimInstance Win32_Process -Filter "Name = 'PI-Desktop.exe'" | + Where-Object { $_.ExecutablePath -eq $appPath } + ) + $concurrencyPathChanged = -not $concurrencyAfterPath -or $concurrencyBeforeHash -ne $concurrencyAfterHash + Write-Host "CONCURRENT_WRAPPER_EXITED=$([bool]$probeExit)" + Write-Host "CONCURRENT_WRAPPERS_SHARED_PATH=$concurrencyAfterPath" + Write-Host "CONCURRENT_WRAPPERS_PATH_CHANGED=$concurrencyPathChanged" + Write-Host "CONCURRENT_FIRST_PROCESS_ALIVE=$($null -ne $firstProcessAfter)" + Write-Host "CONCURRENT_APP_PROCESS_COUNT=$($concurrencyChildren.Count)" + Write-Host "CONCURRENT_WRAPPERS_SUPPORTED=false" + $probeChild = $concurrencyChildren | + Where-Object { $_.ProcessId -ne $concurrencyPid } | + Select-Object -First 1 + if ($null -ne $probeChild) { + $childPids += $probeChild.ProcessId + } + Stop-PortableRun -Wrapper $probeWrapper -Child $probeChild + Stop-PortableRun -Wrapper $concurrencyWrapper -Child $concurrencyChild +} +finally { + foreach ($childId in @($childPids | Select-Object -Unique)) { + Stop-Process -Id $childId -Force -ErrorAction SilentlyContinue + } + foreach ($run in $runs) { + Stop-Process -Id $run.Id -Force -ErrorAction SilentlyContinue + Wait-Process -Id $run.Id -Timeout 10 -ErrorAction SilentlyContinue + } + $pinCleanupComplete = $true + $cleanupPinPaths = @($createdPinPaths) + if ($baselinePinsCaptured) { + try { + foreach ($link in @(Get-TaskbarLinks | Where-Object { $_.TargetPath -eq $appPath })) { + if ($baselinePinPaths -notcontains $link.Path) { + $cleanupPinPaths += $link.Path + } + } + } catch { + Write-Host "TASKBAR_PIN_CLEANUP_SCAN_ERROR=$($_.Exception.Message)" + $pinCleanupComplete = $false + } + } + foreach ($pinPath in @($cleanupPinPaths | Select-Object -Unique)) { + Remove-Item -LiteralPath $pinPath -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $pinPath) { + Write-Host "TASKBAR_PIN_CLEANUP_FAILED=$pinPath" + $pinCleanupComplete = $false + } + } + $portableProcessesStopped = $true + $portableCleanupComplete = $false + for ($attempt = 0; $attempt -lt 10; $attempt++) { + $portableProcessesStopped = Stop-PortableProcesses $unpackRoot + Remove-Item -LiteralPath $unpackRoot -Recurse -Force -ErrorAction SilentlyContinue + if (-not (Test-Path -LiteralPath $unpackRoot)) { + $portableProcessesStopped = Stop-PortableProcesses $unpackRoot + if ($portableProcessesStopped) { + $portableCleanupComplete = $true + break + } + } + Start-Sleep -Milliseconds 500 + } + if (-not $portableProcessesStopped) { + Write-Host "PORTABLE_CHILD_CLEANUP_FAILED=processes remain under $unpackRoot" + } + if (-not $portableCleanupComplete) { + Write-Host "PORTABLE_CLEANUP_FAILED=extraction directory remains after cleanup" + } + if (-not $pinCleanupComplete -or -not $portableProcessesStopped -or -not $portableCleanupComplete) { + throw "Portable smoke cleanup failed" + } +} +Write-Host "WINDOWS_PORTABLE_E2E=PASS"