From 5a279b8439f24b124c1205abb7bbaade54062bc0 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Mon, 26 Oct 2026 09:59:16 +1300 Subject: [PATCH 1/9] refactor(windows): replace wallpaper helper with Python --- .github/workflows/electron-windows.yml | 13 +- .github/workflows/python-windows.yml | 5 - .github/workflows/source-release.yml | 7 +- LICENSES/provenance.json | 15 + THIRD_PARTY_NOTICES.md | 8 + .../issue264-python-wallpaper/README.md | 136 ++ docs/windows_wallpaper_lifecycle.md | 102 +- electron/electron-builder-code-only.cjs | 3 +- electron/package.json | 2 +- .../windows-wallpaper}/Apache-2.0.txt | 0 .../resources/windows-wallpaper/NOTICE.md | 20 + .../resources/windows-wallpaper/helper.py | 988 +++++++++++++ electron/scripts/build-runtime-resources.cjs | 4 +- electron/scripts/build-windows-wallpaper.cjs | 11 - electron/scripts/probe-windows-wallpaper.cjs | 12 +- electron/scripts/smoke-windows-wallpaper.cjs | 8 +- electron/src/main/index.ts | 2 +- electron/src/main/windowsWallpaper.ts | 14 +- electron/tests/windowsWallpaper.test.mjs | 33 +- release/source_release_policy.json | 3 + scripts/setup_windows_wallpaper.ps1 | 20 +- tests/test_windows_wallpaper_helper.py | 880 ++++++++++++ tools/probes/windows_wallpaper_cold_start.py | 87 ++ .../windows_wallpaper_counterexample.py | 26 + tools/probes/windows_wallpaper_lifecycle.py | 13 +- wallpaper/windows/.gitignore | 2 - .../Amadeus.Wallpaper.Tests.csproj | 9 - .../Amadeus.Wallpaper.Tests/ColdStartProbe.cs | 92 -- .../Amadeus.Wallpaper.Tests/Program.cs | 735 ---------- .../Amadeus.Wallpaper.csproj | 19 - .../windows/Amadeus.Wallpaper/LivelyHost.cs | 310 ---- .../windows/Amadeus.Wallpaper/Program.cs | 79 - .../Amadeus.Wallpaper/Proto/commands.proto | 6 - .../Amadeus.Wallpaper/Proto/desktop.proto | 19 - .../Amadeus.Wallpaper/Proto/display.proto | 7 - .../Amadeus.Wallpaper/Proto/settings.proto | 17 - .../windows/Amadeus.Wallpaper/Session.cs | 180 --- .../Amadeus.Wallpaper/WallpaperRequest.cs | 92 -- .../Amadeus.Wallpaper/packages.lock.json | 41 - wallpaper/windows/notices/DEPENDENCIES.md | 26 - wallpaper/windows/notices/dotnet-MIT.txt | 23 - .../notices/dotnet-THIRD-PARTY-NOTICES.txt | 1272 ----------------- .../windows/notices/protobuf-BSD-3-Clause.txt | 32 - 43 files changed, 2303 insertions(+), 3070 deletions(-) create mode 100644 docs/evidence/issue264-python-wallpaper/README.md rename {wallpaper/windows/notices => electron/resources/windows-wallpaper}/Apache-2.0.txt (100%) create mode 100644 electron/resources/windows-wallpaper/NOTICE.md create mode 100644 electron/resources/windows-wallpaper/helper.py delete mode 100644 electron/scripts/build-windows-wallpaper.cjs create mode 100644 tests/test_windows_wallpaper_helper.py create mode 100644 tools/probes/windows_wallpaper_cold_start.py create mode 100644 tools/probes/windows_wallpaper_counterexample.py delete mode 100644 wallpaper/windows/.gitignore delete mode 100644 wallpaper/windows/Amadeus.Wallpaper.Tests/Amadeus.Wallpaper.Tests.csproj delete mode 100644 wallpaper/windows/Amadeus.Wallpaper.Tests/ColdStartProbe.cs delete mode 100644 wallpaper/windows/Amadeus.Wallpaper.Tests/Program.cs delete mode 100644 wallpaper/windows/Amadeus.Wallpaper/Amadeus.Wallpaper.csproj delete mode 100644 wallpaper/windows/Amadeus.Wallpaper/LivelyHost.cs delete mode 100644 wallpaper/windows/Amadeus.Wallpaper/Program.cs delete mode 100644 wallpaper/windows/Amadeus.Wallpaper/Proto/commands.proto delete mode 100644 wallpaper/windows/Amadeus.Wallpaper/Proto/desktop.proto delete mode 100644 wallpaper/windows/Amadeus.Wallpaper/Proto/display.proto delete mode 100644 wallpaper/windows/Amadeus.Wallpaper/Proto/settings.proto delete mode 100644 wallpaper/windows/Amadeus.Wallpaper/Session.cs delete mode 100644 wallpaper/windows/Amadeus.Wallpaper/WallpaperRequest.cs delete mode 100644 wallpaper/windows/Amadeus.Wallpaper/packages.lock.json delete mode 100644 wallpaper/windows/notices/DEPENDENCIES.md delete mode 100644 wallpaper/windows/notices/dotnet-MIT.txt delete mode 100644 wallpaper/windows/notices/dotnet-THIRD-PARTY-NOTICES.txt delete mode 100644 wallpaper/windows/notices/protobuf-BSD-3-Clause.txt diff --git a/.github/workflows/electron-windows.yml b/.github/workflows/electron-windows.yml index 89ced27f..8dbacbf5 100644 --- a/.github/workflows/electron-windows.yml +++ b/.github/workflows/electron-windows.yml @@ -25,10 +25,13 @@ jobs: cache: npm cache-dependency-path: electron/package-lock.json - - uses: actions/setup-dotnet@v5 + - uses: actions/setup-python@v7 with: - # Pin the same SDK resolved by 8.0.x; avoid its broken aka.ms blob redirect. - dotnet-version: '8.0.427' + python-version: '3.12.10' + + - name: Select the existing Python runtime + shell: pwsh + run: '"AMADEUS_PYTHON=$((Get-Command python).Source)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append' - name: Install locked dependencies run: npm ci @@ -81,7 +84,9 @@ jobs: if ($probe.ExitCode -ne 0) { throw "Startup settings smoke failed: $($probe.ExitCode)" } - name: Test Windows wallpaper lifecycle contracts - run: dotnet run --project ../wallpaper/windows/Amadeus.Wallpaper.Tests + run: | + python -m pip install pytest==8.4.2 + python -m pytest ../tests/test_windows_wallpaper_helper.py - name: Build the unpacked Windows application including beforePack env: diff --git a/.github/workflows/python-windows.yml b/.github/workflows/python-windows.yml index b6f38f17..31d24819 100644 --- a/.github/workflows/python-windows.yml +++ b/.github/workflows/python-windows.yml @@ -153,11 +153,6 @@ jobs: --report-dir build\electron-smoke --extension-fixture "${{ env.EXTENSION_RELEASE_FIXTURE }}" --extension-failures - - uses: actions/setup-dotnet@v5 - with: - # Pin the same SDK resolved by 8.0.x; avoid its broken aka.ms blob redirect. - dotnet-version: '8.0.427' - - name: Build actual Windows executable working-directory: electron env: diff --git a/.github/workflows/source-release.yml b/.github/workflows/source-release.yml index f1fa931e..00e9d982 100644 --- a/.github/workflows/source-release.yml +++ b/.github/workflows/source-release.yml @@ -13,7 +13,7 @@ on: - electron/package-lock.json - scripts/** - wallpaper/** - - electron/scripts/build-windows-wallpaper.cjs + - electron/resources/windows-wallpaper/** - tools/build_source_release.py - tools/stage_japanese_dictionary.py - tools/probes/verify_bundled_dictionary.py @@ -102,11 +102,6 @@ jobs: with: node-version: "22.21.1" - - uses: actions/setup-dotnet@v5 - with: - # Pin the same SDK resolved by 8.0.x; avoid its broken aka.ms blob redirect. - dotnet-version: '8.0.427' - - name: Install pinned uv run: python -m pip install uv==0.12.8 diff --git a/LICENSES/provenance.json b/LICENSES/provenance.json index 5e5d38f5..508ef950 100644 --- a/LICENSES/provenance.json +++ b/LICENSES/provenance.json @@ -2,6 +2,7 @@ "schema": "amadeus.third-party-provenance.v1", "last_reviewed": "2026-09-21", "inventory_roots": [ + "electron/resources/windows-wallpaper", "GPT_SoVITS", "tools/AP_BWE_main", "tools/asr", @@ -15,6 +16,20 @@ "vendor/qwen-asr" ], "components": [ + { + "id": "windows-wallpaper-wire-adapter", + "name": "Amadeus Windows wallpaper wire adapter", + "category": "adapted-source", + "paths": ["electron/resources/windows-wallpaper/**"], + "source_url": "https://github.com/cyanfish/grpc-dotnet-namedpipes", + "source_revision": "da307b551a9d56b62f5780e91a2d95a699f32430 (3.1.0 transport.proto and Windows message framing reference)", + "license_expression": "AGPL-3.0 AND Apache-2.0", + "license_evidence": ["LICENSE", "electron/resources/windows-wallpaper/NOTICE.md", "electron/resources/windows-wallpaper/Apache-2.0.txt"], + "modified": true, + "release_action": "include", + "gate_status": "ready", + "notes": "First-party standard-library Python helper implementing the bounded external Lively 2.2.1.0 IPC subset, with protocol/framing reference attribution preserved. No upstream .NET client, protobuf runtime, NuGet package, or .NET runtime binary is included. Lively remains separately supplied by its publisher." + }, { "id": "gpt-sovits", "name": "GPT-SoVITS", diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c1e14e89..1618e3a6 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,5 +1,13 @@ # Third-party notices and pre-release provenance status +The Windows wallpaper helper uses the existing Python runtime and ships no .NET +or NuGet binaries. Its bounded wire adapter references GrpcDotNetNamedPipes 3.1.0 +at `da307b551a9d56b62f5780e91a2d95a699f32430` (Copyright 2020 Google LLC, +Apache-2.0); attribution and license are included in +`electron/resources/windows-wallpaper/NOTICE.md` and `Apache-2.0.txt`, with a +matching provenance record. Lively remains an optional separately distributed +GPL-3.0 application with its own prerequisites and notices. + Status: **pre-release inventory — no known unresolved provenance component is selected as a release blocker under the stated immediate-upstream policy**. diff --git a/docs/evidence/issue264-python-wallpaper/README.md b/docs/evidence/issue264-python-wallpaper/README.md new file mode 100644 index 00000000..7070809b --- /dev/null +++ b/docs/evidence/issue264-python-wallpaper/README.md @@ -0,0 +1,136 @@ +# Issue #264: Python wallpaper helper migration + +Date: 2026-10-26 (Pacific/Auckland). Baseline: +`b6616a166925963ad6de4ab0a02f4eef2dccd657`, main after #261 merged. +This is a migration candidate, not a release qualification or merge approval. + +## Owning layer and compatibility + +This is a Windows integration/dependency replacement. Electron keeps its +lifecycle entry points, status and shutdown ownership. The independent helper +uses the backend interpreter selected by `pythonRuntime.ts`, runs with `-I`, +and ships with Electron resources. It imports only the Python standard library +and stays outside the backend's cleanup tree. No new package is required. + +The original lifecycle state machine and #233 reporting/close limitations remain. +PascalCase journals and string phases are retained. The same-pipe receiver +identity is flushed before any Set bytes. Native creation FILETIME is converted +to .NET UTC ticks with integer addition of `504911232000000000`; a real-process +test compiles the original C# expression and asserts exact equality, then verifies +that an existing live Pending record remains fenced. Unknown identity is an +error, not process-exit evidence. + +The bounded transport follows GrpcDotNetNamedPipes 3.1.0 at +`da307b551a9d56b62f5780e91a2d95a699f32430`. Synchronous call ownership replaces +the old background-connect dispatch gate. Overlapped IO is cancelled and drained +before releasing buffers. Deadlines/cancellation wait for an entered persistence +callback, and a check after that callback prevents a late first write. Persisted +Pending remains conservative even when no bytes follow. After dispatch, Cancel +is best effort and is not treated as proof that Lively stopped loading. + +Settings are a sequence of original protobuf fields. Only 28/48/49 are rewritten; +every other field retains its original bytes and relative order. Tests include +noncanonical varints, duplicate fields, fixed-width values, groups, wrong-wire +unknown occurrences and proto3 false omission. Restoration reads current settings +and preserves newer user choices. Existing launch-image JSON backups also recover. + +The C# projects, NuGet lockfile, .NET build hooks/CI setup and runtime-only notices +were removed after running the original baseline and live tick comparison. +The wire-reference Apache attribution/license moved with the Python resource, +and source-policy/provenance records explicitly include the helper. + +## Verification + +Environment: Windows x64, OS build 26200; CPython 3.12.10, Node 22.21.1, +Electron 44.0.0. Existing local Python/Node environments were reused; no optional +model or microphone was used by the desktop probes. + +- Unchanged C# baseline: **50 contracts passed**, before source removal. +- Python helper contracts: **82 passed**. This includes real Windows message + pipes, exact receiver-before-dispatch, actual journal write obstruction, + late server availability, deadline/cancellation before and after dispatch, + persistence spanning a deadline, large separate payloads, exclusive locking, + old journals and the C#/Python identity comparison. +- Final Electron suite: **703 passed, 0 skipped**. Both source and packaged helper + launches verify resource selection, isolated mode and the backend interpreter. +- `npm run build`: passed; existing Vite chunk-size warning remains. +- Windows code-only unpacked package: built without a .NET build step. Packaged + native tray/startup policy/read-only helper smoke passed. The wallpaper resource + directory contains only helper.py, setup, NOTICE and Apache license. +- Actual packaged model-less application: **15 checks passed**, including + startup, authentication, navigation and frontend/backend exit. +- Actual Electron managed-wallpaper entry/quit and the `--host-exit` fault probe + passed: the control-panel gear preserved the mounted scene; helper death + stopped backend wallpaper ownership, removed the stale Slice, revealed the + window and reported the error. The stop endpoint checked authentication and + Origin, and cleanup restored the original desktop. +- Local installed-Lively lifecycle: normal exit, stdin loss, helper termination + plus journal recovery, and actual parent death all restored the original + reported scene/options/running state, with a live renderer connection for + each mounted scenario. The first run exposed a missing probe report directory; + it was fixed at the probe layer and the complete probe rerun passed. +- Fresh-settings cold start: no first-run UI, WebView2 selected, independent + autostart disabled, newly owned host exited, original profile/layout/running + state restored in finally. +- Changed Python lint and diff whitespace checks passed. + +The full Python suite and final source-archive checks are recorded below once +complete. Individual totals above overlap and must not be added together. + +Reproducible commands (use the existing project Python environment): + +```powershell +# Historical, at the baseline before removal: +dotnet run --project wallpaper/windows/Amadeus.Wallpaper.Tests + +.venv/Scripts/python.exe -m pytest tests/test_windows_wallpaper_helper.py +.venv/Scripts/python.exe -X utf8 tools/run_tests.py +npm --prefix electron test +npm --prefix electron run build +# From electron/: +npx electron-builder --config electron-builder-code-only.cjs --win --x64 --dir --publish never +# From the repository root, with the native Electron smoke wrapper used by CI: +.venv/Scripts/python.exe tools/smoke_electron_model_less.py --packaged-executable electron/build/win-unpacked/amadeus-desktop.exe +.venv/Scripts/python.exe tools/probes/windows_wallpaper_lifecycle.py +.venv/Scripts/python.exe tools/probes/windows_wallpaper_cold_start.py +.venv/Scripts/python.exe tools/probes/windows_wallpaper_counterexample.py +.venv/Scripts/python.exe tools/build_source_release.py +``` + +The separate false-empty/no-op-close diagnostic still reports an unreported +active scene, deleted journal, requested owned-host stop and +`strongRecoveryProven=false`. Its execution is explicitly excluded from passing +recovery counts. Migration does not fix #233. + +## Distribution and timing measurement + +The exact baseline was published with its original self-contained win-x64 +settings, excluding PDBs as in the old package filter. Helper plus notices: +**74,922,671 bytes / 194 files**. The replacement helper plus protocol notices is +**56,451 bytes / 3 files**; the setup script is separate in both comparisons. +This removes about 71.4 MiB of uncompressed helper resources. It is not a claim +about compressed installer size or a complete standalone runtime distribution. + +Read-only `inspect` startup on the same live host, one warmup then five samples: +old helper **181, 189, 195, 195, 215 ms**, median **195 ms**; Python helper +**259, 261, 269, 279, 286 ms**, median **269 ms**. These local measurements ran +alongside regression work and establish no startup speed improvement. + +Source build/packaging no longer needs the modern .NET SDK, NuGet restore or a +self-contained runtime publish. First-use setup still provisions Lively through +winget if missing. Lively's own prerequisites remain its publisher's concern. +Python/model/asset provisioning remains external, as before. + +## Remaining release gates + +- The installed Lively 2.2.1.0 has the local null-display-name repair documented + in #233. Its binary was not certified as an unmodified official release here. + These real-desktop results are supplemental, not stock-Lively acceptance. +- A clean machine without a modern .NET SDK/system runtime, an unmodified + supported Lively release, physical multiple displays and full release-asset + checks still require qualification. The local package is explicitly code-only. +- Independent review is required before merge/replacement. No merge or release + approval is claimed by green local tests. +- [.NET 8 support ends on 2026-11-10](https://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core). + If the migration cannot be qualified by then, retarget the old release helper + and CI to net10.0 separately; do not wait past the support date. diff --git a/docs/windows_wallpaper_lifecycle.md b/docs/windows_wallpaper_lifecycle.md index 97d03f2b..23500dc5 100644 --- a/docs/windows_wallpaper_lifecycle.md +++ b/docs/windows_wallpaper_lifecycle.md @@ -115,23 +115,35 @@ does not transfer ownership of the new host to Amadeus or authorize stopping a host showing a user's new wallpaper. A failed identity query retains the journal and reports the failure instead of assuming the old process exited. -The Set adapter is coupled to the locked GrpcDotNetNamedPipes 3.1.0 transport. Its -single internal `PipeCallback` binding captures the actual request stream; the -request marshaller reads `GetNamedPipeServerProcessId` after that stream connects -and persists the identity before any request bytes are written. The adapter does -not probe a second connection, retry, or substitute a process-name guess. If the -binding, identity read, or journal write fails, no Set is dispatched and settings -preparation can roll back. A library upgrade must rerun the real synthetic-pipe -contracts before accepting this boundary. - -The library's response deadline can expire while its background connection or -serializer is still running. A single dispatch gate closes before `Send` returns -and waits for an already entered identity/persistence boundary to finish. A -connection arriving after that closure cannot identify a receiver, save a journal -or write the Set request, so an unstarted request can finish settings-only rollback. -If persistence already began, the caller waits for it and retains `Pending` on a -deadline error. The transport write may still follow that persisted boundary; -this is uncertain dispatch protected by the journal, not proof of cancellation. +The standard-library Python helper implements the bounded Windows message-pipe +protocol from GrpcDotNetNamedPipes 3.1.0, pinned at +`da307b551a9d56b62f5780e91a2d95a699f32430`. It uses overlapped native IO with a +single call thread and no background connector or serializer. On the actual Set +connection, `GetNamedPipeServerProcessId` identifies the receiver and the Session +flushes its Pending journal before any request bytes are written. An identity or +persistence failure sends no request. No second connection or process-name guess +substitutes for receiver identity. + +Connect/read/write waits honor the call deadline and cancellation. Pending native +IO is cancelled and drained before its buffers are released. The call cannot +return while an entered persistence callback is running. Once that callback +returns, a deadline/cancellation check precedes the first write; a persisted +Pending journal still conservatively fences recovery even when this check stops +the write. No late task can recreate the journal after cleanup. A timeout after +dispatch sends best-effort Cancel and closes the pipe, but neither cancellation +nor an empty reply proves that Lively stopped loading the scene. + +Journal fields retain PascalCase and string phases. Process `Started` is exactly +the Windows creation FILETIME plus `504911232000000000` (.NET UTC ticks), computed +with integers. The native regression compiles the original C# expression and +compares it with Python for the same live process, then verifies that an old +Pending record still blocks recovery. A failed identity read remains an error. + +Settings retain their raw protobuf field sequence. Only scalars 28 (browser), +48 (lock image), and 49 (desktop image) are rewritten; all other field bytes, +including unknown encodings, duplicates, groups and order, survive unchanged. +Proto3 false/default writes omit the scalar. Restoration reads current settings +first and preserves newer user choices rather than constructing a Settings model. Old published journals without `Phase` retain their original semantics. A `Pending` journal without `RequestProcess` is invalid: recovery reports an error @@ -166,17 +178,17 @@ Session 已接管某个进程的清理(包括旧日志恢复),即记录其 Lively;这不转移新宿主的所有权,也不授权停止承载用户新壁纸的宿主。身份查询失败 时保留日志并明确报错,不臆测原进程已退出。 -Set 适配器耦合锁定的 GrpcDotNetNamedPipes 3.1.0:仅一处内部 `PipeCallback` 绑定 -取得实际请求流,marshaller 在该流连接后读取 `GetNamedPipeServerProcessId`,并在 -写入任何请求字节前持久保存身份。不另开探测连接,不重试,也不以进程名称猜测替代。 -钩子、身份读取或日志保存失败时不派发 Set,设置准备可正常回滚。升级传输库前必须 -重新通过真实合成管道契约,才能接受该边界。 +Python helper 仅用标准库,实现上述锁定版本的 Windows 消息管道协议,以原生 overlapped +IO 配合同一调用线程执行;没有后台连接器或序列化任务。在实际 Set 连接上先读取 +`GetNamedPipeServerProcessId`,持久保存 Pending 后才允许写请求。身份或日志失败不派发。 +连接、读、写遵守 deadline/取消;取消原生 IO 后等待其完成再释放缓冲区。已进入的持久化 +回调结束前调用不能返回,回调后写入前再次核验 deadline/取消。已保存 Pending 时仍保守 +保留恢复栅栏;迟到任务不能在清理后重建日志。派发后的 Cancel 是尽力而为,不能证明 +Lively 不会继续迟到挂载。 -传输库的响应 deadline 可能先于后台连接或序列化结束。单一派发门在 `Send` 返回前 -关闭,并等待已进入的身份读取/持久化边界完成。关闭后迟到的连接不得再识别进程、 -保存日志或写入 Set 请求,因此尚未开始派发的请求可完成仅恢复设置的回滚。 -若持久化边界已开始,调用方须等待其结束,deadline 错误保留 `Pending`。传输写入 -仍可能随后发生;这是由日志保护的不确定派发,不能据此声称取消成功。 +日志保留 PascalCase、字符串 Phase;Started 必须以整数精确等于 FILETIME 加 +`504911232000000000`,真实进程回归与原 C# 表达式逐值比较。Settings 保留原始字段序列, +仅改 28/48/49,其他字段按原字节写回;false/default 写入省略字段,保留用户新选择。 已发布的无 `Phase` 旧日志保持原语义。缺少 `RequestProcess` 的 `Pending` 日志 视为无效:恢复明确报错,不改写日志或改变桌面。该格式和 `MountUnconfirmed` 仅 @@ -228,7 +240,7 @@ snapshot. The `recover` command also handles an interrupted launch preparation. Manual recovery, after closing Amadeus: ```powershell -build\windows-wallpaper\host\Amadeus.Wallpaper.exe recover +.venv\Scripts\python.exe -I electron\resources\windows-wallpaper\helper.py recover ``` `recover` is an operator diagnostic/recovery command, also used by the desktop @@ -245,12 +257,21 @@ cancellation/time-budget policy remains a release-acceptance item. ## Build and distribution -Source runs need .NET 8 SDK on the build machine to build the helper once. -The published Windows x64 helper includes .NET 8.0.31, so end users do not need -an SDK. The Electron Windows packaging hook builds and includes the helper and -its dependency notices. It runs no Windows setup for macOS/Linux targets. -Experimental logs, profile backups and screenshots live outside the published -`host` directory and are not included in the application package. +The helper is `electron/resources/windows-wallpaper/helper.py`, shipped with +Electron under `resources/windows-wallpaper`, together with setup and protocol +notices. Source runs use that same checked-in resource. Electron passes its +backend interpreter selected by `pythonRuntime.ts` and invokes `python -I`; +no additional Python package, .NET SDK, NuGet build or bundled .NET runtime is +required. The helper remains an independent child of Electron, outside the +backend's process-tree/Job cleanup. macOS/Linux and external-host paths do not +run Windows setup. Experimental logs, backups and screenshots stay under build/, +outside the explicitly selected packaged resource files. + +[.NET 8 support ends on 2026-11-10](https://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core). +This motivates #264. If replacement cannot be qualified before that date, +the release branch must first retarget the old helper and CI to net10.0 as a +separate change. The migration branch contains the replacement; it does not +claim clean-machine or unmodified-Lively release qualification. Electron packaging writes to `electron/build`, separate from compiled input in `electron/dist`; otherwise electron-builder excludes its own output folder @@ -260,9 +281,8 @@ does not abort startup, and the main window remains visible if the tray lacks an icon. ```powershell -powershell -NoProfile -File scripts\setup_windows_wallpaper.ps1 -BuildOnly -Rebuild -# Also prepare/install the host when required: -powershell -NoProfile -File scripts\setup_windows_wallpaper.ps1 +# Prepare/install Lively when required, using the existing backend interpreter: +powershell -NoProfile -File scripts\setup_windows_wallpaper.ps1 -Python .venv\Scripts\python.exe ``` Lively remains a separate unmodified GPL-3.0 application acquired from the @@ -377,7 +397,7 @@ the policy; changing these values does not require re-encoding character assets. ## Verification ```powershell -dotnet run --project wallpaper\windows\Amadeus.Wallpaper.Tests +.venv\Scripts\python.exe -m pytest tests\test_windows_wallpaper_helper.py cd electron npm test npm run build @@ -387,10 +407,10 @@ The separate protocol counterexample diagnostic is not an acceptance check: 独立协议反例诊断不计为验收通过: ```powershell -dotnet run --no-restore --project wallpaper\windows\Amadeus.Wallpaper.Tests -- --false-empty-counterexample +.venv\Scripts\python.exe tools\probes\windows_wallpaper_counterexample.py ``` -The Windows Electron CI job also runs the C# contract executable, builds an +The Windows Electron CI job also runs the Python lifecycle/wire contracts, builds an unpacked Windows application with electron-builder (including `beforePack`), and runs `electron/scripts/smoke-windows-wallpaper.cjs` against that package. This smoke imports the packaged startup policy and tray component, creates a @@ -403,7 +423,7 @@ it in cleanup): ```powershell .venv\Scripts\python.exe tools\probes\windows_wallpaper_lifecycle.py -dotnet run --project wallpaper\windows\Amadeus.Wallpaper.Tests -- --cold-start-probe build\windows-wallpaper +.venv\Scripts\python.exe tools\probes\windows_wallpaper_cold_start.py ``` The lifecycle probe requires a real renderer connection, then verifies the diff --git a/electron/electron-builder-code-only.cjs b/electron/electron-builder-code-only.cjs index 11452504..16308138 100644 --- a/electron/electron-builder-code-only.cjs +++ b/electron/electron-builder-code-only.cjs @@ -1,8 +1,7 @@ // Explicit developer/CI shell; never label this as a full application distribution. -const config = require('./package.json').build +const { beforePack, ...config } = require('./package.json').build module.exports = { ...config, artifactName: 'amadeus-code-only-${version}-${arch}.${ext}', - beforePack: 'scripts/build-windows-wallpaper.cjs', extraResources: config.extraResources.filter(item => item.to !== 'assets/bundled/japanese-pronunciation'), } diff --git a/electron/package.json b/electron/package.json index 87d5f763..0ccc57df 100644 --- a/electron/package.json +++ b/electron/package.json @@ -54,7 +54,7 @@ "icon": "../assets/icons/app/app_icon.ico", "extraResources": [ { "from": "../assets/icons/app/app_icon.ico", "to": "assets/icons/app/app_icon.ico" }, - { "from": "../build/windows-wallpaper/host", "to": "windows-wallpaper", "filter": ["**/*", "!*.pdb"] }, + { "from": "resources/windows-wallpaper", "to": "windows-wallpaper", "filter": ["*.py", "NOTICE.md", "Apache-2.0.txt"] }, { "from": "../scripts/setup_windows_wallpaper.ps1", "to": "windows-wallpaper/setup_windows_wallpaper.ps1" } ] } diff --git a/wallpaper/windows/notices/Apache-2.0.txt b/electron/resources/windows-wallpaper/Apache-2.0.txt similarity index 100% rename from wallpaper/windows/notices/Apache-2.0.txt rename to electron/resources/windows-wallpaper/Apache-2.0.txt diff --git a/electron/resources/windows-wallpaper/NOTICE.md b/electron/resources/windows-wallpaper/NOTICE.md new file mode 100644 index 00000000..72bb15d5 --- /dev/null +++ b/electron/resources/windows-wallpaper/NOTICE.md @@ -0,0 +1,20 @@ +# Windows wallpaper helper + +Amadeus's helper is first-party AGPL-3.0 code and uses only Python's standard +library. Python is supplied by the same runtime selection as the desktop backend; +this resource does not bundle a Python or .NET runtime. + +The bounded RPC wire adapter was implemented with reference to the external +GrpcDotNetNamedPipes protocol and framing at revision +`da307b551a9d56b62f5780e91a2d95a699f32430` (3.1.0): +https://github.com/cyanfish/grpc-dotnet-namedpipes/tree/da307b551a9d56b62f5780e91a2d95a699f32430 + +Protocol/framing reference: Copyright 2020 Google LLC; Apache License 2.0, +included in `Apache-2.0.txt`. No GrpcDotNetNamedPipes, Google.Protobuf, +Grpc.Core.Api, System.Memory, or .NET runtime binaries are distributed here. +The original source and pinned revision remain available at the URL above. + +The service field numbers describe Lively 2.2.1.0's external IPC interface. +Lively is a separate optional GPL-3.0 application obtained from its publisher; +its own prerequisites and notices are the responsibility of that distribution. +Known reporting/close limitations are tracked in Amadeus #233. diff --git a/electron/resources/windows-wallpaper/helper.py b/electron/resources/windows-wallpaper/helper.py new file mode 100644 index 00000000..6a9328fd --- /dev/null +++ b/electron/resources/windows-wallpaper/helper.py @@ -0,0 +1,988 @@ +"""Independent Windows wallpaper helper; standard library only, run with python -I. + +Lively 2.2.1.0 uses GrpcDotNetNamedPipes, not HTTP/2. The bounded wire adapter +below follows transport.proto at da307b551a9d56b62f5780e91a2d95a699f32430. +No backend imports, background RPC workers, or automatic request retries. +""" +from __future__ import annotations + +import ctypes +from ctypes import wintypes as w +from dataclasses import asdict, dataclass, replace +import json +import os +from pathlib import Path +import struct +import subprocess +import sys +import threading +import time +from urllib.parse import urlsplit + +SUPPORTED_VERSION = "2.2.1.0" +DOTNET_FILETIME_OFFSET = 504911232000000000 + + +class RpcError(OSError): + def __init__(self, code: int, detail: str): + self.code, self.detail = code, detail + super().__init__(f"Lively RPC status {code}: {detail}") + + +def varint(value: int) -> bytes: + value &= (1 << 64) - 1 + result = bytearray() + while value > 127: + result.append((value & 127) | 128) + value >>= 7 + result.append(value) + return bytes(result) + + +def read_varint(data: bytes, pos: int = 0) -> tuple[int, int]: + value = 0 + for shift in range(0, 70, 7): + if pos >= len(data): + raise ValueError("Truncated protobuf varint") + byte = data[pos] + pos += 1 + if shift == 63 and byte > 1: + raise ValueError("Invalid protobuf varint") + value |= (byte & 127) << shift + if not byte & 128: + return value, pos + raise ValueError("Invalid protobuf varint") + + +def scalar(number: int, value: int) -> bytes: + return varint(number << 3) + varint(value) if value else b"" + + +def blob(number: int, value: bytes) -> bytes: + return varint(number << 3 | 2) + varint(len(value)) + value + + +def string(number: int, value: str) -> bytes: + return blob(number, value.encode("utf-8")) if value else b"" + + +@dataclass(frozen=True) +class Field: + number: int + wire: int + value: int | bytes + raw: bytes + + +def fields(data: bytes) -> list[Field]: + """Retain each field's exact encoding, including unknown groups/duplicates.""" + def one(pos: int) -> tuple[Field, int]: + start = pos + tag, pos = read_varint(data, pos) + number, wire = tag >> 3, tag & 7 + if not 0 < number < (1 << 29): + raise ValueError("Invalid protobuf field number") + if wire == 0: + value, pos = read_varint(data, pos) + elif wire in (1, 5): + end = pos + (8 if wire == 1 else 4) + value, pos = data[pos:end], end + elif wire == 2: + size, pos = read_varint(data, pos) + end = pos + size + value, pos = data[pos:end], end + elif wire == 3: + content = pos + while True: + closing, after = read_varint(data, pos) + if closing & 7 == 4: + if closing >> 3 != number: + raise ValueError("Mismatched protobuf group") + value, pos = data[content:pos], after + break + _, pos = one(pos) + else: + raise ValueError("Invalid protobuf wire type") + if pos > len(data): + raise ValueError("Truncated protobuf field") + return Field(number, wire, value, data[start:pos]), pos + + result, pos = [], 0 + while pos < len(data): + field, pos = one(pos) + result.append(field) + return result + + +def value(data: bytes, number: int, default=0, wire: int = 0): + return next((f.value for f in reversed(fields(data)) if f.number == number and f.wire == wire), default) + + +def text_value(data: bytes, number: int) -> str: + return value(data, number, b"", 2).decode("utf-8") + + +def rewrite_settings(data: bytes, changes: dict[int, int]) -> bytes: + """Only three owned scalars may change; all other fields remain byte exact.""" + if changes.keys() - {28, 48, 49}: + raise ValueError("Only browser and image settings may be rewritten") + result, written = [], set() + for field in fields(data): + if field.number in changes and field.wire == 0: + if field.number not in written: + result.append(scalar(field.number, changes[field.number])) + written.add(field.number) + else: + result.append(field.raw) + for number, replacement in changes.items(): + if number not in written: + result.append(scalar(number, replacement)) + return b"".join(result) + + +@dataclass(frozen=True) +class ProcessIdentity: + Id: int + Started: int + + +@dataclass(frozen=True) +class DesktopItem: + Monitor: str + Path: str + + +@dataclass(frozen=True) +class HostOptions: + Arrangement: int + Browser: int + DesktopImage: bool + LockImage: bool + + +@dataclass(frozen=True) +class Snapshot: + Project: str + Monitor: str + Options: HostOptions + Wallpapers: list[DesktopItem] + OwnedProcess: ProcessIdentity | None = None + Mounted: bool = False + Phase: str | None = None + RequestProcess: ProcessIdentity | None = None + + +def identity_json(item) -> ProcessIdentity | None: + if item is None: + return None + if not isinstance(item, dict) or any(type(item.get(k)) is not int or item[k] <= 0 for k in ("Id", "Started")): + raise ValueError("Invalid wallpaper process identity") + return ProcessIdentity(item["Id"], item["Started"]) + + +def atomic_json(file: Path, item) -> None: + file.parent.mkdir(parents=True, exist_ok=True) + temporary = file.with_name(file.name + ".tmp") + with temporary.open("w", encoding="utf-8", newline="\n") as stream: + json.dump(item, stream, ensure_ascii=True, indent=2) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, file) + + +def same_path(left: str, right: str) -> bool: + return left.lower() == right.lower() + + +class Session: + """The journal owns recovery; process ownership never substitutes for it.""" + def __init__(self, host, journal: Path): + self.host, self.journal = host, Path(journal) + self.managed_cleanup_process = None + + def read(self) -> Snapshot: + item = json.loads(self.journal.read_text(encoding="utf-8-sig")) + phase = item.get("Phase") + if phase not in (None, "Preparing", "Pending", "Settled"): + raise ValueError("Invalid wallpaper recovery phase") + snapshot = Snapshot(item["Project"], item["Monitor"], HostOptions(**item["Options"]), + [DesktopItem(**x) for x in item["Wallpapers"]], identity_json(item.get("OwnedProcess")), + item.get("Mounted", False), phase, identity_json(item.get("RequestProcess"))) + if phase == "Pending" and snapshot.RequestProcess is None: + raise ValueError("Invalid wallpaper recovery journal: Pending requires a request receiver identity.") + return snapshot + + def save(self, snapshot: Snapshot) -> None: + atomic_json(self.journal, asdict(snapshot)) + + def release_unstarted_host(self, process: ProcessIdentity | None) -> None: + if process is not None and process != self.managed_cleanup_process and not self.journal.exists(): + self.host.stop_owned_process(process) + + def mount(self, snapshot: Snapshot) -> None: + if self.journal.exists(): + raise RuntimeError("Recover the previous wallpaper session first.") + snapshot = replace(snapshot, Mounted=False, Phase="Preparing", RequestProcess=None) + self.save(snapshot) + self.managed_cleanup_process = snapshot.OwnedProcess + + def settled(): + nonlocal snapshot + updated = replace(snapshot, Phase="Settled") + self.save(updated) + snapshot = updated + + def dispatching(process): + nonlocal snapshot + updated = replace(snapshot, Phase="Pending", RequestProcess=process) + self.save(updated) + snapshot = updated + + try: + self.host.set_session_options() + self.host.set_wallpaper(DesktopItem(snapshot.Monitor, snapshot.Project), settled, dispatching) + self.save(replace(snapshot, Mounted=True, Phase="Settled")) + except Exception as primary: + if snapshot.Phase == "Pending": + raise + try: + self.restore() + except Exception as cleanup: + raise ExceptionGroup("Wallpaper mount and rollback failed.", [primary, cleanup]) from None + raise + + def run_mounted(self, snapshot: Snapshot, body) -> None: + self.mount(snapshot) + try: + body() + except Exception as primary: + try: + self.restore() + except Exception as cleanup: + raise ExceptionGroup("Wallpaper body and cleanup failed.", [primary, cleanup]) from None + raise + else: + self.restore() + + def prepare_recovery(self) -> None: + if not self.journal.exists(): + return + snapshot = self.read() + if snapshot.Phase != "Pending": + return + if self.host.is_process_running(snapshot.RequestProcess): + raise RuntimeError("Wallpaper mount request may still complete in its original Lively process. " + "Fully exit Lively, then reopen managed wallpaper or run recover.") + self.save(replace(snapshot, Mounted=False, Phase="Settled")) + + def restore(self, restarted_process: ProcessIdentity | None = None) -> None: + self.prepare_recovery() + if not self.journal.exists(): + return + snapshot = self.read() + self.managed_cleanup_process = restarted_process or snapshot.OwnedProcess + if snapshot.Phase == "Preparing": + self.host.restore_options(snapshot.Options) + options = self.host.get_options() + current = self.host.get_wallpapers() + changed = options.Arrangement != snapshot.Options.Arrangement or any( + not any(old.Monitor == x.Monitor and same_path(old.Path, x.Path) for old in snapshot.Wallpapers) + for x in current) + if snapshot.OwnedProcess and not changed: + self.host.stop_owned_process(restarted_process or snapshot.OwnedProcess) + self.journal.unlink() + return + current = self.host.get_wallpapers() + options = self.host.get_options() + ours = [x for x in current if same_path(x.Path, snapshot.Project)] + layout_unchanged = options.Arrangement == snapshot.Options.Arrangement + affected = {snapshot.Monitor} if snapshot.Options.Arrangement == 0 else { + snapshot.Monitor, *(x.Monitor for x in snapshot.Wallpapers)} + restorable = {monitor for monitor in affected if any(x.Monitor == monitor for x in ours) or ( + (not snapshot.Mounted or restarted_process is not None) and all(x.Monitor != monitor for x in current))} + if snapshot.Options.Arrangement != 0 and ours: + restorable = affected + if ours or (snapshot.Phase == "Settled" and not snapshot.Mounted): + self.save(replace(snapshot, Mounted=False)) + self.host.close_project(snapshot.Project) + if layout_unchanged: + self.host.restore_options(snapshot.Options, images=False) + for wallpaper in snapshot.Wallpapers: + if wallpaper.Monitor in restorable: + self.host.set_wallpaper(wallpaper) + self.host.restore_options(snapshot.Options) + after = self.host.get_wallpapers() + if any(same_path(x.Path, snapshot.Project) and not any( + old.Monitor == x.Monitor and same_path(old.Path, x.Path) for old in snapshot.Wallpapers) for x in after) or ( + layout_unchanged and any(not any(x.Monitor == old.Monitor and same_path(x.Path, old.Path) for x in after) + for old in snapshot.Wallpapers if old.Monitor in restorable)): + raise OSError("Wallpaper restoration was not confirmed; recovery journal retained.") + user_changed = not layout_unchanged or any(not any( + old.Monitor == x.Monitor and same_path(old.Path, x.Path) for old in snapshot.Wallpapers) for x in after) + if snapshot.OwnedProcess and not user_changed: + self.host.stop_owned_process(restarted_process or snapshot.OwnedProcess) + self.journal.unlink() + + +class OVERLAPPED(ctypes.Structure): + _fields_ = [("Internal", ctypes.c_size_t), ("InternalHigh", ctypes.c_size_t), + ("Offset", w.DWORD), ("OffsetHigh", w.DWORD), ("hEvent", w.HANDLE)] + + +class PROCESSENTRY32W(ctypes.Structure): + _fields_ = [("dwSize", w.DWORD), ("cntUsage", w.DWORD), ("th32ProcessID", w.DWORD), + ("th32DefaultHeapID", ctypes.c_size_t), ("th32ModuleID", w.DWORD), ("cntThreads", w.DWORD), + ("th32ParentProcessID", w.DWORD), ("pcPriClassBase", w.LONG), ("dwFlags", w.DWORD), + ("szExeFile", w.WCHAR * 260)] + + +class Windows: + def __init__(self): + self.api = ctypes.WinDLL("kernel32", use_last_error=True) + signatures = { + "CreateFileW": (w.HANDLE, [w.LPCWSTR, w.DWORD, w.DWORD, w.LPVOID, w.DWORD, w.DWORD, w.HANDLE]), + "CloseHandle": (w.BOOL, [w.HANDLE]), + "CreateEventW": (w.HANDLE, [w.LPVOID, w.BOOL, w.BOOL, w.LPCWSTR]), + "WaitForSingleObject": (w.DWORD, [w.HANDLE, w.DWORD]), + "ReadFile": (w.BOOL, [w.HANDLE, w.LPVOID, w.DWORD, ctypes.POINTER(w.DWORD), ctypes.POINTER(OVERLAPPED)]), + "WriteFile": (w.BOOL, [w.HANDLE, w.LPCVOID, w.DWORD, ctypes.POINTER(w.DWORD), ctypes.POINTER(OVERLAPPED)]), + "GetOverlappedResult": (w.BOOL, [w.HANDLE, ctypes.POINTER(OVERLAPPED), ctypes.POINTER(w.DWORD), w.BOOL]), + "CancelIoEx": (w.BOOL, [w.HANDLE, ctypes.POINTER(OVERLAPPED)]), + "SetNamedPipeHandleState": (w.BOOL, [w.HANDLE, ctypes.POINTER(w.DWORD), w.LPVOID, w.LPVOID]), + "GetNamedPipeServerProcessId": (w.BOOL, [w.HANDLE, ctypes.POINTER(w.ULONG)]), + "OpenProcess": (w.HANDLE, [w.DWORD, w.BOOL, w.DWORD]), + "GetProcessTimes": (w.BOOL, [w.HANDLE] + [ctypes.POINTER(w.FILETIME)] * 4), + "ProcessIdToSessionId": (w.BOOL, [w.DWORD, ctypes.POINTER(w.DWORD)]), + "QueryFullProcessImageNameW": (w.BOOL, [w.HANDLE, w.DWORD, w.LPWSTR, ctypes.POINTER(w.DWORD)]), + "CreateToolhelp32Snapshot": (w.HANDLE, [w.DWORD, w.DWORD]), + "Process32FirstW": (w.BOOL, [w.HANDLE, ctypes.POINTER(PROCESSENTRY32W)]), + "Process32NextW": (w.BOOL, [w.HANDLE, ctypes.POINTER(PROCESSENTRY32W)]), + } + for name, (result, args) in signatures.items(): + function = getattr(self.api, name) + function.restype, function.argtypes = result, args + + def check(self, result): + if not result: + raise ctypes.WinError(ctypes.get_last_error()) + return result + + def close(self, handle): + self.api.CloseHandle(handle) + + def open_process(self, pid): + handle = self.api.OpenProcess(0x1000 | 0x100000, False, pid) + if not handle and ctypes.get_last_error() == 87: + return None + return self.check(handle) + + def identity(self, pid: int) -> ProcessIdentity: + handle = self.open_process(pid) + if handle is None: + raise ProcessLookupError("Wallpaper process exited during identity lookup") + try: + started, exited, kernel, user = (w.FILETIME() for _ in range(4)) + self.check(self.api.GetProcessTimes(handle, ctypes.byref(started), ctypes.byref(exited), + ctypes.byref(kernel), ctypes.byref(user))) + ticks = (started.dwHighDateTime << 32) | started.dwLowDateTime + return ProcessIdentity(pid, ticks + DOTNET_FILETIME_OFFSET) + finally: + self.close(handle) + + def is_running(self, identity: ProcessIdentity) -> bool: + handle = self.open_process(identity.Id) + if handle is None: + return False + try: + state = self.api.WaitForSingleObject(handle, 0) + if state == 0: + return False + if state != 258: + raise ctypes.WinError(ctypes.get_last_error()) + # Read from the same process object; a reused PID cannot change it. + started, exited, kernel, user = (w.FILETIME() for _ in range(4)) + self.check(self.api.GetProcessTimes(handle, ctypes.byref(started), ctypes.byref(exited), + ctypes.byref(kernel), ctypes.byref(user))) + return ((started.dwHighDateTime << 32) | started.dwLowDateTime) + DOTNET_FILETIME_OFFSET == identity.Started + finally: + self.close(handle) + + def receiver(self, pipe) -> ProcessIdentity: + pid = w.ULONG() + self.check(self.api.GetNamedPipeServerProcessId(pipe, ctypes.byref(pid))) + return self.identity(pid.value) + + def username(self) -> str: + api = ctypes.WinDLL("advapi32", use_last_error=True) + api.GetUserNameW.argtypes = [w.LPWSTR, ctypes.POINTER(w.DWORD)] + api.GetUserNameW.restype = w.BOOL + size, buffer = w.DWORD(257), ctypes.create_unicode_buffer(257) + self.check(api.GetUserNameW(buffer, ctypes.byref(size))) + return buffer.value + + def running(self) -> ProcessIdentity | None: + current = w.DWORD() + self.check(self.api.ProcessIdToSessionId(os.getpid(), ctypes.byref(current))) + snapshot = self.api.CreateToolhelp32Snapshot(2, 0) + if snapshot == ctypes.c_void_p(-1).value: + raise ctypes.WinError(ctypes.get_last_error()) + try: + entry = PROCESSENTRY32W() + entry.dwSize = ctypes.sizeof(entry) + found = self.api.Process32FirstW(snapshot, ctypes.byref(entry)) + while found: + if entry.szExeFile.lower() == "lively.exe": + session = w.DWORD() + self.check(self.api.ProcessIdToSessionId(entry.th32ProcessID, ctypes.byref(session))) + if session.value == current.value: + try: + return self.identity(entry.th32ProcessID) + except ProcessLookupError: + pass + found = self.api.Process32NextW(snapshot, ctypes.byref(entry)) + if ctypes.get_last_error() != 18: + raise ctypes.WinError(ctypes.get_last_error()) + return None + finally: + self.close(snapshot) + + def executable(self, pid: int) -> str: + handle = self.open_process(pid) + if handle is None: + raise ProcessLookupError("Lively exited") + try: + size, buffer = w.DWORD(32768), ctypes.create_unicode_buffer(32768) + self.check(self.api.QueryFullProcessImageNameW(handle, 0, buffer, ctypes.byref(size))) + return buffer.value + finally: + self.close(handle) + + def lock(self, file: Path): + file.parent.mkdir(parents=True, exist_ok=True) + handle = self.api.CreateFileW(str(file), 0xC0000000, 0, None, 4, 0x80, None) + if handle == ctypes.c_void_p(-1).value: + raise ctypes.WinError(ctypes.get_last_error()) + return handle + + +class Budget: + def __init__(self, seconds: float, cancel: threading.Event | None = None): + self.until = time.monotonic() + seconds + self.utc_ns = time.time_ns() + int(seconds * 1_000_000_000) + self.cancel = cancel + + def check(self): + if time.monotonic() >= self.until: + raise RpcError(4, "Deadline exceeded") + if self.cancel is not None and self.cancel.is_set(): + raise RpcError(1, "Cancelled") + + +class Pipe: + """Overlapped IO is cancelled and drained before its buffers are released.""" + def __init__(self, windows: Windows, name: str, budget: Budget): + self.windows, self.handle = windows, None + while True: + budget.check() + handle = windows.api.CreateFileW("\\\\.\\pipe\\" + name, 0xC0000000, 0, None, 3, 0x40000000, None) + if handle != ctypes.c_void_p(-1).value: + self.handle = handle + break + error = ctypes.get_last_error() + if error not in (2, 231): + raise ctypes.WinError(error) + time.sleep(min(0.02, max(0, budget.until - time.monotonic()))) + try: + budget.check() + mode = w.DWORD(2) + windows.check(windows.api.SetNamedPipeHandleState(handle, ctypes.byref(mode), None, None)) + except BaseException: + self.close() + raise + + def close(self): + if self.handle is not None: + self.windows.close(self.handle) + self.handle = None + + def receiver(self): + return self.windows.receiver(self.handle) + + def io(self, data: bytes | None, budget: Budget) -> tuple[bytes, bool]: + budget.check() + api = self.windows.api + event = self.windows.check(api.CreateEventW(None, True, False, None)) + overlap = OVERLAPPED(hEvent=event) + buffer = ctypes.create_string_buffer(data, len(data)) if data is not None else ctypes.create_string_buffer(16384) + count = w.DWORD() + try: + operation = api.WriteFile if data is not None else api.ReadFile + success = operation(self.handle, buffer, len(buffer), ctypes.byref(count), ctypes.byref(overlap)) + error = 0 if success else ctypes.get_last_error() + if error == 234: + # With overlapped message reads, ERROR_MORE_DATA can complete + # immediately without filling lpNumberOfBytesRead. The result + # API owns the actual byte count for this partial message. + api.GetOverlappedResult(self.handle, ctypes.byref(overlap), ctypes.byref(count), False) + if error == 997: + try: + while True: + budget.check() + state = api.WaitForSingleObject(event, max(1, min(25, int((budget.until - time.monotonic()) * 1000)))) + if state == 0: + break + if state != 258: + raise ctypes.WinError(ctypes.get_last_error()) + success = api.GetOverlappedResult(self.handle, ctypes.byref(overlap), ctypes.byref(count), False) + error = 0 if success else ctypes.get_last_error() + except BaseException: + # Windows retains the OVERLAPPED/buffer until completion, even + # after CancelIoEx. No detached work can later dispatch a Set. + api.CancelIoEx(self.handle, ctypes.byref(overlap)) + api.GetOverlappedResult(self.handle, ctypes.byref(overlap), ctypes.byref(count), True) + raise + if error not in (0, 234): + raise ctypes.WinError(error) + if data is not None and count.value != len(data): + raise OSError("Incomplete named-pipe write") + if data is None and count.value == 0: + raise EOFError("Lively closed the RPC pipe before trailers") + return buffer.raw[:count.value], error != 234 + finally: + self.windows.close(event) + + def write(self, data: bytes, budget: Budget): + self.io(data, budget) + + def read(self, budget: Budget) -> bytes: + chunks = [] + while True: + data, complete = self.io(None, budget) + chunks.append(data) + if complete: + return b"".join(chunks) + + +def transport_message(number: int, payload: bytes | int) -> bytes: + message = scalar(number, payload) if isinstance(payload, int) else blob(number, payload) + return varint(len(message)) + message + + +class Rpc: + def __init__(self, windows: Windows, name: str | None = None, pipe_factory=Pipe): + self.windows = windows + self.name = name or "Grpc_LIVELY:DESKTOPWALLPAPERSYSTEM" + windows.username() + self.pipe_factory = pipe_factory + + def call(self, service: str, method: str, payload: bytes = b"", *, timeout: float = 15, + dispatching=None, cancel: threading.Event | None = None, streaming: bool = False) -> list[bytes]: + budget = Budget(timeout, cancel) + pipe = self.pipe_factory(self.windows, self.name, budget) + sent = False + try: + budget.check() + if dispatching is not None: + identity = pipe.receiver() + budget.check() + # Runs on this call's thread. A deadline cannot return while the + # journal callback is entered; no background serializer survives. + dispatching(identity) + budget.check() + seconds, nanos = divmod(budget.utc_ns, 1_000_000_000) + init = string(1, f"/Lively.Grpc.Common.Proto.{service}.{service}Service/{method}") + blob( + 2, scalar(1, seconds) + scalar(2, nanos)) + packet = (transport_message(1, init) + transport_message(2, b"") + + transport_message(3, scalar(1, len(payload)) + scalar(2, 1)) + payload) + sent = True # A partially written request is also uncertain. + pipe.write(packet, budget) + result = [] + while True: + packet, pos = pipe.read(budget), 0 + while pos < len(packet): + size, pos = read_varint(packet, pos) + end = pos + size + if end > len(packet): + raise ValueError("Truncated Lively transport message") + message, pos = fields(packet[pos:end]), end + if len(message) != 1: + raise ValueError("Invalid Lively transport envelope") + field = message[0] + if field.number == 3 and field.wire == 2: + length = value(field.value, 1) + if value(field.value, 2): + end = pos + length + if end > len(packet): + raise ValueError("Truncated inline Lively payload") + result.append(packet[pos:end]) + pos = end + else: + separate = pipe.read(budget) + if len(separate) != length: + raise ValueError("Invalid separate Lively payload length") + result.append(separate) + elif field.number == 5 and field.wire == 2: + code, detail = value(field.value, 2), text_value(field.value, 3) + if code: + raise RpcError(code, detail) + if not streaming and len(result) != 1: + raise ValueError("Expected one unary Lively response payload") + return result + elif field.number == 4 and field.value == 1: + raise RpcError(1, "Server cancelled") + elif field.number not in (2, 4): + raise ValueError("Unexpected Lively transport message") + except BaseException: + if sent: + try: + pipe.write(transport_message(4, 1), Budget(0.05)) + except Exception: + pass # Preserve the original error; cancel is not exit proof. + raise + finally: + pipe.close() + + +class LivelyHost: + def __init__(self, windows: Windows, data_directory: Path | None = None, rpc: Rpc | None = None): + self.windows = windows + self.data_directory = data_directory or Path(os.environ["LOCALAPPDATA"]) / "Lively Wallpaper" + self.rpc = rpc or Rpc(windows) + self.started_process = None + + @property + def launch_backup(self): + return self.data_directory / "Amadeus.launch-settings.json" + + def is_process_running(self, identity): + return self.windows.is_running(identity) + + def find_executable(self) -> str | None: + import winreg + running = self.windows.running() + if running: + return self.windows.executable(running.Id) + uninstall = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): + try: + root = winreg.OpenKey(hive, uninstall) + except FileNotFoundError: + continue + with root: + for index in range(winreg.QueryInfoKey(root)[0]): + with winreg.OpenKey(root, winreg.EnumKey(root, index)) as key: + try: + name = winreg.QueryValueEx(key, "DisplayName")[0] + if not name.startswith("Lively Wallpaper"): + continue + executable = Path(winreg.QueryValueEx(key, "InstallLocation")[0]) / "Lively.exe" + if executable.is_file(): + return str(executable) + except FileNotFoundError: + continue + for directory in (Path(os.environ["LOCALAPPDATA"]) / "Programs", Path(os.environ["PROGRAMFILES"])): + executable = directory / "Lively Wallpaper" / "Lively.exe" + if executable.is_file(): + return str(executable) + return None + + @staticmethod + def check_version(version): + if version != SUPPORTED_VERSION: + raise RuntimeError(f"Managed wallpaper supports Lively {SUPPORTED_VERSION}; found {version}. " + "Existing installation was not changed.") + + @staticmethod + def file_version(file: str) -> str: + api = ctypes.WinDLL("version", use_last_error=True) + api.GetFileVersionInfoSizeW.argtypes = [w.LPCWSTR, ctypes.POINTER(w.DWORD)] + api.GetFileVersionInfoSizeW.restype = w.DWORD + api.GetFileVersionInfoW.argtypes = [w.LPCWSTR, w.DWORD, w.DWORD, w.LPVOID] + api.GetFileVersionInfoW.restype = w.BOOL + api.VerQueryValueW.argtypes = [w.LPCVOID, w.LPCWSTR, ctypes.POINTER(w.LPVOID), ctypes.POINTER(w.UINT)] + api.VerQueryValueW.restype = w.BOOL + unused = w.DWORD() + size = api.GetFileVersionInfoSizeW(file, ctypes.byref(unused)) + if not size: + raise ctypes.WinError(ctypes.get_last_error()) + buffer = ctypes.create_string_buffer(size) + if not api.GetFileVersionInfoW(file, 0, size, buffer): + raise ctypes.WinError(ctypes.get_last_error()) + pointer, length = w.LPVOID(), w.UINT() + if not api.VerQueryValueW(buffer, "\\", ctypes.byref(pointer), ctypes.byref(length)) or length.value < 16: + raise OSError("Could not read Lively file version") + signature, _, high, low = struct.unpack("<4I", ctypes.string_at(pointer, 16)) + if signature != 0xFEEF04BD: + raise ValueError("Invalid Lively version resource") + return f"{high >> 16}.{high & 65535}.{low >> 16}.{low & 65535}" + + @staticmethod + def prepare_first_run(directory: Path) -> bool: + directory.mkdir(parents=True, exist_ok=True) + file = directory / "Settings.json" + try: + with file.open("x", encoding="utf-8") as stream: + json.dump(dict(AppVersion=SUPPORTED_VERSION, IsFirstRun=False, Startup=False, WebBrowser=1, + SysTrayIcon=False, DesktopAutoWallpaper=False, LockScreenAutoWallpaper=False), stream) + stream.flush() + os.fsync(stream.fileno()) + except FileExistsError: + return False + return True + + def prepare_launch_images(self): + if self.launch_backup.exists(): + return + file = self.data_directory / "Settings.json" + settings = json.loads(file.read_text(encoding="utf-8-sig")) + previous = dict(Desktop=settings.get("DesktopAutoWallpaper", False), Lock=settings.get("LockScreenAutoWallpaper", False)) + if not any(previous.values()): + return + with self.launch_backup.open("x", encoding="utf-8") as stream: + json.dump(previous, stream) + stream.flush() + os.fsync(stream.fileno()) + settings.update(DesktopAutoWallpaper=False, LockScreenAutoWallpaper=False) + atomic_json(file, settings) + + def get_settings(self) -> bytes: + return self.rpc.call("Settings", "GetSettings")[0] + + def set_settings(self, settings: bytes): + self.rpc.call("Settings", "SetSettings", settings) + + def recover_launch_images(self): + if not self.launch_backup.exists(): + return + previous = json.loads(self.launch_backup.read_text(encoding="utf-8-sig")) + if self.windows.running(): + self.set_settings(rewrite_settings(self.get_settings(), {49: int(previous["Desktop"]), 48: int(previous["Lock"])})) + else: + file = self.data_directory / "Settings.json" + settings = json.loads(file.read_text(encoding="utf-8-sig")) + settings.update(DesktopAutoWallpaper=previous["Desktop"], LockScreenAutoWallpaper=previous["Lock"]) + atomic_json(file, settings) + self.launch_backup.unlink() + + def ensure_running(self): + try: + self.start_and_wait() + finally: + self.recover_launch_images() + + def start_and_wait(self): + expected = None + if self.windows.running() is None: + executable = self.find_executable() + if not executable: + raise RuntimeError("Lively Wallpaper is missing. Run the Windows wallpaper setup.") + self.check_version(self.file_version(executable)) + self.prepare_first_run(self.data_directory) + self.prepare_launch_images() + layout = self.data_directory / "WallpaperLayout.json" + if layout.exists(): + expected = [DesktopItem(x["LivelyScreen"]["DeviceId"], x["LivelyInfoPath"]) + for x in json.loads(layout.read_text(encoding="utf-8-sig"))] + process = subprocess.Popen([executable], creationflags=subprocess.CREATE_NO_WINDOW) + self.started_process = self.windows.identity(process.pid) + until, last = time.monotonic() + 45, None + while time.monotonic() < until: + try: + stats = self.rpc.call("Desktop", "GetCoreStats", timeout=2)[0] + self.check_version(text_value(stats, 2)) + if value(stats, 3): + connected = {x.Monitor for x, _ in self.get_screens()} + current = self.get_wallpapers() + if expected is None or all(any(x.Monitor == old.Monitor and same_path(x.Path, old.Path) for x in current) + for old in expected if old.Monitor in connected): + return + except RpcError as error: + last = error + time.sleep(0.2) + raise TimeoutError("Lively did not become ready.") from last + + def get_options(self) -> HostOptions: + settings = self.get_settings() + return HostOptions(value(settings, 16), value(settings, 28), bool(value(settings, 49)), bool(value(settings, 48))) + + def set_session_options(self): + settings = self.get_settings() + if value(settings, 28) != 1 or value(settings, 48) or value(settings, 49): + self.set_settings(rewrite_settings(settings, {28: 1, 48: 0, 49: 0})) + + def restore_options(self, previous: HostOptions, images=True): + settings = self.get_settings() + changes = {} + if value(settings, 28) == 1 and previous.Browser != 1: + changes[28] = previous.Browser + if images: + if not value(settings, 49) and previous.DesktopImage: + changes[49] = 1 + if not value(settings, 48) and previous.LockImage: + changes[48] = 1 + if changes: + self.set_settings(rewrite_settings(settings, changes)) + + def get_wallpapers(self) -> list[DesktopItem]: + return [DesktopItem(text_value(value(item, 2, b"", 2), 1), text_value(item, 1)) + for item in self.rpc.call("Desktop", "GetWallpapers", streaming=True)] + + def get_screens(self): + screens = self.rpc.call("Display", "GetScreens")[0] + return [(DesktopItem(text_value(f.value, 1), ""), bool(value(f.value, 6))) + for f in fields(screens) if f.number == 1 and f.wire == 2] + + def primary_monitor(self): + primary = [screen.Monitor for screen, is_primary in self.get_screens() if is_primary] + if len(primary) != 1: + raise ValueError("Expected exactly one primary Lively display") + return primary[0] + + def write_project(self, url): + directory = Path(text_value(self.get_settings(), 33)) / "wallpapers" / "amadeus-managed" + atomic_json(directory / "LivelyInfo.json", dict(AppVersion=SUPPORTED_VERSION, Title="Amadeus", Desc="Managed by Amadeus", + Author="Amadeus", Type=3, FileName=url, IsAbsolutePath=True, Arguments="", Contact="", License="AGPL-3.0")) + return str(directory) + + def set_wallpaper(self, wallpaper, request_settled=None, request_dispatching=None): + self.rpc.call("Desktop", "SetWallpaper", string(1, wallpaper.Path) + string(2, wallpaper.Monitor), + dispatching=request_dispatching) + if request_settled: + request_settled() + until = time.monotonic() + 30 + while time.monotonic() < until: + if any(same_path(x.Path, wallpaper.Path) and x.Monitor == wallpaper.Monitor for x in self.get_wallpapers()): + return + time.sleep(0.2) + raise TimeoutError("Lively did not confirm the wallpaper mount.") + + def close_project(self, project): + self.rpc.call("Desktop", "CloseWallpaperLibrary", string(2, project)) + + def screenshot(self, file): + self.rpc.call("Desktop", "TakeScreenshot", string(1, self.primary_monitor()) + string(2, str(file.resolve()))) + if not file.is_file(): + raise OSError("The Lively player did not produce a screenshot.") + + def stop_owned_process(self, identity): + if not self.windows.is_running(identity): + return + + def receiver(actual): + if actual != identity: + raise RuntimeError("Lively shutdown pipe belongs to another process; shutdown blocked.") + try: + self.rpc.call("Commands", "AutomationCommand", b"".join(string(1, x) for x in ("app", "--shutdown", "true")), + dispatching=receiver) + except (OSError, EOFError): + if not self.windows.is_running(identity): + return + raise + until = time.monotonic() + 15 + while self.windows.is_running(identity): + if time.monotonic() >= until: + raise TimeoutError("Owned Lively process did not exit.") + time.sleep(0.05) + + +def error_detail(error: BaseException) -> str: + if isinstance(error, BaseExceptionGroup): + return str(error) + ": " + "; ".join(error_detail(x) for x in error.exceptions) + return str(error) + + +def report(item): + try: + # Raw output has no buffered data for Python to flush again at shutdown + # after the parent's stdout reader has disappeared. + pending = memoryview((json.dumps(item, ensure_ascii=True) + "\n").encode("ascii")) + while pending: + pending = pending[os.write(sys.stdout.fileno(), pending):] + except OSError: + pass # Parent death closes stdout too; restoration must still finish. + + +def main(args=None) -> int: + if sys.platform != "win32": + raise RuntimeError("Windows wallpaper helper only.") + args = list(sys.argv[1:] if args is None else args) + windows = Windows() + host = LivelyHost(windows) + state = Path(os.environ["LOCALAPPDATA"]) / "Amadeus" / "wallpaper" + journal = state / "session.json" + session = Session(host, journal) + lock = None + try: + command = args[0] if args else "inspect" + if command == "screenshot" and len(args) == 2: + host.screenshot(Path(args[1])) + report(dict(screenshot=str(Path(args[1]).resolve()))) + return 0 + if command == "inspect": + running = windows.running() + report(dict(executable=host.find_executable(), running=running is not None, recoveryPending=journal.exists(), + wallpapers=[asdict(x) for x in host.get_wallpapers()] if running else None, + options=asdict(host.get_options()) if running else None)) + return 0 + if command == "prepare": + report(dict(prepared=host.prepare_first_run(host.data_directory))) + return 0 + if command not in ("run", "recover"): + raise ValueError("Expected inspect, prepare, run or recover.") + if command == "run": + url = urlsplit(args[1]) if len(args) == 2 else None + if url is None or url.scheme != "http" or url.hostname != "127.0.0.1" or url.path != "/wallpaper/lively/index.html": + raise ValueError("Expected the local Amadeus wallpaper wrapper URL.") + lock = windows.lock(state / "session.lock") + if command == "recover" and not journal.exists(): + host.recover_launch_images() + report(dict(status="clean")) + return 0 + session.prepare_recovery() + host.ensure_running() + session.restore(host.started_process) + if command == "recover": + report(dict(status="restored")) + return 0 + host.ensure_running() + snapshot = Snapshot(host.write_project(args[1]), host.primary_monitor(), host.get_options(), + host.get_wallpapers(), host.started_process) + + def body(): + report(dict(status="mounted")) + lively = windows.running() + if lively is None: + raise OSError("Lively exited after mounting.") + stopped = threading.Event() + def read_input(): + try: + # A daemon blocked in TextIOWrapper.readline holds Python's + # buffered-reader lock during host-exit finalization. Raw IO + # lets the helper exit even while its parent still has stdin. + while (byte := os.read(sys.stdin.fileno(), 1)) and byte != b"\n": + pass + finally: + stopped.set() + threading.Thread(target=read_input, daemon=True).start() + while not stopped.wait(0.1): + if not windows.is_running(lively): + raise OSError("Lively exited unexpectedly; the wallpaper recovery journal is retained.") + session.run_mounted(snapshot, body) + report(dict(status="restored")) + return 0 + except Exception as error: + try: + session.release_unstarted_host(host.started_process) + except Exception as cleanup: + try: + print(error_detail(cleanup), file=sys.stderr, flush=True) + except OSError: + pass + report(dict(status="error", error=error_detail(error), recoveryPending=journal.exists())) + return 1 + finally: + if lock is not None: + windows.close(lock) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/electron/scripts/build-runtime-resources.cjs b/electron/scripts/build-runtime-resources.cjs index 8277c08a..6712ea53 100644 --- a/electron/scripts/build-runtime-resources.cjs +++ b/electron/scripts/build-runtime-resources.cjs @@ -1,7 +1,6 @@ const fs = require('node:fs') const path = require('node:path') const { createHash } = require('node:crypto') -const buildWallpaper = require('./build-windows-wallpaper.cjs') function verifyDictionary(directory, pin) { const names = ['userdict.csv', 'provenance.json', 'NOTICE.md', 'GPT-SoVITS-MIT.txt', @@ -37,9 +36,8 @@ function prepareDictionary(root) { return staged } -module.exports = async context => { +module.exports = async () => { prepareDictionary(path.resolve(__dirname, '../..')) - await buildWallpaper(context) } module.exports.prepareDictionary = prepareDictionary module.exports.verifyDictionary = verifyDictionary diff --git a/electron/scripts/build-windows-wallpaper.cjs b/electron/scripts/build-windows-wallpaper.cjs deleted file mode 100644 index 92dbb4c2..00000000 --- a/electron/scripts/build-windows-wallpaper.cjs +++ /dev/null @@ -1,11 +0,0 @@ -const { execFileSync } = require('node:child_process') -const path = require('node:path') - -// electron-builder hook: other targets must not build or install Windows tools. -module.exports = async context => { - if (context.electronPlatformName !== 'win32') return - execFileSync('powershell.exe', [ - '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', - path.resolve(__dirname, '../../scripts/setup_windows_wallpaper.ps1'), '-BuildOnly', '-Rebuild', - ], { stdio: 'inherit', windowsHide: true }) -} diff --git a/electron/scripts/probe-windows-wallpaper.cjs b/electron/scripts/probe-windows-wallpaper.cjs index dd5e7396..a8125809 100644 --- a/electron/scripts/probe-windows-wallpaper.cjs +++ b/electron/scripts/probe-windows-wallpaper.cjs @@ -10,8 +10,10 @@ const { pathToFileURL } = require('node:url') const execute = promisify(execFile) const root = path.resolve(__dirname, '../..') const output = path.join(root, 'build/windows-wallpaper') -const helper = path.join(output, 'host/Amadeus.Wallpaper.exe') -const inspect = async () => JSON.parse((await execute(helper, ['inspect'], { windowsHide: true })).stdout.trim()) +const helper = path.join(root, 'electron/resources/windows-wallpaper/helper.py') +const runtime = import(pathToFileURL(path.join(root, 'electron/dist/main/pythonRuntime.js')).href) +const executeHelper = async args => execute((await runtime).projectPythonCommand(root), ['-I', helper, ...args], { windowsHide: true }) +const inspect = async () => JSON.parse((await executeHelper(['inspect'])).stdout.trim()) process.env.NODE_ENV = 'production' // This probe explicitly selects wallpaper regardless of the saved GUI mode. process.env.AMADEUS_WALLPAPER = '1' @@ -39,7 +41,7 @@ app.on('will-quit', event => { void (async () => { // Killing the helper deliberately removes its restoration worker. Use its // documented recovery command to restore the user's desktop in cleanup. - if (faultInjected) await execute(helper, ['recover'], { windowsHide: true }) + if (faultInjected) await executeHelper(['recover']) return inspect() })().then(after => { const passed = !failed && JSON.stringify(before.wallpapers) === JSON.stringify(after.wallpapers) @@ -62,7 +64,7 @@ void (async () => { if (current.wallpapers.some(item => path.basename(item.Path) === 'amadeus-managed') && slices.length > 0) { await new Promise(resolve => setTimeout(resolve, 10000)) try { - await execute(helper, ['screenshot', path.join(output, 'electron-mounted-scene.jpg')], { windowsHide: true }) + await executeHelper(['screenshot', path.join(output, 'electron-mounted-scene.jpg')]) } catch (error) { console.warn('Wallpaper screenshot unavailable:', error.message) } const image = await slices[0].webContents.capturePage() fs.writeFileSync(path.join(output, 'electron-slice.png'), image.toPNG()) @@ -103,7 +105,7 @@ void (async () => { 'X-Amadeus-Token': token, Origin: 'https://untrusted.invalid', } })).status, 403) const children = JSON.parse((await execute('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', - `@(Get-CimInstance Win32_Process -Filter "Name = 'Amadeus.Wallpaper.exe'" | Where-Object ParentProcessId -eq ${process.pid} | Select-Object -ExpandProperty ProcessId) | ConvertTo-Json -Compress`, + `@(Get-CimInstance Win32_Process -Filter "ParentProcessId = ${process.pid}" | Where-Object { $_.Name -match '^python(w)?\\.exe$' -and $_.CommandLine.Contains('windows-wallpaper') -and $_.CommandLine.Contains('helper.py') } | Select-Object -ExpandProperty ProcessId) | ConvertTo-Json -Compress`, ], { windowsHide: true })).stdout) const ids = Array.isArray(children) ? children : [children] assert.equal(ids.length, 1, 'expected exactly one helper owned by this probe') diff --git a/electron/scripts/smoke-windows-wallpaper.cjs b/electron/scripts/smoke-windows-wallpaper.cjs index edd5c9a4..3584ccd2 100644 --- a/electron/scripts/smoke-windows-wallpaper.cjs +++ b/electron/scripts/smoke-windows-wallpaper.cjs @@ -37,11 +37,13 @@ app.whenReady().then(async () => { try { assert.equal(fallback.hasIcon, false) } finally { fallback.destroy() } - const helper = path.join(resources, 'windows-wallpaper', 'Amadeus.Wallpaper.exe') - assert.ok(fs.existsSync(helper), 'beforePack did not publish the helper') + const helper = path.join(resources, 'windows-wallpaper', 'helper.py') + assert.ok(fs.existsSync(helper), 'Packaged Python wallpaper helper is missing') assert.ok(fs.existsSync(path.join(resources, 'windows-wallpaper', 'setup_windows_wallpaper.ps1'))) // Read-only. Never installs Lively, mounts a scene or modifies the CI desktop. - const inspected = JSON.parse(execFileSync(helper, ['inspect'], { encoding: 'utf8', windowsHide: true }).trim()) + const { projectPythonCommand } = await import(pathToFileURL(path.join(main, 'pythonRuntime.js')).href) + const python = projectPythonCommand(path.resolve(__dirname, '../..')) + const inspected = JSON.parse(execFileSync(python, ['-I', helper, 'inspect'], { encoding: 'utf8', windowsHide: true }).trim()) assert.equal(typeof inspected.running, 'boolean') console.log('PASS packaged Windows startup choices and external host ownership: native tray, missing-icon recovery, setup resources and helper runtime') app.quit() diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index d1a6afd3..31f5bdbc 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -123,7 +123,7 @@ const isDev = !app.isPackaged && process.env.NODE_ENV !== 'production' const devServer = isDev ? developmentServer(PROJECT_ROOT) : null const windowsWallpaper = managesWindowsWallpaper(process.platform, process.env) ? new WindowsWallpaperSession({ - ...windowsWallpaperDependencies(PROJECT_ROOT, process.resourcesPath, app.isPackaged), + ...windowsWallpaperDependencies(PROJECT_ROOT, process.resourcesPath, app.isPackaged, getPythonCommand()), status: status => { windowsWallpaperTray?.setStatus(status) mainWindow?.setProgressBar(['preparing', 'mounting', 'restoring'].includes(status) ? 2 : -1) diff --git a/electron/src/main/windowsWallpaper.ts b/electron/src/main/windowsWallpaper.ts index 86f631b0..78bf2c4f 100644 --- a/electron/src/main/windowsWallpaper.ts +++ b/electron/src/main/windowsWallpaper.ts @@ -147,18 +147,20 @@ export async function recoverWindowsWallpaperHostExit(error: unknown, actions: { return cleanupConfirmed } -export function windowsWallpaperDependencies(projectRoot: string, resourcesPath: string, packaged: boolean): WallpaperDependencies { - const directory = packaged ? path.join(resourcesPath, 'windows-wallpaper') : path.join(projectRoot, 'build', 'windows-wallpaper', 'host') - const executable = path.join(directory, 'Amadeus.Wallpaper.exe') +export function windowsWallpaperDependencies(projectRoot: string, resourcesPath: string, packaged: boolean, + python: string): WallpaperDependencies { + const directory = packaged ? path.join(resourcesPath, 'windows-wallpaper') : path.join(projectRoot, 'electron', 'resources', 'windows-wallpaper') + const helper = path.join(directory, 'helper.py') return { // Recheck on each new session: installation state is external, and failed // setup must be retryable without restarting Amadeus. prepare: () => new Promise((resolve, reject) => { - // Packaged builds contain the published helper. Source runs build it once. + // The helper ships with Electron; setup only checks/provisions Lively. const script = packaged ? path.join(directory, 'setup_windows_wallpaper.ps1') : path.join(projectRoot, 'scripts', 'setup_windows_wallpaper.ps1') - const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script], { + const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, + '-Python', python], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], }) let detail = '' @@ -168,7 +170,7 @@ export function windowsWallpaperDependencies(projectRoot: string, resourcesPath: child.once('exit', code => code === 0 ? resolve() : reject(new Error(`Windows wallpaper setup failed: ${detail}`))) }), launch: url => { - const child = spawn(executable, ['run', url], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }) + const child = spawn(python, ['-I', helper, 'run', url], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }) let resolveReady: () => void let rejectReady: (error: Error) => void const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject }) diff --git a/electron/tests/windowsWallpaper.test.mjs b/electron/tests/windowsWallpaper.test.mjs index 8a21fa87..2549411e 100644 --- a/electron/tests/windowsWallpaper.test.mjs +++ b/electron/tests/windowsWallpaper.test.mjs @@ -3,8 +3,39 @@ import test from 'node:test' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' +import { loadTypeScript } from './helpers/loadTypeScript.mjs' import { managesWindowsWallpaper, recoverWindowsWallpaperHostExit, stopBackendWallpaperAfterHostExit, stopWallpaperForRenderer, windowsWallpaperDependencies, WindowsWallpaperSession } from '../src/main/windowsWallpaper.ts' +const { projectPythonCommand } = loadTypeScript(new URL('../src/main/pythonRuntime.ts', import.meta.url)) + +for (const packaged of [false, true]) { + test(`helper follows Electron resources and the backend interpreter in isolated mode (packaged=${packaged})`, async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'amadeus-python-wallpaper-')) + try { + const resources = path.join(root, 'resources') + const helperDir = packaged ? path.join(resources, 'windows-wallpaper') : path.join(root, 'electron/resources/windows-wallpaper') + await fs.mkdir(helperDir, { recursive: true }) + const trace = path.join(root, 'trace.json') + await fs.writeFile(path.join(helperDir, 'helper.py'), `import json, sys +from pathlib import Path +Path(${JSON.stringify(trace)}).write_text(json.dumps(dict(isolated=sys.flags.isolated, executable=sys.executable, file=__file__, args=sys.argv[1:]))) +print('{"status":"mounted"}', flush=True) +sys.stdin.readline() +`) + const python = projectPythonCommand(path.resolve(import.meta.dirname, '../..')) + const helper = windowsWallpaperDependencies(root, resources, packaged, python).launch('http://127.0.0.1:1234/wallpaper/lively/index.html') + await helper.ready + helper.process.stdin.end() + await helper.done + const observed = JSON.parse(await fs.readFile(trace, 'utf8')) + assert.equal(observed.isolated, 1) + assert.equal(path.resolve(observed.executable), path.resolve(python)) + assert.equal(path.resolve(observed.file), path.join(helperDir, 'helper.py')) + assert.deepEqual(observed.args, ['run', 'http://127.0.0.1:1234/wallpaper/lively/index.html']) + } finally { await fs.rm(root, { recursive: true, force: true }) } + }) +} + test('managed host is Windows-only; external hosts remain selectable', () => { assert.equal(managesWindowsWallpaper('win32', {}), true) assert.equal(managesWindowsWallpaper('win32', { AMADEUS_WALLPAPER_HOST: 'external' }), false) @@ -155,7 +186,7 @@ Set-Content -LiteralPath $marker -Value 'attempted' Write-Error 'fixture installation unavailable' exit 1 `) - const dependencies = windowsWallpaperDependencies(root, '', false) + const dependencies = windowsWallpaperDependencies(root, '', false, 'python') await assert.rejects(dependencies.prepare()) await dependencies.prepare() } finally { diff --git a/release/source_release_policy.json b/release/source_release_policy.json index 13b23383..7e3433bc 100644 --- a/release/source_release_policy.json +++ b/release/source_release_policy.json @@ -25,6 +25,9 @@ "examples/extensions/LICENSE", "LICENSES/Amadeus-Extension-Examples-MIT-0.txt", "electron/src/main/pythonRuntime.ts", + "electron/resources/windows-wallpaper/helper.py", + "electron/resources/windows-wallpaper/NOTICE.md", + "electron/resources/windows-wallpaper/Apache-2.0.txt", "characters/__init__.py", "characters/kurisu.toml", "llm/character_prompts.py", diff --git a/scripts/setup_windows_wallpaper.ps1 b/scripts/setup_windows_wallpaper.ps1 index 9f62dd48..641bbf9d 100644 --- a/scripts/setup_windows_wallpaper.ps1 +++ b/scripts/setup_windows_wallpaper.ps1 @@ -1,20 +1,12 @@ -param([switch]$BuildOnly, [switch]$Rebuild) +param([string]$Python = 'python') $ErrorActionPreference = 'Stop' $projectRoot = Split-Path -Parent $PSScriptRoot -$helperProject = Join-Path $projectRoot 'wallpaper\windows\Amadeus.Wallpaper\Amadeus.Wallpaper.csproj' -$helperDirectory = Join-Path $projectRoot 'build\windows-wallpaper\host' -$helper = Join-Path $helperDirectory 'Amadeus.Wallpaper.exe' -if (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'Amadeus.Wallpaper.exe')) { - $helperDirectory = $PSScriptRoot - $helper = Join-Path $helperDirectory 'Amadeus.Wallpaper.exe' +$helper = Join-Path $projectRoot 'electron\resources\windows-wallpaper\helper.py' +if (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'helper.py')) { + $helper = Join-Path $PSScriptRoot 'helper.py' } if ($env:OS -ne 'Windows_NT') { throw 'This setup is Windows-only.' } -if ($Rebuild -or !(Test-Path -LiteralPath $helper)) { - & dotnet publish $helperProject -c Release -r win-x64 --self-contained true -o $helperDirectory --nologo - if ($LASTEXITCODE -ne 0) { throw 'Windows wallpaper helper build failed.' } -} -if ($BuildOnly) { exit 0 } -$inspection = (& $helper inspect | Select-Object -Last 1) | ConvertFrom-Json +$inspection = (& $Python -I $helper inspect | Select-Object -Last 1) | ConvertFrom-Json if ($LASTEXITCODE -ne 0) { throw 'Could not inspect the wallpaper host.' } if (!$inspection.executable) { if (Get-AppxPackage '*LivelyWallpaper*') { @@ -27,6 +19,6 @@ if (!$inspection.executable) { # Do not launch Lively until its first-run configuration has been prepared. & winget.exe install --id rocksdanister.LivelyWallpaper --version 2.2.1.0 --exact --source winget --silent --accept-source-agreements --accept-package-agreements --disable-interactivity --override '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER /NOAUTOLAUNCH /MERGETASKS=!desktopicon,!windowsstartup' if ($LASTEXITCODE -ne 0) { throw "Lively installation failed: $LASTEXITCODE" } - & $helper prepare + & $Python -I $helper prepare if ($LASTEXITCODE -ne 0) { throw 'Lively first-run preparation failed.' } } diff --git a/tests/test_windows_wallpaper_helper.py b/tests/test_windows_wallpaper_helper.py new file mode 100644 index 00000000..cf031e3d --- /dev/null +++ b/tests/test_windows_wallpaper_helper.py @@ -0,0 +1,880 @@ +"""Lifecycle contracts ported from Amadeus.Wallpaper.Tests, plus wire regressions. + +Native tests use uniquely named synthetic Windows pipes; they never touch Lively. +""" +from dataclasses import asdict, replace +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import threading +import time +import uuid + +import pytest + +HELPER = Path(__file__).resolve().parents[1] / "electron/resources/windows-wallpaper/helper.py" +spec = importlib.util.spec_from_file_location("windows_wallpaper_helper", HELPER) +h = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = h +spec.loader.exec_module(h) + + +class FakeHost: + def __init__(self): + self.items, self.reported = [], None + self.options = h.HostOptions(0, 1, False, False) + self.running = h.ProcessIdentity(100, 200) + self.live = {self.running} + self.stopped, self.dispatch, self.identity_failure = None, None, None + self.fail_next, self.fail_after, self.fail_pending = False, False, False + self.close_noop, self.ignore_restored = False, False + self.set_calls = self.restore_calls = self.reads = 0 + self.closed, self.pending, self.on_confirmation = [], None, None + self.mount_failure = TimeoutError("wallpaper mount confirmation timed out") + self.pending_failure = OSError("native SetWallpaper RPC deadline exceeded") + self.options_failure = self.options_read_failure = self.close_failure = self.restore_failure = None + + def is_process_running(self, identity): + if self.identity_failure: + raise self.identity_failure + return identity in self.live + + def get_options(self): + if self.options_read_failure: + raise self.options_read_failure + return self.options + + def get_wallpapers(self): + self.reads += 1 + return list(self.items if self.reported is None else self.reported) + + def set_wallpaper(self, wallpaper, request_settled=None, request_dispatching=None): + if request_dispatching: + if self.dispatch: + self.dispatch(request_dispatching) + else: + if self.identity_failure: + raise self.identity_failure + request_dispatching(self.running) + self.set_calls += 1 + self.items = [x for x in self.items if x.Monitor != wallpaper.Monitor] + if self.fail_pending: + self.fail_pending = False + self.pending = wallpaper + raise self.pending_failure + if self.fail_next: + self.fail_next = False + if request_settled: + request_settled() + raise OSError("player failed") + if not self.ignore_restored or wallpaper.Path == "amadeus": + self.items.append(wallpaper) + if request_settled: + request_settled() + if self.fail_after: + self.fail_after = False + if self.on_confirmation: + self.on_confirmation() + raise self.mount_failure + + def complete_pending(self): + self.items = [x for x in self.items if x.Monitor != self.pending.Monitor] + [self.pending] + self.pending = None + + def close_project(self, project): + self.closed.append(project) + if self.close_failure: + raise self.close_failure + if not self.close_noop: + self.items = [x for x in self.items if not h.same_path(x.Path, project)] + + def set_session_options(self): + self.options = replace(self.options, Browser=1, DesktopImage=False, LockImage=False) + if self.options_failure: + raise self.options_failure + + def restore_options(self, previous, images=True): + self.restore_calls += 1 + if self.restore_failure: + raise self.restore_failure + self.options = replace(self.options, Browser=previous.Browser if self.options.Browser == 1 else self.options.Browser, + DesktopImage=previous.DesktopImage if images and not self.options.DesktopImage else self.options.DesktopImage, + LockImage=previous.LockImage if images and not self.options.LockImage else self.options.LockImage) + + def stop_owned_process(self, process): + self.stopped = process + + +@pytest.fixture +def f(tmp_path): + host = FakeHost() + session = h.Session(host, tmp_path / "session.json") + return host, session + + +def before(host, owned=None): + return h.Snapshot("amadeus", "primary", host.options, list(host.items), owned) + + +def original(host): + host.items = [h.DesktopItem("primary", "old")] + + +def failure(action): + with pytest.raises(Exception) as caught: + action() + return caught.value + + +def phase(session, expected): + assert json.loads(session.journal.read_text())["Phase"] == expected + + +@pytest.mark.parametrize("arrangement,items", [ + (0, [h.DesktopItem("primary", "old")]), + (0, [h.DesktopItem("primary", "old"), h.DesktopItem("secondary", "second")]), + (1, [h.DesktopItem("primary", "span-old")]), + (2, [h.DesktopItem("primary", "old"), h.DesktopItem("secondary", "old")]), +]) +def test_restores_scenes_and_layout_without_stopping_shared_host(f, arrangement, items): + host, session = f + host.options = replace(host.options, Arrangement=arrangement) + host.items = list(items) + session.mount(before(host)) + if arrangement == 0 and len(items) == 2: + assert items[1] in host.items + session.restore() + assert set(host.items) == set(items) + assert host.stopped is None and not session.journal.exists() + + +def test_static_wallpaper_revealed_and_owned_host_released(f): + host, session = f + owned = h.ProcessIdentity(10, 20) + session.mount(before(host, owned)) + session.restore() + assert host.items == [] and host.stopped == owned + assert not session.journal.exists() + + +@pytest.mark.parametrize("choice", ["user-new", None, "layout"]) +def test_user_choices_win(f, choice): + host, session = f + original(host) + session.mount(before(host, h.ProcessIdentity(10, 20) if choice is not None else None)) + if choice == "layout": + host.options = replace(host.options, Arrangement=1) + elif choice is None: + host.items.clear() + else: + host.set_wallpaper(h.DesktopItem("primary", choice)) + session.restore() + assert [x.Path for x in host.items] == ([choice] if choice == "user-new" else []) + assert host.stopped is None and not session.journal.exists() + if choice == "layout": + assert host.options.Arrangement == 1 + + +@pytest.mark.parametrize("empty", [False, True]) +@pytest.mark.parametrize("after", [False, True]) +def test_settled_mount_failure_rolls_back_and_allows_next_mount(f, empty, after): + host, session = f + if not empty: + original(host) + snapshot = before(host, h.ProcessIdentity(10, 20)) + host.fail_after, host.fail_next = after, not after + error = failure(lambda: session.mount(snapshot)) + assert error is host.mount_failure if after else isinstance(error, OSError) + assert host.items == snapshot.Wallpapers and not session.journal.exists() + assert host.closed == ["amadeus"] + session.mount(before(host)) + session.restore() + assert not session.journal.exists() + + +def test_crash_recovery_uses_new_session(f): + host, session = f + original(host) + session.mount(before(host)) + h.Session(host, session.journal).restore() + assert host.items == [h.DesktopItem("primary", "old")] + assert not session.journal.exists() + + +@pytest.mark.parametrize("kind", ["set", "options", "close", "noop", "missing"]) +def test_restore_failure_keeps_journal_and_retries(f, kind): + host, session = f + if kind != "noop": + original(host) + session.mount(before(host)) + if kind == "set": + host.fail_next = True + elif kind == "options": + host.restore_failure = OSError("restore options unavailable") + elif kind == "close": + host.close_failure = OSError("close unavailable") + elif kind == "noop": + host.close_noop = True + else: + host.ignore_restored = True + error = failure(session.restore) + assert isinstance(error, OSError) and session.journal.exists() and host.stopped is None + host.close_failure = host.restore_failure = None + host.close_noop = host.ignore_restored = False + h.Session(host, session.journal).restore() + assert host.items == ([] if kind == "noop" else [h.DesktopItem("primary", "old")]) and not session.journal.exists() + + +@pytest.mark.parametrize("cleanup_fails", [False, True]) +def test_partial_settings_failure_recovers_settings_only(f, cleanup_fails): + host, session = f + host.options = h.HostOptions(0, 0, True, True) + original(host) + snapshot = before(host, h.ProcessIdentity(10, 20)) + host.options_failure = OSError("partial settings failure") + host.restore_failure = OSError("settings rollback failed") if cleanup_fails else None + error = failure(lambda: session.mount(snapshot)) + if cleanup_fails: + assert error.exceptions == (host.options_failure, host.restore_failure) + phase(session, "Preparing") + host.restore_failure = None + h.Session(host, session.journal).restore() + else: + assert error is host.options_failure + assert host.options == snapshot.Options and host.items == snapshot.Wallpapers + assert host.set_calls == 0 and host.closed == [] and not session.journal.exists() + + +def test_mount_cleanup_dual_errors_and_user_choice_on_retry(f): + host, session = f + original(host) + host.fail_after = True + host.close_failure = OSError("close unavailable") + error = failure(lambda: session.mount(before(host, h.ProcessIdentity(10, 20)))) + assert error.exceptions == (host.mount_failure, host.close_failure) + assert "close unavailable" in h.error_detail(error) and "timed out" in h.error_detail(error) + phase(session, "Settled") + host.close_failure = None + host.set_wallpaper(h.DesktopItem("primary", "user-new")) + h.Session(host, session.journal).restore() + assert host.items == [h.DesktopItem("primary", "user-new")] + assert host.stopped is None and not session.journal.exists() + + +def test_mount_and_options_cleanup_errors_retry_original_scene(f): + host, session = f + original(host) + host.fail_after = True + host.restore_failure = OSError("restoration unavailable") + error = failure(lambda: session.mount(before(host))) + assert error.exceptions == (host.mount_failure, host.restore_failure) + phase(session, "Settled") + host.restore_failure = None + h.Session(host, session.journal).restore() + assert host.items == [h.DesktopItem("primary", "old")] and not session.journal.exists() + + +@pytest.mark.parametrize("wrong_monitor", [False, True]) +def test_original_project_is_valid_only_on_original_monitor(f, wrong_monitor): + host, session = f + host.items = [h.DesktopItem("primary", "amadeus")] + session.mount(before(host)) + if wrong_monitor: + host.reported = [h.DesktopItem("secondary", "amadeus")] + assert isinstance(failure(session.restore), OSError) and session.journal.exists() + else: + session.restore() + assert host.items == [h.DesktopItem("primary", "amadeus")] and not session.journal.exists() + + +@pytest.mark.parametrize("restart", ["owned", "manual", "reused-pid"]) +def test_pending_late_mount_requires_original_receiver_exit(f, restart): + host, session = f + original(host) + owned = h.ProcessIdentity(10, 20) if restart == "owned" else None + host.fail_pending = True + assert failure(lambda: session.mount(before(host, owned))) is host.pending_failure + phase(session, "Pending") + saved = json.loads(session.journal.read_text()) + assert saved["RequestProcess"] == asdict(host.running) + assert saved["OwnedProcess"] == (asdict(owned) if owned else None) + reads = host.reads + for identity in (None, owned, h.ProcessIdentity(11, 30)): + assert isinstance(failure(lambda: h.Session(host, session.journal).restore(identity)), RuntimeError) + assert host.reads == reads and host.restore_calls == 0 and host.closed == [] and host.stopped is None + host.complete_pending() + assert host.items == [h.DesktopItem("primary", "amadeus")] + assert isinstance(failure(session.restore), RuntimeError) + host.items.clear() + host.live.clear() + host.running = h.ProcessIdentity(host.running.Id, host.running.Started + 1) if restart == "reused-pid" else h.ProcessIdentity(11, 30) + host.live.add(host.running) + h.Session(host, session.journal).restore(host.running if owned else None) + assert host.items == [h.DesktopItem("primary", "old")] and not session.journal.exists() + assert host.stopped == (host.running if owned else None) + + +def test_pending_identity_failures_preserve_original_error_and_fence(f): + host, session = f + host.identity_failure = OSError("identity unavailable") + assert failure(lambda: session.mount(before(host))) is host.identity_failure + assert host.set_calls == 0 and host.restore_calls == 1 and not session.journal.exists() + host.identity_failure = None + host.fail_pending = True + failure(lambda: session.mount(before(host))) + content = session.journal.read_bytes() + host.identity_failure = OSError("liveness unavailable") + assert failure(lambda: session.restore(h.ProcessIdentity(11, 30))) is host.identity_failure + assert session.journal.read_bytes() == content and host.stopped is None and host.closed == [] + + +@pytest.mark.parametrize("user_choice", [False, True]) +def test_pending_receiver_exit_allows_retry_but_is_not_restore_proof(f, user_choice): + host, session = f + original(host) + host.fail_pending = True + failure(lambda: session.mount(before(host, host.running))) + host.live.clear() + if user_choice: + host.items = [h.DesktopItem("primary", "user-new")] + host.restore_failure = OSError("recovery unavailable") + assert failure(session.restore) is host.restore_failure and session.journal.exists() + host.restore_failure = None + h.Session(host, session.journal).restore() + assert host.items == [h.DesktopItem("primary", "user-new" if user_choice else "old")] + assert not session.journal.exists() + if user_choice: + assert host.stopped is None + + +@pytest.mark.parametrize("running", [False, True]) +@pytest.mark.parametrize("invalid", ["missing", "integer-phase", "bad-identity"]) +def test_invalid_pending_journal_is_untouched_without_host_calls(f, running, invalid): + host, session = f + original(host) + saved = asdict(before(host)) | {"Phase": "Pending"} + if invalid == "integer-phase": + saved["Phase"] = 1 + elif invalid == "bad-identity": + saved["RequestProcess"] = {"Id": 100, "Started": "200"} + session.journal.write_text(json.dumps(saved)) + content = session.journal.read_bytes() + if not running: + host.live.clear() + assert isinstance(failure(session.restore), ValueError) + assert session.journal.read_bytes() == content + assert host.set_calls == host.restore_calls == host.reads == 0 and host.closed == [] and host.stopped is None + + +def test_legacy_pascalcase_journals_keep_unmounted_and_mounted_semantics(f): + host, session = f + original(host) + saved = asdict(before(host)) + for key in ("Phase", "RequestProcess"): + saved.pop(key) + session.journal.write_text(json.dumps(saved)) + host.items.clear() + session.restore() + assert host.items == [h.DesktopItem("primary", "old")] and not session.journal.exists() + saved["Mounted"] = True + session.journal.write_text(json.dumps(saved)) + host.items = [h.DesktopItem("primary", "user-new")] + session.restore() + assert host.items == [h.DesktopItem("primary", "user-new")] and not session.journal.exists() + + +def test_mounted_body_does_not_run_or_repeat_mount_failure_cleanup(f): + host, session = f + original(host) + host.fail_next = True + def unexpected(): + pytest.fail("failed mount entered body") + assert isinstance(failure(lambda: session.run_mounted(before(host), unexpected)), OSError) + assert host.closed == ["amadeus"] and host.restore_calls == 2 and not session.journal.exists() + + +def test_body_and_cleanup_errors_preserved_and_retryable(f): + host, session = f + original(host) + body_error = OSError("mounted body failed") + host.restore_failure = OSError("mounted cleanup failed") + def body(): + raise body_error + error = failure(lambda: session.run_mounted(before(host), body)) + assert error.exceptions == (body_error, host.restore_failure) + assert host.closed == ["amadeus"] and host.restore_calls == 1 and session.journal.exists() + host.restore_failure = None + h.Session(host, session.journal).restore() + assert host.items == [h.DesktopItem("primary", "old")] and not session.journal.exists() + + +@pytest.mark.parametrize("stage", ["mount", "body", "recovery"]) +def test_outer_cleanup_does_not_stop_host_kept_for_user_choice(f, stage): + host, session = f + owned = h.ProcessIdentity(10, 20) + def user_choice(): + host.items = [h.DesktopItem("primary", "user-new")] + if stage == "recovery": + session.save(replace(before(host, h.ProcessIdentity(9, 10)), Mounted=True)) + user_choice() + session.restore(owned) + host.options_read_failure = OSError("snapshot failed") + failure(lambda: host.get_options()) + else: + def body(): + user_choice() + raise OSError("body failed") + if stage == "mount": + host.fail_after, host.on_confirmation = True, user_choice + failure(lambda: session.run_mounted(before(host, owned), body)) + session.release_unstarted_host(owned) + assert host.items == [h.DesktopItem("primary", "user-new")] + assert host.stopped is None and not session.journal.exists() + + +def test_initial_journal_failure_releases_only_unstarted_host(f, tmp_path): + host, _ = f + blocked = tmp_path / "file" + blocked.write_text("obstruction") + session = h.Session(host, blocked / "session.json") + owned = h.ProcessIdentity(10, 20) + assert isinstance(failure(lambda: session.mount(before(host, owned))), OSError) + session.release_unstarted_host(owned) + assert host.stopped == owned and host.set_calls == host.restore_calls == 0 + + +def test_existing_pending_journal_blocks_outer_cleanup_and_new_mount(f): + host, session = f + owned = h.ProcessIdentity(10, 20) + session.save(replace(before(host, owned), Phase="Pending", RequestProcess=host.running)) + assert isinstance(failure(lambda: session.mount(before(host, owned))), RuntimeError) + session.release_unstarted_host(owned) + assert session.journal.exists() and host.stopped is None and host.set_calls == host.restore_calls == 0 + + +def test_new_identity_can_be_released_after_old_recovery(f): + host, session = f + recovered = h.ProcessIdentity(10, 20) + session.save(before(host, h.ProcessIdentity(9, 10))) + session.restore(recovered) + assert host.stopped == recovered + newly_started = h.ProcessIdentity(10, 21) + session.release_unstarted_host(newly_started) + assert host.stopped == newly_started + + +def test_first_run_preparation_is_idempotent_and_preserves_existing_settings(tmp_path): + assert h.LivelyHost.prepare_first_run(tmp_path) + settings = json.loads((tmp_path / "Settings.json").read_text()) + assert not settings["IsFirstRun"] and not settings["Startup"] + (tmp_path / "Settings.json").write_text("user-owned-settings") + assert not h.LivelyHost.prepare_first_run(tmp_path) + assert (tmp_path / "Settings.json").read_text() == "user-owned-settings" + + +def test_launch_image_backup_recovers_old_pascalcase_backup_and_unknown_json(tmp_path): + class OfflineWindows: + def running(self): + return None + host = h.LivelyHost(OfflineWindows(), tmp_path, object()) + settings = dict(DesktopAutoWallpaper=True, LockScreenAutoWallpaper=True, Future={"values": [1, "future"]}) + h.atomic_json(tmp_path / "Settings.json", settings) + host.prepare_launch_images() + assert json.loads(host.launch_backup.read_text()) == dict(Desktop=True, Lock=True) + current = json.loads((tmp_path / "Settings.json").read_text()) + assert current == settings | dict(DesktopAutoWallpaper=False, LockScreenAutoWallpaper=False) + host.prepare_launch_images() # Interrupted preparation must not overwrite the backup. + host.recover_launch_images() + assert json.loads((tmp_path / "Settings.json").read_text()) == settings + assert not host.launch_backup.exists() + + +def test_broken_parent_stdout_does_not_change_helper_exit_status(): + code = "import os, runpy, sys; helper = runpy.run_path(sys.argv[1]); os.read(sys.stdin.fileno(), 1); helper['report'](dict(status='restored'))" + child = subprocess.Popen([sys.executable, "-I", "-c", code, str(HELPER)], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + child.stdout.close() + child.stdin.write(b"x") + child.stdin.close() + assert child.wait(timeout=15) == 0, child.stderr.read().decode("utf-8", errors="replace") + assert child.stderr.read() == b"" + + +def test_settings_rewrite_preserves_unknown_bytes_order_and_defaults(): + # Noncanonical varint, duplicate scalar, fixed32/64, group, bytes, wrong-wire + # occurrence of a known field: every unowned byte must survive unchanged. + unknown = [b"\x18\x81\x00", h.blob(70, b"future\x00value"), b"\x7d1234", b"\x79abcdefgh", + b"\x53\x08\x01\x54", h.blob(28, b"unknown-wire"), h.scalar(16, 2)] + source = unknown[0] + h.scalar(28, 3) + b"".join(unknown[1:4]) + h.scalar(48, 1) + b"".join(unknown[4:]) + h.scalar(49, 1) + h.scalar(28, 2) + output = h.rewrite_settings(source, {28: 1, 48: 0, 49: 0}) + unowned = [x.raw for x in h.fields(output) if not (x.wire == 0 and x.number in {28, 48, 49})] + assert unowned == unknown + assert [(x.number, x.value) for x in h.fields(output) if x.wire == 0 and x.number in {28, 48, 49}] == [(28, 1)] + assert h.rewrite_settings(b"\x18\x81\x00", {48: 0, 49: 0}) == b"\x18\x81\x00" + assert failure(lambda: h.rewrite_settings(source, {16: 0})).args + + +@pytest.mark.parametrize("browser,desktop,lock", [(0, False, False), (2, True, True), (1, False, True)]) +def test_settings_restore_preserves_newer_user_choices(browser, desktop, lock): + raw = h.scalar(28, browser) + h.scalar(49, int(desktop)) + h.scalar(48, int(lock)) + b"\x18\x81\x00" + class SettingsRpc: + def call(self, service, method, payload=b"", **kwargs): + assert service == "Settings" + if method == "GetSettings": + return [raw] + self.result = payload + return [b""] + rpc = SettingsRpc() + host = h.LivelyHost(None, Path("unused"), rpc) + host.restore_options(h.HostOptions(0, 0, True, True)) + result = getattr(rpc, "result", raw) + assert h.value(result, 28) == (0 if browser == 1 else browser) + assert h.value(result, 48) == h.value(result, 49) == 1 + assert b"\x18\x81\x00" in result + + +@pytest.mark.parametrize("data", [b"\x00", b"\x08\x80", b"\x0a\x03ab", b"\x0c", b"\x0b\x14", b"\x09abc"]) +def test_malformed_wire_is_observable(data): + assert isinstance(failure(lambda: h.fields(data)), ValueError) + + +class MemoryPipe: + def __init__(self, packets): + self.packets = iter(packets) + self.writes, self.closed = [], False + def receiver(self): + return h.ProcessIdentity(100, 200) + def write(self, packet, budget): + budget.check() + self.writes.append(packet) + def read(self, budget): + budget.check() + return next(self.packets) + def close(self): + self.closed = True + + +def rpc_for(pipe): + return h.Rpc(None, "synthetic", lambda *_: pipe) + + +def test_transport_inline_separate_stream_and_trailers(): + # Literal wire vectors are independent of the production envelope encoder. + pipe = MemoryPipe([b"\x02\x12\x00\x06\x1a\x04\x08\x03\x10\x01abc\x04\x1a\x02\x08\x04", b"data", b"\x02\x2a\x00"]) + assert rpc_for(pipe).call("Desktop", "GetWallpapers", streaming=True) == [b"abc", b"data"] + assert pipe.closed + request = pipe.writes[0] + assert b"/Lively.Grpc.Common.Proto.Desktop.DesktopService/GetWallpapers" in request + + +@pytest.mark.parametrize("code,detail", [(7, "permission denied"), (13, "original failure")]) +def test_rpc_retains_original_status_and_detail(code, detail): + pipe = MemoryPipe([h.transport_message(5, h.scalar(2, code) + h.string(3, detail))]) + error = failure(lambda: rpc_for(pipe).call("Desktop", "SetWallpaper")) + assert isinstance(error, h.RpcError) and (error.code, error.detail) == (code, detail) + assert pipe.closed + + +@pytest.mark.parametrize("packets", [[b"\x02\x2a\x00"], [b"\x04\x1a\x02\x08"], [b"\x06\x1a\x04\x08\x05\x10\x01ab"]]) +def test_rpc_never_accepts_missing_or_truncated_unary_payload(packets): + pipe = MemoryPipe(packets) + assert isinstance(failure(lambda: rpc_for(pipe).call("Desktop", "SetWallpaper")), ValueError) + assert pipe.closed + + +@pytest.mark.parametrize("stage", ["identity", "journal", "cancel", "deadline"]) +def test_pre_dispatch_failure_sends_no_bytes_and_preserves_cause(stage): + pipe = MemoryPipe([]) + primary = OSError("synthetic identity or persistence failure") + cancel = threading.Event() + def dispatching(_): + if stage in ("identity", "journal"): + raise primary + if stage == "cancel": + cancel.set() + else: + time.sleep(0.03) + if stage == "identity": + pipe.receiver = lambda: (_ for _ in ()).throw(primary) + error = failure(lambda: rpc_for(pipe).call("Desktop", "SetWallpaper", dispatching=dispatching, + timeout=0.01 if stage == "deadline" else 1, cancel=cancel)) + assert error is primary if stage in ("identity", "journal") else isinstance(error, h.RpcError) + assert pipe.writes == [] and pipe.closed + + +@pytest.mark.parametrize("cancelled", [False, True]) +def test_expired_calls_do_not_identify_persist_or_write(cancelled): + pipe = MemoryPipe([]) + cancel = threading.Event() + if cancelled: + cancel.set() + error = failure(lambda: rpc_for(pipe).call("Desktop", "SetWallpaper", timeout=1 if cancelled else 0, + cancel=cancel, dispatching=lambda _: pytest.fail("ended request entered persistence"))) + assert error.code == (1 if cancelled else 4) and not pipe.writes and pipe.closed + + +@pytest.mark.parametrize("cancelled", [False, True]) +def test_deadline_waits_for_entered_persistence_and_retains_pending(f, cancelled): + host, session = f + pipe = MemoryPipe([]) + entered, release, cancel = threading.Event(), threading.Event(), threading.Event() + errors = [] + def dispatch(dispatching): + def persist(identity): + entered.set() + assert release.wait(3) + dispatching(identity) + rpc_for(pipe).call("Desktop", "SetWallpaper", timeout=2 if cancelled else 0.1, cancel=cancel, dispatching=persist) + host.dispatch = dispatch + thread = threading.Thread(target=lambda: errors.append(failure(lambda: session.mount(before(host))))) + thread.start() + try: + assert entered.wait(2) + if cancelled: + cancel.set() + else: + time.sleep(0.15) + assert thread.is_alive() and host.restore_calls == 0 + finally: + release.set() + thread.join(3) + assert not thread.is_alive() and errors[0].code == (1 if cancelled else 4) + phase(session, "Pending") + assert host.restore_calls == 0 and pipe.writes == [] + + +native = pytest.mark.skipif(sys.platform != "win32", reason="Native Windows process/message-pipe contract") + + +@native +def test_real_process_identity_matches_csharp_and_fences_old_pending_journal(f): + windows = h.Windows() + identity = windows.identity(os.getpid()) + # Compile the same C# expression used by the old helper, against this Python + # process. No float timestamp, .NET SDK, NuGet, or test-double identity. + code = 'public static class ProcessTicks { public static long Read(int id) { using (var p = System.Diagnostics.Process.GetProcessById(id)) { return p.StartTime.ToUniversalTime().Ticks; } } }' + script = "Add-Type -TypeDefinition '" + code + "'; [ProcessTicks]::Read(" + str(os.getpid()) + ")" + result = subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, text=True, check=True, timeout=30) + assert identity.Started == int(result.stdout.strip()) + assert windows.is_running(identity) and not windows.is_running(replace(identity, Started=identity.Started + 1)) + host, session = f + host.is_process_running = windows.is_running + session.save(replace(before(host), Phase="Pending", RequestProcess=identity)) + content = session.journal.read_bytes() + assert isinstance(failure(session.prepare_recovery), RuntimeError) + assert session.journal.read_bytes() == content and host.restore_calls == host.set_calls == host.reads == 0 + + +@native +def test_cross_process_journal_lock_is_exclusive(tmp_path): + windows = h.Windows() + lock = tmp_path / "session.lock" + handle = windows.lock(lock) + try: + assert isinstance(failure(lambda: windows.lock(lock)), OSError) + finally: + windows.close(handle) + windows.close(windows.lock(lock)) + + +class NativeServer: + """One synthetic message-mode pipe, bounded overlapped connect and IO.""" + def __init__(self, action, name=None): + import ctypes + from ctypes import wintypes as w + self.windows = h.Windows() + self.name = name or "amadeus-wallpaper-contract-" + uuid.uuid4().hex + api = self.windows.api + api.CreateNamedPipeW.argtypes = [w.LPCWSTR, w.DWORD, w.DWORD, w.DWORD, w.DWORD, w.DWORD, w.DWORD, w.LPVOID] + api.CreateNamedPipeW.restype = w.HANDLE + api.ConnectNamedPipe.argtypes = [w.HANDLE, ctypes.POINTER(h.OVERLAPPED)] + api.ConnectNamedPipe.restype = w.BOOL + self.handle = api.CreateNamedPipeW("\\\\.\\pipe\\" + self.name, 0x40000003, 6, 1, 65536, 65536, 0, None) + assert self.handle != ctypes.c_void_p(-1).value + self.errors, self.connected, self.stop = [], threading.Event(), threading.Event() + def run(): + event = self.windows.check(api.CreateEventW(None, True, False, None)) + overlap = h.OVERLAPPED(hEvent=event) + count = w.DWORD() + try: + if not api.ConnectNamedPipe(self.handle, ctypes.byref(overlap)): + error = ctypes.get_last_error() + if error == 997: + until = time.monotonic() + 4 + while not self.stop.is_set() and time.monotonic() < until: + if api.WaitForSingleObject(event, 20) == 0: + break + else: + api.CancelIoEx(self.handle, ctypes.byref(overlap)) + api.GetOverlappedResult(self.handle, ctypes.byref(overlap), ctypes.byref(count), True) + if self.stop.is_set(): + return + raise TimeoutError("synthetic server connect") + self.windows.check(api.GetOverlappedResult(self.handle, ctypes.byref(overlap), ctypes.byref(count), False)) + elif error != 535: + raise ctypes.WinError(error) + self.connected.set() + pipe = h.Pipe.__new__(h.Pipe) + pipe.windows, pipe.handle = self.windows, self.handle + action(pipe) + except BaseException as error: + self.errors.append(error) + finally: + self.windows.close(event) + self.windows.close(self.handle) + self.thread = threading.Thread(target=run, daemon=True) + self.thread.start() + + def join(self): + self.thread.join(6) + assert not self.thread.is_alive() + if self.errors: + raise self.errors[0] + + +@native +def test_real_set_pipe_persists_actual_receiver_before_any_request_bytes(f): + host, session = f + calls = [] + def receive(pipe): + packet = pipe.read(h.Budget(3)) + saved = session.read() + assert saved.RequestProcess == pipe.windows.identity(os.getpid()) and saved.Phase == "Pending" + assert b"SetWallpaper" in packet and b"synthetic" in packet + calls.append(packet) + pipe.write(b"\x04\x1a\x02\x10\x01\x02\x2a\x00", h.Budget(3)) + server = NativeServer(receive) + rpc = h.Rpc(server.windows, server.name) + host.dispatch = lambda callback: rpc.call("Desktop", "SetWallpaper", h.string(1, "synthetic"), dispatching=callback) + try: + session.mount(before(host)) + finally: + server.join() + assert len(calls) == 1 + session.restore() + assert not session.journal.exists() + + +@native +@pytest.mark.parametrize("stage", ["identity", "journal"]) +def test_real_set_pipe_identity_or_persistence_failure_sends_zero_bytes(f, stage, monkeypatch): + host, session = f + received = [] + def receive(pipe): + try: + received.append(pipe.read(h.Budget(2))) + except (OSError, EOFError): + pass + server = NativeServer(receive) + primary = OSError("original identity failure") + if stage == "identity": + monkeypatch.setattr(server.windows, "receiver", lambda _: (_ for _ in ()).throw(primary)) + rpc = h.Rpc(server.windows, server.name) + def dispatch(callback): + if stage == "journal": + session.journal.with_name(session.journal.name + ".tmp").mkdir() + rpc.call("Desktop", "SetWallpaper", dispatching=callback) + host.dispatch = dispatch + try: + error = failure(lambda: session.mount(before(host))) + assert error is primary if stage == "identity" else isinstance(error, PermissionError) + finally: + server.join() + assert received == [] and host.restore_calls == 1 and not session.journal.exists() + + +@native +@pytest.mark.parametrize("cancelled", [False, True]) +def test_real_pipe_after_dispatch_deadline_or_cancel_keeps_pending_and_sends_cancel(f, cancelled): + host, session = f + received, sent, cancel = [], threading.Event(), threading.Event() + def receive(pipe): + received.append(pipe.read(h.Budget(3))) + sent.set() + if cancelled: + cancel.set() + received.append(pipe.read(h.Budget(3))) + server = NativeServer(receive) + rpc = h.Rpc(server.windows, server.name) + host.dispatch = lambda callback: rpc.call("Desktop", "SetWallpaper", dispatching=callback, + timeout=2 if cancelled else 0.15, cancel=cancel) + try: + error = failure(lambda: session.mount(before(host))) + finally: + server.join() + assert sent.is_set() and error.code == (1 if cancelled else 4) + assert received[1] == b"\x02\x20\x01" + phase(session, "Pending") + assert host.restore_calls == 0 + + +@native +def test_real_connection_deadline_cannot_later_identify_or_persist_or_send(f): + host, session = f + name = "amadeus-wallpaper-absent-" + uuid.uuid4().hex + rpc = h.Rpc(h.Windows(), name) + calls = [] + host.dispatch = lambda callback: rpc.call("Desktop", "SetWallpaper", timeout=0.05, + dispatching=lambda identity: (calls.append(identity), callback(identity))) + assert failure(lambda: session.mount(before(host))).code == 4 + assert host.restore_calls == 1 and calls == [] and not session.journal.exists() + server = NativeServer(lambda _: pytest.fail("ended request connected to a late server"), name) + try: + assert not server.connected.wait(0.15) + assert calls == [] and not session.journal.exists() + finally: + server.stop.set() + server.join() + + +@native +def test_real_pipe_deadline_waits_for_entered_durable_boundary(f): + host, session = f + entered, release = threading.Event(), threading.Event() + received, errors = [], [] + def receive(pipe): + try: + received.append(pipe.read(h.Budget(3))) + except (OSError, EOFError): + pass + server = NativeServer(receive) + rpc = h.Rpc(server.windows, server.name) + def dispatch(callback): + def persist(identity): + entered.set() + assert release.wait(3) + callback(identity) + rpc.call("Desktop", "SetWallpaper", dispatching=persist, timeout=0.1) + host.dispatch = dispatch + thread = threading.Thread(target=lambda: errors.append(failure(lambda: session.mount(before(host))))) + thread.start() + try: + assert entered.wait(2) + time.sleep(0.15) + assert thread.is_alive() and host.restore_calls == 0 + finally: + release.set() + thread.join(3) + server.join() + assert not thread.is_alive() and errors[0].code == 4 and received == [] + phase(session, "Pending") + assert host.restore_calls == 0 + + +@native +def test_native_large_message_and_separate_payload_are_read_completely(): + payload = bytes(range(256)) * 350 + def receive(pipe): + pipe.read(h.Budget(3)) + pipe.write(h.transport_message(3, h.scalar(1, len(payload))), h.Budget(3)) + pipe.write(payload, h.Budget(3)) + pipe.write(b"\x02\x2a\x00", h.Budget(3)) + server = NativeServer(receive) + try: + assert h.Rpc(server.windows, server.name).call("Settings", "GetSettings") == [payload] + finally: + server.join() diff --git a/tools/probes/windows_wallpaper_cold_start.py b/tools/probes/windows_wallpaper_cold_start.py new file mode 100644 index 00000000..899e2d68 --- /dev/null +++ b/tools/probes/windows_wallpaper_cold_start.py @@ -0,0 +1,87 @@ +"""Opt-in reversible fresh-settings probe on installed Lively, not clean-machine qualification.""" +import importlib.util +import json +from pathlib import Path +import sys +import time + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("wallpaper_helper_probe", ROOT / "electron/resources/windows-wallpaper/helper.py") +h = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = h +spec.loader.exec_module(h) + + +def main(): + windows = h.Windows() + host = h.LivelyHost(windows) + journal = host.data_directory.parent / "Amadeus/wallpaper/session.json" + if journal.exists(): + raise RuntimeError("Resolve the active wallpaper session before the cold-start probe.") + original = windows.running() + baseline = host.get_wallpapers() if original else [] + backup = ROOT / "build/windows-wallpaper" / ("first-run-backup-" + str(time.time_ns())) + backup.mkdir(parents=True) + saved = {} + for name in ("Settings.json", "WallpaperLayout.json"): + file = host.data_directory / name + saved[name] = file.read_bytes() if file.exists() else None + if saved[name] is not None: + (backup / name).write_bytes(saved[name]) + try: + if original: + host.stop_owned_process(original) + (host.data_directory / "Settings.json").unlink(missing_ok=True) + (host.data_directory / "WallpaperLayout.json").write_text("[]") + h.LivelyHost.prepare_first_run(host.data_directory) + fresh = host.data_directory / "Settings.json" + configuration = json.loads(fresh.read_text()) + configuration["WallpaperDir"] = str(backup / "test-library") + h.atomic_json(fresh, configuration) + prepared = h.LivelyHost(windows) + prepared.ensure_running() + time.sleep(3) + # Toolhelp's same-session process list is queried separately for the UI. + import subprocess + result = subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", + "@(Get-Process Lively.UI.WinUI -ErrorAction SilentlyContinue | Where-Object SessionId -eq " + "(Get-Process -Id $PID).SessionId).Count"], capture_output=True, text=True, check=True, timeout=15) + assert int(result.stdout.strip()) == 0, "Fresh launch opened Lively UI" + assert prepared.get_options().Browser == 1 + configuration = json.loads(fresh.read_text(encoding="utf-8-sig")) + assert not configuration["IsFirstRun"] and not configuration["Startup"] + project = backup / "test-library/wallpapers/amadeus-cold-start-test" + project.mkdir(parents=True, exist_ok=True) + page = project / "index.html" + page.write_text("Amadeus startup experiment") + h.atomic_json(project / "LivelyInfo.json", dict(Title="Amadeus startup experiment", Type=1, + FileName=str(page), IsAbsolutePath=True)) + session = h.Session(prepared, backup / "test-session.json") + session.mount(h.Snapshot(str(project), prepared.primary_monitor(), prepared.get_options(), + prepared.get_wallpapers(), prepared.started_process)) + session.restore() + assert windows.running() is None, "Newly owned Lively process was not released" + h.atomic_json(backup.parent / "first-run-experiment.json", dict(passed=True, wizardVisible=False, webView2=True, + independentAutostart=False, ownedHostReleased=True, kind="fresh-settings-on-installed-Lively")) + print("PASS fresh settings: no UI, WebView2, no independent autostart, owned host released", flush=True) + finally: + running = windows.running() + if running: + host.stop_owned_process(running) + for name, content in saved.items(): + file = host.data_directory / name + if content is None: + file.unlink(missing_ok=True) + else: + file.write_bytes(content) + if original: + host.ensure_running() + until = time.monotonic() + 30 + while host.get_wallpapers() != baseline and time.monotonic() < until: + time.sleep(0.2) + assert host.get_wallpapers() == baseline, "Original Lively layout did not restore" + print("Original settings, layout and running state restored.", flush=True) + + +if __name__ == "__main__": + main() diff --git a/tools/probes/windows_wallpaper_counterexample.py b/tools/probes/windows_wallpaper_counterexample.py new file mode 100644 index 00000000..409f609b --- /dev/null +++ b/tools/probes/windows_wallpaper_counterexample.py @@ -0,0 +1,26 @@ +"""Known #233 false-empty/no-op-close diagnostic; NEVER an acceptance pass.""" +from dataclasses import asdict +import json +from pathlib import Path +import runpy +import tempfile + +ROOT = Path(__file__).resolve().parents[2] + + +def main(): + contract = runpy.run_path(str(ROOT / "tests/test_windows_wallpaper_helper.py")) + helper, fake = contract["h"], contract["FakeHost"]() + fake.reported, fake.close_noop, fake.fail_after = [], True, True + with tempfile.TemporaryDirectory(prefix="amadeus-wallpaper-counterexample-") as temporary: + session = helper.Session(fake, Path(temporary) / "session.json") + error = contract["failure"](lambda: session.mount(contract["before"](fake, helper.ProcessIdentity(10, 20)))) + print(json.dumps(dict(diagnostic="known false-empty protocol counterexample; not a recovery acceptance pass", + error=str(error), actual=[asdict(x) for x in fake.items], reported=fake.get_wallpapers(), + closeReturnedWithoutRemoval=bool(fake.closed and fake.items), + journalRetained=session.journal.exists(), stopRequested=fake.stopped is not None, + strongRecoveryProven=False), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/probes/windows_wallpaper_lifecycle.py b/tools/probes/windows_wallpaper_lifecycle.py index bb2cfbd1..de379467 100644 --- a/tools/probes/windows_wallpaper_lifecycle.py +++ b/tools/probes/windows_wallpaper_lifecycle.py @@ -17,11 +17,11 @@ ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) -HELPER = ROOT / "build/windows-wallpaper/host/Amadeus.Wallpaper.exe" +HELPER = [sys.executable, "-I", str(ROOT / "electron/resources/windows-wallpaper/helper.py")] def inspect() -> dict: - result = subprocess.run([str(HELPER), "inspect"], capture_output=True, text=True, check=True) + result = subprocess.run([*HELPER, "inspect"], capture_output=True, text=True, check=True) return json.loads(result.stdout.splitlines()[-1]) @@ -55,6 +55,7 @@ def main() -> int: animator = SpriteForgeAnimator(bridge) report = {"before": baseline, "experiments": []} output = ROOT / "build/windows-wallpaper/lifecycle-experiment.json" + output.parent.mkdir(parents=True, exist_ok=True) process = None bridge.start() animator.start() @@ -62,7 +63,7 @@ def main() -> int: for scenario in ("normal-exit", "parent-pipe-loss", "helper-crash-recovery"): previous_clients = {id(client) for client in bridge._state.clients} process = subprocess.Popen( - [str(HELPER), "run", bridge.lively_url], stdin=subprocess.PIPE, + [*HELPER, "run", bridge.lively_url], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, creationflags=subprocess.CREATE_NO_WINDOW, ) @@ -79,7 +80,7 @@ def main() -> int: if scenario == "helper-crash-recovery": process.kill() # Deliberately kill ONLY this probe's helper. process.wait(timeout=10) - recovered = subprocess.run([str(HELPER), "recover"], capture_output=True, text=True, timeout=75) + recovered = subprocess.run([*HELPER, "recover"], capture_output=True, text=True, timeout=75) assert recovered.returncode == 0, recovered.stdout + recovered.stderr else: if scenario == "normal-exit": @@ -111,7 +112,7 @@ def main() -> int: os._exit(0) sys.exit(1) ''' - parent = subprocess.run([sys.executable, "-c", parent_script, str(HELPER), "run", bridge.lively_url], + parent = subprocess.run([sys.executable, "-c", parent_script, *HELPER, "run", bridge.lively_url], capture_output=True, text=True, timeout=75) assert parent.returncode == 0, parent.stdout + parent.stderr deadline = time.monotonic() + 45 @@ -129,7 +130,7 @@ def main() -> int: if process is not None and process.poll() is None: process.stdin.close() process.wait(timeout=75) - recovery = subprocess.run([str(HELPER), "recover"], capture_output=True, text=True, timeout=75) + recovery = subprocess.run([*HELPER, "recover"], capture_output=True, text=True, timeout=75) if recovery.returncode: print("RECOVERY FAILED: " + recovery.stdout + recovery.stderr, file=sys.stderr) animator.stop() diff --git a/wallpaper/windows/.gitignore b/wallpaper/windows/.gitignore deleted file mode 100644 index 59672943..00000000 --- a/wallpaper/windows/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -**/bin/ -**/obj/ diff --git a/wallpaper/windows/Amadeus.Wallpaper.Tests/Amadeus.Wallpaper.Tests.csproj b/wallpaper/windows/Amadeus.Wallpaper.Tests/Amadeus.Wallpaper.Tests.csproj deleted file mode 100644 index 99da9633..00000000 --- a/wallpaper/windows/Amadeus.Wallpaper.Tests/Amadeus.Wallpaper.Tests.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - Exe - net8.0-windows - enable - enable - - - diff --git a/wallpaper/windows/Amadeus.Wallpaper.Tests/ColdStartProbe.cs b/wallpaper/windows/Amadeus.Wallpaper.Tests/ColdStartProbe.cs deleted file mode 100644 index 911975dc..00000000 --- a/wallpaper/windows/Amadeus.Wallpaper.Tests/ColdStartProbe.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Diagnostics; -using System.Text.Json; -using Amadeus.Wallpaper; - -// Opt-in integration experiment. Original settings/layout are backed up before -// touching them and restored in finally. It never uninstalls the user's host. -static class ColdStartProbe -{ - public static async Task Run(string output) - { - var host = new LivelyHost(); - var executable = LivelyHost.FindExecutable() ?? throw new Exception("Lively must already be installed for this probe."); - var recovery = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Amadeus", "wallpaper", "session.json"); - if (File.Exists(recovery)) throw new Exception("Resolve the active wallpaper session before the cold-start probe."); - using var original = LivelyHost.Running(); - var baseline = original is null ? [] : await host.GetWallpapers(); - var backup = Path.Combine(Path.GetFullPath(output), "first-run-backup-" + DateTime.UtcNow.ToString("yyyyMMddHHmmss")); - Directory.CreateDirectory(backup); - var files = new[] { "Settings.json", "WallpaperLayout.json" }; - var saved = files.ToDictionary(name => name, name => { - var file = Path.Combine(LivelyHost.DataDirectory, name); - var bytes = File.Exists(file) ? File.ReadAllBytes(file) : null; - if (bytes is not null) File.WriteAllBytes(Path.Combine(backup, name), bytes); - return bytes; - }); - try - { - if (original is not null) - await host.StopOwnedProcess(new(original.Id, original.StartTime.ToUniversalTime().Ticks)); - // Exact fresh-profile input, with a separate library for the experiment. - File.Delete(Path.Combine(LivelyHost.DataDirectory, "Settings.json")); - File.WriteAllText(Path.Combine(LivelyHost.DataDirectory, "WallpaperLayout.json"), "[]"); - LivelyHost.PrepareFirstRun(LivelyHost.DataDirectory); - var fresh = Path.Combine(LivelyHost.DataDirectory, "Settings.json"); - var configuration = JsonSerializer.Deserialize>(File.ReadAllText(fresh))!; - configuration["WallpaperDir"] = JsonSerializer.SerializeToElement(Path.Combine(backup, "test-library")); - File.WriteAllText(fresh, JsonSerializer.Serialize(configuration)); - var prepared = new LivelyHost(); - await prepared.EnsureRunning(); - await Task.Delay(3000); - var ui = Process.GetProcessesByName("Lively.UI.WinUI"); - if (ui.Any(p => p.SessionId == Process.GetCurrentProcess().SessionId)) - throw new Exception("First-run preparation still opened Lively UI."); - if ((await prepared.GetOptions()).Browser != 1) - throw new Exception("Fresh settings did not select WebView2."); - using var settings = JsonDocument.Parse(File.ReadAllText(fresh)); - if (settings.RootElement.GetProperty("IsFirstRun").GetBoolean() - || settings.RootElement.GetProperty("Startup").GetBoolean()) - throw new Exception("Fresh launch re-enabled the wizard or independent autostart."); - var project = Path.Combine(backup, "test-library", "wallpapers", "amadeus-cold-start-test"); - Directory.CreateDirectory(project); - var page = Path.Combine(project, "index.html"); - File.WriteAllText(page, "Amadeus startup experiment"); - File.WriteAllText(Path.Combine(project, "LivelyInfo.json"), JsonSerializer.Serialize(new { - Title = "Amadeus startup experiment", Type = 1, FileName = page, IsAbsolutePath = true - })); - var session = new Session(prepared, Path.Combine(backup, "test-session.json")); - await session.Mount(new Snapshot(project, await prepared.PrimaryMonitor(), await prepared.GetOptions(), - await prepared.GetWallpapers(), prepared.StartedProcess)); - await session.Restore(); - using var leftover = LivelyHost.Running(); - if (leftover is not null) throw new Exception("The newly owned Lively process was not released after restore."); - File.WriteAllText(Path.Combine(output, "first-run-experiment.json"), JsonSerializer.Serialize(new { - passed = true, wizardVisible = false, webView2 = true, independentAutostart = false, - ownedHostReleased = true, - kind = "fresh-settings-on-installed-Lively", backup - }, new JsonSerializerOptions { WriteIndented = true })); - Console.WriteLine("PASS real Lively cold start with fresh settings: no UI, WebView2, no independent autostart"); - } - finally - { - using var running = LivelyHost.Running(); - if (running is not null) - await host.StopOwnedProcess(new(running.Id, running.StartTime.ToUniversalTime().Ticks)); - foreach (var (name, bytes) in saved) - { - var file = Path.Combine(LivelyHost.DataDirectory, name); - if (bytes is null) File.Delete(file); else File.WriteAllBytes(file, bytes); - } - if (original is not null) - { - await host.EnsureRunning(); - var deadline = DateTime.UtcNow.AddSeconds(30); - while (DateTime.UtcNow < deadline && !(await host.GetWallpapers()).SequenceEqual(baseline)) - await Task.Delay(200); - if (!(await host.GetWallpapers()).SequenceEqual(baseline)) - throw new Exception("Original Lively layout did not restore. Backup: " + backup); - } - Console.WriteLine("Original settings, layout and running state restored. Backup: " + backup); - } - } -} diff --git a/wallpaper/windows/Amadeus.Wallpaper.Tests/Program.cs b/wallpaper/windows/Amadeus.Wallpaper.Tests/Program.cs deleted file mode 100644 index a71bd965..00000000 --- a/wallpaper/windows/Amadeus.Wallpaper.Tests/Program.cs +++ /dev/null @@ -1,735 +0,0 @@ -using System.Text.Json; -using System.IO.Pipes; -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; -using Grpc.Core; -using GrpcDotNetNamedPipes; -using Amadeus.Wallpaper; -using Amadeus.Wallpaper.Protocol.Desktop; -using Amadeus.Wallpaper.Protocol.Settings; - -if (args.Length == 2 && args[0] == "--cold-start-probe") -{ - await ColdStartProbe.Run(args[1]); - return; -} -if (args.Length == 1 && args[0] == "--false-empty-counterexample") -{ - var directory = Path.Combine(Path.GetTempPath(), "amadeus-wallpaper-counterexample", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); - try - { - var journal = Path.Combine(directory, "session.json"); - var host = new FakeHost { ReportedItems = [], CloseNoOp = true, FailAfterMount = true }; - var session = new Session(host, journal); - var error = await Failure(() => session.Mount(new("amadeus", "primary", host.Options, [], new(10, 20)))); - // 仅记录尚未修复的协议反例,不能计入恢复安全契约的通过数。 - Console.WriteLine(JsonSerializer.Serialize(new { - diagnostic = "known false-empty protocol counterexample; not a recovery acceptance pass", - error = error.Message, actual = host.ActualItems, reported = await host.GetWallpapers(), - closeReturnedWithoutRemoval = host.ClosedProjects.Count > 0 && host.ActualItems.Count > 0, - journalRetained = File.Exists(journal), stopRequested = host.Stopped is not null, - strongRecoveryProven = false - })); - } - finally { Directory.Delete(directory, true); } - return; -} - -static void Check(bool condition, string message) -{ - if (!condition) throw new Exception(message); -} -var passed = 0; -var failures = new List(); -async Task Test(string name, Func test) -{ - var directory = Path.Combine(Path.GetTempPath(), "amadeus-wallpaper-tests", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); - try - { - var host = new FakeHost(); - var journal = Path.Combine(directory, "session.json"); - await test(host, new Session(host, journal), journal); - Console.WriteLine("PASS " + name); - passed++; - } - catch (Exception error) - { - failures.Add(new Exception(name, error)); - Console.WriteLine("FAIL " + name + ": " + error.Message); - } - finally { Directory.Delete(directory, true); } -} -Snapshot Before(FakeHost host, ProcessIdentity? owned = null) => - new("amadeus", "primary", host.Options, host.ActualItems.ToList(), owned); -async Task Failure(Func operation) -{ - try { await operation(); } - catch (Exception error) { return error; } - throw new Exception("expected failure"); -} -void Phase(string journal, string expected) -{ - using var saved = JsonDocument.Parse(File.ReadAllText(journal)); - Check(saved.RootElement.GetProperty("Phase").GetString() == expected, "wrong durable operation phase"); -} -void WriteLegacy(string journal, Snapshot snapshot) -{ - File.WriteAllText(journal, JsonSerializer.Serialize(new { - snapshot.Project, snapshot.Monitor, snapshot.Options, snapshot.Wallpapers, - snapshot.OwnedProcess, snapshot.Mounted - })); -} - -await Test("restores the existing wallpaper without closing a shared Lively", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - await session.Mount(Before(host)); - await session.Restore(); - Check(host.ActualItems.SequenceEqual(new[] { new DesktopItem("primary", "old") }), "original wallpaper missing"); - Check(host.Stopped is null && !File.Exists(journal), "shared host stopped or journal left behind"); -}); -await Test("static wallpaper is revealed by removing only our layer", async (host, session, journal) => { - var process = new ProcessIdentity(10, 20); - await session.Mount(Before(host, process)); - await session.Restore(); - Check(host.ActualItems.Count == 0 && host.Stopped == process, "owned host not released"); - Check(!File.Exists(journal), "journal retained after successful restore"); -}); -await Test("secondary monitors keep their original wallpaper", async (host, session, _) => { - host.ActualItems.AddRange([new("primary", "old"), new("secondary", "second")]); - await session.Mount(Before(host)); - Check(host.ActualItems.Contains(new("secondary", "second")), "mount changed secondary monitor"); - await session.Restore(); - Check(host.ActualItems.Contains(new("secondary", "second")), "restore changed secondary monitor"); -}); -await Test("user wallpaper changes win over our original snapshot", async (host, session, _) => { - host.ActualItems.Add(new("primary", "old")); - await session.Mount(Before(host, new(10, 20))); - await host.SetWallpaper(new("primary", "user-new")); - await session.Restore(); - Check(host.ActualItems.Single().Path == "user-new" && host.Stopped is null, "user's choice overwritten"); -}); -await Test("user clearing the wallpaper is not undone", async (host, session, _) => { - host.ActualItems.Add(new("primary", "old")); - await session.Mount(Before(host)); - host.ActualItems.Clear(); - await session.Restore(); - Check(host.ActualItems.Count == 0, "user's clear was undone"); -}); -await Test("failed mount rolls back after Lively has closed the old wallpaper", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - host.FailNextMount = true; - try { await session.Mount(Before(host)); throw new Exception("expected failure"); } - catch (IOException) { } - Check(host.ActualItems.Single().Path == "old" && !File.Exists(journal), "rollback failed"); -}); -await Test("crash recovery uses the durable journal in a new session", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - await session.Mount(Before(host)); - await new Session(host, journal).Restore(); - Check(host.ActualItems.Single().Path == "old", "recovery lost original"); -}); -await Test("restoration failure retains enough state to retry after our layer closes", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - await session.Mount(Before(host)); - host.FailNextMount = true; - try { await session.Restore(); throw new Exception("expected failure"); } - catch (IOException) { } - Check(File.Exists(journal), "failed recovery discarded journal"); - await new Session(host, journal).Restore(); - Check(host.ActualItems.Single().Path == "old" && !File.Exists(journal), "retry failed"); -}); -await Test("a changed layout is preserved", async (host, session, _) => { - host.ActualItems.Add(new("primary", "old")); - await session.Mount(Before(host, new(10, 20))); - host.Options = host.Options with { Arrangement = 1 }; - await session.Restore(); - Check(host.Options.Arrangement == 1 && host.ActualItems.Count == 0 && host.Stopped is null, "new layout overwritten"); -}); -await Test("span layout restores its original project", async (host, session, _) => { - host.Options = host.Options with { Arrangement = 1 }; - host.ActualItems.Add(new("primary", "span-old")); - await session.Mount(Before(host)); - await session.Restore(); - Check(host.ActualItems.Single().Path == "span-old", "span restore failed"); -}); -await Test("duplicate layout restores all original displays", async (host, session, _) => { - host.Options = host.Options with { Arrangement = 2 }; - host.ActualItems.AddRange([new("primary", "old"), new("secondary", "old")]); - await session.Mount(Before(host)); - await session.Restore(); - Check(host.ActualItems.Count == 2 && host.ActualItems.All(x => x.Path == "old"), "duplicate restore failed"); -}); -await Test("first-run preparation is idempotent and preserves existing settings", async (_, _, journal) => { - var directory = Path.GetDirectoryName(journal)!; - Check(LivelyHost.PrepareFirstRun(directory), "did not prepare fresh settings"); - var file = Path.Combine(directory, "Settings.json"); - using var settings = JsonDocument.Parse(File.ReadAllText(file)); - Check(!settings.RootElement.GetProperty("IsFirstRun").GetBoolean(), "setup wizard still enabled"); - Check(!settings.RootElement.GetProperty("Startup").GetBoolean(), "second autostart owner created"); - File.WriteAllText(file, "user-owned-settings"); - Check(!LivelyHost.PrepareFirstRun(directory) && File.ReadAllText(file) == "user-owned-settings", "existing settings replaced"); - await Task.CompletedTask; -}); -await Test("settings IPC preserves fields outside our wire subset", async (_, _, _) => { - // field 3 (Startup=true) is intentionally unknown to our client schema. - var input = new byte[] { 24, 1, 224, 1, 0 }; - var message = Settings.Parser.ParseFrom(input); - message.WebBrowser = 1; - var roundTrip = message.ToByteArray(); - var reader = new CodedInputStream(roundTrip); - var startup = false; - uint tag; - while ((tag = reader.ReadTag()) != 0) { - if (tag == 24) startup = reader.ReadBool(); else reader.SkipLastField(); - } - Check(startup, "unknown setting was discarded"); - await Task.CompletedTask; -}); -await Test("a settled failed mount restores an originally empty desktop", async (host, session, journal) => { - var process = new ProcessIdentity(10, 20); - host.FailNextMount = true; - var original = await Failure(() => session.Mount(Before(host, process))); - Check(original is IOException && host.ActualItems.Count == 0, "empty desktop rollback failed"); - Check(host.Stopped == process && !File.Exists(journal), "empty rollback did not release ownership"); - await new Session(host, journal).Restore(); - await session.Mount(Before(host)); - await session.Restore(); - Check(!File.Exists(journal), "failed mount permanently blocked the next session"); -}); -await Test("settled but unconfirmed mounting still automatically rolls back", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - host.FailAfterMount = true; - var original = await Failure(() => session.Mount(Before(host))); - Check(ReferenceEquals(original, host.MountFailure), "original mount error was lost"); - Check(host.ActualItems.Single().Path == "old" && !File.Exists(journal), "settled rollback lost the old scene"); - Check(host.ClosedProjects.SequenceEqual(new[] { "amadeus" }), "rollback did not scope close to the submitted project"); -}); -await Test("partially applied session settings roll back without wallpaper mutations", async (host, session, journal) => { - host.Options = new(0, 0, true, true); - host.ActualItems.Add(new("primary", "old")); - var before = Before(host, new(10, 20)); - host.OptionsFailure = new IOException("settings write failed after applying"); - var original = await Failure(() => session.Mount(before)); - Check(ReferenceEquals(original, host.OptionsFailure), "settings error was replaced"); - Check(host.Options == before.Options && host.ActualItems.SequenceEqual(before.Wallpapers), "partial settings not restored"); - Check(host.SetCalls == 0 && host.ClosedProjects.Count == 0, "settings rollback dispatched wallpaper mutations"); - Check(host.RestoreCalls == 1 && !File.Exists(journal), "settings rollback was skipped or repeated"); -}); -await Test("preparation cleanup failure retains both errors and retries settings only", async (host, session, journal) => { - host.Options = new(0, 0, true, true); - var before = Before(host); - host.OptionsFailure = new IOException("partial settings failure"); - host.RestoreFailure = new IOException("settings rollback failed"); - var error = await Failure(() => session.Mount(before)); - Check(error is AggregateException both && both.InnerExceptions.SequenceEqual(new[] { host.OptionsFailure, host.RestoreFailure }), "settings primary and cleanup errors were not retained"); - Phase(journal, "Preparing"); - Check(host.SetCalls == 0 && host.ClosedProjects.Count == 0, "preparation cleanup mutated wallpapers"); - host.RestoreFailure = null; - await new Session(host, journal).Restore(); - Check(host.Options == before.Options && !File.Exists(journal), "preparation recovery could not retry"); -}); -await Test("mount and cleanup failures retain both causes and can retry", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - host.FailAfterMount = true; - host.RestoreFailure = new IOException("restoration unavailable"); - var error = await Failure(() => session.Mount(Before(host, new(10, 20)))); - Check(error is AggregateException both && both.InnerExceptions.SequenceEqual(new Exception[] { host.MountFailure, host.RestoreFailure }), "mount and cleanup errors were not retained"); - Phase(journal, "Settled"); - Check(File.Exists(journal) && host.Stopped is null, "failed rollback discarded retry state"); - host.RestoreFailure = null; - await new Session(host, journal).Restore(); - Check(host.ActualItems.Single().Path == "old" && !File.Exists(journal), "recovery retry did not restore the original scene"); -}); -await Test("a visible close no-op keeps the journal and can retry", async (host, session, journal) => { - await session.Mount(Before(host, new(10, 20))); - host.CloseNoOp = true; - var error = await Failure(() => session.Restore()); - Check(error is IOException && File.Exists(journal) && host.Stopped is null, "visible remaining scene was accepted as closed"); - Check(host.ActualItems.Single().Path == "amadeus", "fake no-op unexpectedly removed the scene"); - host.CloseNoOp = false; - await new Session(host, journal).Restore(); - Check(host.ActualItems.Count == 0 && !File.Exists(journal), "close no-op recovery could not retry"); -}); -await Test("an explicit close exception keeps recovery state for retry", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - await session.Mount(Before(host)); - host.CloseFailure = new IOException("project close unavailable"); - var error = await Failure(() => session.Restore()); - Check(ReferenceEquals(error, host.CloseFailure) && File.Exists(journal), "close failure lost journal or error"); - host.CloseFailure = null; - await new Session(host, journal).Restore(); - Check(host.ActualItems.Single().Path == "old" && !File.Exists(journal), "close exception recovery could not retry"); -}); -await Test("missing original wallpaper after restore keeps retry state", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - await session.Mount(Before(host)); - host.IgnoreRestoredWallpaper = true; - var error = await Failure(() => session.Restore()); - Check(error is IOException && File.Exists(journal), "missing original wallpaper was treated as restored"); - host.IgnoreRestoredWallpaper = false; - await new Session(host, journal).Restore(); - Check(host.ActualItems.Single().Path == "old" && !File.Exists(journal), "missing-original recovery could not retry"); -}); -await Test("the original project can be the same managed project on its original monitor", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "amadeus")); - var before = Before(host); - await session.Mount(before); - await session.Restore(); - Check(host.ActualItems.SequenceEqual(before.Wallpapers) && !File.Exists(journal), "the original managed project was rejected as a leftover"); -}); -await Test("the original project does not permit a leftover on another monitor", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "amadeus")); - await session.Mount(Before(host)); - host.ReportedItems = [new("secondary", "amadeus")]; - var error = await Failure(() => session.Restore()); - Check(error is IOException && File.Exists(journal), "a managed project outside the before-image monitor was accepted"); -}); -await Test("pending RPC can mount late and only a new host identity unlocks recovery", async (host, session, journal) => { - var process = new ProcessIdentity(10, 20); - host.ActualItems.Add(new("primary", "old")); - host.FailPendingMount = true; - var original = await Failure(() => session.Mount(Before(host, process))); - Check(ReferenceEquals(original, host.PendingFailure), "pending RPC primary error was lost"); - Phase(journal, "Pending"); - Check(host.ClosedProjects.Count == 0 && host.RestoreCalls == 0 && host.Stopped is null, "pending RPC was cleaned up as settled"); - var reads = host.WallpaperReads; - Check(await Failure(() => new Session(host, journal).Restore()) is InvalidOperationException, "pending RPC was accepted without restart"); - Check(await Failure(() => new Session(host, journal).Restore(process)) is InvalidOperationException, "same process was accepted as a restart"); - Check(host.WallpaperReads == reads && host.ClosedProjects.Count == 0, "pending recovery inspected or mutated the old host"); - host.CompletePendingMount(); - Check(host.ActualItems.Single().Path == "amadeus" && File.Exists(journal), "fixture did not reproduce the late mount"); - Check(await Failure(() => new Session(host, journal).Restore()) is InvalidOperationException, "a visible late mount cleared pending without restart"); - host.ActualItems.Clear(); - host.LiveProcesses.Clear(); - var restarted = new ProcessIdentity(11, 30); - host.RunningProcess = restarted; - host.LiveProcesses.Add(restarted); - await new Session(host, journal).Restore(restarted); - Check(host.ActualItems.Single().Path == "old" && host.Stopped == restarted && !File.Exists(journal), "actual host restart did not unlock recovery"); -}); -await Test("pending journal records the request receiver separately from cleanup ownership", async (host, session, journal) => { - host.FailPendingMount = true; - await Failure(() => session.Mount(Before(host))); - using var saved = JsonDocument.Parse(File.ReadAllText(journal)); - Check(saved.RootElement.TryGetProperty("RequestProcess", out var receiver), "pending journal has no request receiver identity"); - Check(JsonSerializer.Deserialize(receiver) == host.RunningProcess, "request receiver was replaced by cleanup ownership"); -}); -await Test("a live request receiver blocks recovery even if the helper supplies another identity", async (host, session, journal) => { - host.FailPendingMount = true; - await Failure(() => session.Mount(Before(host))); - var error = await Failure(() => new Session(host, journal).Restore(new(11, 30))); - Check(error is InvalidOperationException && File.Exists(journal), "live request receiver was treated as exited"); - Check(host.Stopped is null && host.ClosedProjects.Count == 0, "pending recovery changed the live request receiver"); -}); -await Test("manual host restart unlocks pending recovery without helper startup ownership", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - host.FailPendingMount = true; - await Failure(() => session.Mount(Before(host))); - host.LiveProcesses.Clear(); - host.RunningProcess = new(11, 30); - host.LiveProcesses.Add(host.RunningProcess); - await new Session(host, journal).Restore(); - Check(host.ActualItems.Single().Path == "old" && !File.Exists(journal), "manual restart did not unlock recovery"); - Check(host.Stopped is null, "recovery stopped a manually started shared host"); -}); -await Test("a reused PID with a new start identity does not keep the old request alive", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - host.FailPendingMount = true; - await Failure(() => session.Mount(Before(host))); - var previous = host.RunningProcess!; - host.LiveProcesses.Clear(); - host.RunningProcess = previous with { Started = previous.Started + 1 }; - host.LiveProcesses.Add(host.RunningProcess); - await new Session(host, journal).Restore(); - Check(host.ActualItems.Single().Path == "old" && !File.Exists(journal), "PID reuse blocked recovery"); -}); -await Test("receiver identity lookup failure cannot dispatch a mount", async (host, session, journal) => { - host.IdentityFailure = new IOException("request receiver identity unavailable"); - var error = await Failure(() => session.Mount(Before(host))); - Check(ReferenceEquals(error, host.IdentityFailure), "receiver lookup error was hidden"); - Check(host.SetCalls == 0 && host.ClosedProjects.Count == 0, "mount dispatched without a receiver identity"); - Check(host.RestoreCalls == 1 && !File.Exists(journal), "pre-dispatch failure did not complete settings-only rollback"); -}); -await Test("receiver liveness lookup failure preserves pending recovery for retry", async (host, session, journal) => { - host.FailPendingMount = true; - await Failure(() => session.Mount(Before(host))); - host.IdentityFailure = new IOException("request receiver liveness unavailable"); - var error = await Failure(() => new Session(host, journal).Restore(new(11, 30))); - Check(ReferenceEquals(error, host.IdentityFailure) && File.Exists(journal), "unknown liveness was treated as process exit"); - Check(host.Stopped is null && host.ClosedProjects.Count == 0, "unknown liveness changed the desktop"); -}); -await Test("manual restart recovery keeps a new user wallpaper and its host", async (host, session, journal) => { - var owned = host.RunningProcess; - host.FailPendingMount = true; - await Failure(() => session.Mount(Before(host, owned))); - host.LiveProcesses.Clear(); - host.RunningProcess = new(11, 30); - host.LiveProcesses.Add(host.RunningProcess); - host.ActualItems.Add(new("primary", "user-new")); - await new Session(host, journal).Restore(); - Check(host.ActualItems.Single().Path == "user-new" && host.Stopped is null && !File.Exists(journal), "manual recovery removed a user's new scene or host"); -}); -await Test("recovery after receiver exit retains failures and can retry", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - host.FailPendingMount = true; - await Failure(() => session.Mount(Before(host))); - host.LiveProcesses.Clear(); - host.RunningProcess = new(11, 30); - host.LiveProcesses.Add(host.RunningProcess); - host.RestoreFailure = new IOException("recovery options unavailable"); - var error = await Failure(() => new Session(host, journal).Restore()); - Check(ReferenceEquals(error, host.RestoreFailure) && File.Exists(journal), "receiver exit was mistaken for restoration success"); - host.RestoreFailure = null; - await new Session(host, journal).Restore(); - Check(host.ActualItems.Single().Path == "old" && !File.Exists(journal), "receiver-exit recovery lost retry state"); -}); -foreach (var running in new[] { true, false }) { - await Test("an identity-less pending journal is invalid with host running=" + running, async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - var before = Before(host); - var invalid = JsonSerializer.Serialize(new { - before.Project, before.Monitor, before.Options, before.Wallpapers, - before.OwnedProcess, before.Mounted, Phase = "Pending" - }); - File.WriteAllText(journal, invalid); - if (!running) { - host.LiveProcesses.Clear(); - host.RunningProcess = null; - } - var error = await Failure(() => session.Restore()); - Check(error is InvalidDataException, "pending journal without a receiver was not rejected as invalid"); - Check(File.ReadAllText(journal) == invalid, "invalid journal was rewritten or deleted"); - Check(host.SetCalls == 0 && host.RestoreCalls == 0 && host.WallpaperReads == 0 && - host.ClosedProjects.Count == 0 && host.Stopped is null, "invalid journal recovery touched the host"); - Check(host.ActualItems.SequenceEqual(before.Wallpapers) && host.Options == before.Options, - "invalid journal recovery changed the desktop"); - }); -} -await Test("a user selection wins after a failed rollback", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - host.FailAfterMount = true; - host.CloseFailure = new IOException("close unavailable"); - await Failure(() => session.Mount(Before(host, new(10, 20)))); - host.CloseFailure = null; - await host.SetWallpaper(new("primary", "user-new")); - await new Session(host, journal).Restore(); - Check(host.ActualItems.Single().Path == "user-new" && host.Stopped is null && !File.Exists(journal), "uncertain recovery overwrote the user's new scene"); -}); -await Test("legacy journals without a phase keep their original recovery semantics", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - var snapshot = Before(host); - WriteLegacy(journal, snapshot); - host.ActualItems.Clear(); - await session.Restore(); - Check(host.ActualItems.Single().Path == "old" && !File.Exists(journal), "legacy pre-mount recovery changed"); - WriteLegacy(journal, snapshot with { Mounted = true }); - await host.SetWallpaper(new("primary", "user-new")); - await new Session(host, journal).Restore(); - Check(host.ActualItems.Single().Path == "user-new" && !File.Exists(journal), "legacy mounted recovery overwrote a user choice"); -}); -await Test("mounted lifecycle does not repeat a failed mount rollback", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - host.FailNextMount = true; - var invoked = false; - var error = await Failure(() => session.RunMounted(Before(host), () => { invoked=true; return Task.CompletedTask; })); - Check(error is IOException && !invoked, "failed mount ran the mounted body"); - Check(host.ClosedProjects.Count == 1 && host.RestoreCalls == 2 && !File.Exists(journal), "failed mount rollback repeated or was skipped"); -}); -await Test("mounted body and cleanup errors remain observable together", async (host, session, journal) => { - host.ActualItems.Add(new("primary", "old")); - var bodyError = new IOException("mounted body failed"); - host.RestoreFailure = new IOException("mounted cleanup failed"); - var error = await Failure(() => session.RunMounted(Before(host), () => Task.FromException(bodyError))); - Check(error is AggregateException both && both.InnerExceptions.SequenceEqual(new[] { bodyError, host.RestoreFailure }), "body or cleanup error was lost"); - Check(host.ClosedProjects.Count == 1 && host.RestoreCalls == 1 && File.Exists(journal), "mounted cleanup was skipped or repeated"); - host.RestoreFailure = null; - await new Session(host, journal).Restore(); - Check(host.ActualItems.Single().Path == "old" && !File.Exists(journal), "body cleanup failure lost retry state"); -}); -await Test("outer failure cleanup preserves a user choice after settled mount rollback", async (host, session, journal) => { - var process = new ProcessIdentity(10, 20); - host.ActualItems.Add(new("primary", "old")); - host.FailAfterMount = true; - host.OnMountConfirmation = () => { - host.ActualItems.Clear(); - host.ActualItems.Add(new("primary", "user-new")); - }; - Exception? original = null; - try { await session.RunMounted(Before(host, process), () => Task.CompletedTask); } - catch (Exception error) { original = error; await session.ReleaseUnstartedHost(process); } - Check(ReferenceEquals(original, host.MountFailure), "outer cleanup replaced the settled mount failure"); - Check(!File.Exists(journal) && host.ActualItems.Single().Path == "user-new", "mount rollback did not retain the user choice"); - Check(host.Stopped is null, "outer catch stopped the host after owned rollback kept a user choice"); -}); -await Test("outer failure cleanup preserves a user choice after mounted body failure", async (host, session, journal) => { - var process = new ProcessIdentity(10, 20); - var bodyError = new IOException("body failed after the user selected a new scene"); - Exception? original = null; - try { - await session.RunMounted(Before(host, process), async () => { - await host.SetWallpaper(new("primary", "user-new")); - throw bodyError; - }); - } - catch (Exception error) { original = error; await session.ReleaseUnstartedHost(process); } - Check(ReferenceEquals(original, bodyError), "outer cleanup replaced the mounted body error"); - Check(!File.Exists(journal) && host.ActualItems.Single().Path == "user-new", "body cleanup did not retain the user choice"); - Check(host.Stopped is null, "outer catch stopped the host after body cleanup kept a user choice"); -}); -await Test("initial journal save failure can release the unstarted owned host", async (host, _, journal) => { - var process = new ProcessIdentity(10, 20); - var blocked = Path.Combine(Path.GetDirectoryName(journal)!, "not-a-directory"); - File.WriteAllText(blocked, "synthetic obstruction"); - var failedJournal = Path.Combine(blocked, "session.json"); - var session = new Session(host, failedJournal); - Exception? original = null; - var invoked = false; - try { await session.RunMounted(Before(host, process), () => { invoked=true; return Task.CompletedTask; }); } - catch (Exception error) { original=error; await session.ReleaseUnstartedHost(process); } - Check(original is IOException && !invoked && !File.Exists(failedJournal), "fixture did not fail the initial save"); - Check(host.Stopped == process && host.SetCalls == 0 && host.RestoreCalls == 0, "unstarted host was not released without wallpaper mutation"); -}); -await Test("an existing pending journal prevents outer initialization cleanup", async (host, session, journal) => { - var process = new ProcessIdentity(10, 20); - File.WriteAllText(journal, JsonSerializer.Serialize(new { - Project = "amadeus", Monitor = "primary", host.Options, Wallpapers = host.ActualItems, - OwnedProcess = process, Mounted = false, Phase = "Pending", RequestProcess = host.RunningProcess - })); - Exception? original = null; - try { await session.RunMounted(Before(host, process), () => Task.CompletedTask); } - catch (Exception error) { original=error; await session.ReleaseUnstartedHost(process); } - Check(original is InvalidOperationException && File.Exists(journal), "existing pending journal was discarded"); - Check(host.Stopped is null && host.SetCalls == 0 && host.RestoreCalls == 0, "pending host was stopped or mutated by outer cleanup"); -}); -await Test("snapshot failure after recovery preserves the recovered host for a user choice", async (host, session, journal) => { - var restarted = new ProcessIdentity(10, 20); - WriteLegacy(journal, Before(host, new(9, 10)) with { Mounted = true }); - host.ActualItems.Add(new("primary", "user-new")); - await session.Restore(restarted); - Check(!File.Exists(journal) && host.Stopped is null, "recovery did not preserve the user scene"); - host.OptionsReadFailure = new IOException("snapshot options read unavailable"); - Exception? original = null; - try { - var next = new Snapshot("amadeus", "primary", await host.GetOptions(), await host.GetWallpapers(), restarted); - await session.RunMounted(next, () => Task.CompletedTask); - } - catch (Exception error) { original=error; await session.ReleaseUnstartedHost(restarted); } - Check(ReferenceEquals(original, host.OptionsReadFailure), "snapshot error was replaced"); - Check(host.Stopped is null && host.ActualItems.Single().Path == "user-new", "outer cleanup stopped the recovered host kept for a user choice"); -}); -await Test("snapshot failure can release a different host started after recovery stopped the old host", async (host, session, journal) => { - var recovered = new ProcessIdentity(10, 20); - WriteLegacy(journal, Before(host, new(9, 10))); - await session.Restore(recovered); - Check(host.Stopped == recovered && !File.Exists(journal), "recovery did not release the old owned host"); - var newlyStarted = new ProcessIdentity(10, 21); - host.OptionsReadFailure = new IOException("new host snapshot unavailable"); - Exception? original = null; - try { - var next = new Snapshot("amadeus", "primary", await host.GetOptions(), await host.GetWallpapers(), newlyStarted); - await session.RunMounted(next, () => Task.CompletedTask); - } - catch (Exception error) { original=error; await session.ReleaseUnstartedHost(newlyStarted); } - Check(ReferenceEquals(original, host.OptionsReadFailure), "new host snapshot error was replaced"); - Check(host.Stopped == newlyStarted, "cleanup ownership of the prior process prevented releasing the unstarted new host"); -}); -Task BoundRequest(string pipeName, Action dispatching, - Func? identify = null, - Action>? bind = null, DateTime? deadline = null) - => WallpaperRequest.Send(pipeName, new SetWallpaperRequest { LivelyInfoPath = "synthetic", MonitorId = "primary" }, dispatching, identify, bind, deadline); -Method SetMethod() => new(MethodType.Unary, - DesktopService.Descriptor.FullName, "SetWallpaper", - Marshallers.Create(value => value.ToByteArray(), SetWallpaperRequest.Parser.ParseFrom), - Marshallers.Create(value => value.ToByteArray(), Empty.Parser.ParseFrom)); -await Test("real Set transport persists its exact pipe receiver before dispatch", async (host, session, journal) => { - var pipeName = "amadeus-wallpaper-contract-" + Guid.NewGuid().ToString("N"); - using var server = new NamedPipeServer(pipeName); - var calls = 0; - server.ServiceBinder.AddMethod(SetMethod(), (SetWallpaperRequest _, ServerCallContext _) => { - using var saved = JsonDocument.Parse(File.ReadAllText(journal)); - var identity = JsonSerializer.Deserialize(saved.RootElement.GetProperty("RequestProcess")); - using var receiver = System.Diagnostics.Process.GetCurrentProcess(); - Check(identity == new ProcessIdentity(Environment.ProcessId, receiver.StartTime.ToUniversalTime().Ticks), "Set executed before its actual receiver identity was persisted"); - Check(saved.RootElement.GetProperty("Phase").GetString() == "Pending", "Set executed before its pending journal was durable"); - calls++; - return Task.FromResult(new Empty()); - }); - server.Start(); - host.Dispatch = dispatching => BoundRequest(pipeName, dispatching); - await session.Mount(Before(host)); - Check(calls == 1, "bound Set did not execute exactly once"); - await session.Restore(); - Check(!File.Exists(journal), "synthetic transport session did not clean up"); -}); -foreach (var stage in new[] { "identity", "journal", "hook" }) { - await Test("real Set transport refuses dispatch after " + stage + " failure", async (host, session, journal) => { - var pipeName = "amadeus-wallpaper-contract-" + Guid.NewGuid().ToString("N"); - using var server = new NamedPipeServer(pipeName); - var calls = 0; - server.ServiceBinder.AddMethod(SetMethod(), (SetWallpaperRequest _, ServerCallContext _) => { - calls++; - return Task.FromResult(new Empty()); - }); - server.Start(); - var failure = new IOException("synthetic " + stage + " failure"); - host.ActualItems.Add(new("primary", "old")); - host.Dispatch = dispatching => { - if (stage == "journal") Directory.CreateDirectory(journal + ".tmp"); - return BoundRequest(pipeName, dispatching, - stage == "identity" ? _ => throw failure : null, - stage == "hook" ? (channel, capture) => WallpaperRequest.Bind(channel, capture, null) : null); - }; - var error = await Failure(() => session.Mount(Before(host))); - Check(stage == "journal" ? error is UnauthorizedAccessException : stage == "hook" - ? error is NotSupportedException : ReferenceEquals(error, failure), "the " + stage + " failure was hidden or replaced"); - Check(calls == 0 && !File.Exists(journal), "Set executed without its durable receiver identity"); - Check(host.RestoreCalls == 1 && host.ClosedProjects.Count == 0 && host.ActualItems.Single().Path == "old", "pre-dispatch transport failure did not finish settings-only rollback"); - }); -} -await Test("a Set connection arriving after timeout cannot identify persist or send", async (host, session, journal) => { - var pipeName = "amadeus-wallpaper-contract-" + Guid.NewGuid().ToString("N"); - var identities = 0; - var dispatches = 0; - host.Dispatch = dispatching => BoundRequest(pipeName, identity => { - Interlocked.Increment(ref dispatches); - dispatching(identity); - }, _ => { - Interlocked.Increment(ref identities); - return new(Environment.ProcessId, 1); - }, deadline: DateTime.UtcNow.AddMilliseconds(100)); - var error = await Failure(() => session.Mount(Before(host))).WaitAsync(TimeSpan.FromSeconds(5)); - Check(error is RpcException rpc && rpc.StatusCode == StatusCode.DeadlineExceeded, "fixture did not expire before connection"); - Check(!File.Exists(journal) && host.RestoreCalls == 1, "an unstarted request did not finish settings rollback"); - using var pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, 1, - PipeTransmissionMode.Message, PipeOptions.Asynchronous); - await pipe.WaitForConnectionAsync().WaitAsync(TimeSpan.FromSeconds(5)); - var bytes = await pipe.ReadAsync(new byte[4096]).AsTask().WaitAsync(TimeSpan.FromSeconds(5)); - Check(identities == 0 && dispatches == 0, "an ended Set invocation later identified or persisted its receiver"); - Check(bytes == 0 && !File.Exists(journal), "an ended Set invocation later wrote request bytes or recreated its journal"); -}); -await Test("timeout waits for an entered persistence boundary before allowing cleanup", async (host, session, journal) => { - var pipeName = "amadeus-wallpaper-contract-" + Guid.NewGuid().ToString("N"); - using var server = new NamedPipeServer(pipeName); - server.ServiceBinder.AddMethod(SetMethod(), (SetWallpaperRequest _, ServerCallContext _) => Task.FromResult(new Empty())); - server.Start(); - var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var boundaryExited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var release = new ManualResetEventSlim(); - var deadline = DateTime.UtcNow.AddMilliseconds(500); - host.Dispatch = dispatching => BoundRequest(pipeName, identity => { - try - { - entered.TrySetResult(); - if (!release.Wait(TimeSpan.FromSeconds(5))) throw new TimeoutException("synthetic persistence release was not signalled"); - dispatching(identity); - } - finally { boundaryExited.TrySetResult(); } - }, deadline: deadline); - var mounting = session.Mount(Before(host)); - try - { - await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); - await Task.Delay(TimeSpan.FromMilliseconds(Math.Max(0, (deadline - DateTime.UtcNow).TotalMilliseconds) + 100)); - Check(!mounting.IsCompleted, "Set returned at its deadline while its persistence boundary was still running"); - Check(File.Exists(journal) && host.RestoreCalls == 0, "deadline cleanup raced the running persistence boundary"); - } - finally - { - release.Set(); - await boundaryExited.Task.WaitAsync(TimeSpan.FromSeconds(5)); - } - var error = await Failure(() => mounting).WaitAsync(TimeSpan.FromSeconds(5)); - Check(error is RpcException rpc && rpc.StatusCode == StatusCode.DeadlineExceeded, "the original request deadline was hidden"); - Phase(journal, "Pending"); - Check(host.RestoreCalls == 0, "a persisted uncertain request was rolled back as unstarted"); -}); -Console.WriteLine($"{passed} contracts passed."); -if (failures.Count > 0) throw new AggregateException("Wallpaper contract failures", failures); - -sealed class FakeHost : IWallpaperHost -{ - public Func, Task>? Dispatch; - public ProcessIdentity? RunningProcess = new(100, 200); - public HashSet LiveProcesses = [new(100, 200)]; - public Exception? IdentityFailure; - public bool IsProcessRunning(ProcessIdentity identity) { - if (IdentityFailure is not null) throw IdentityFailure; - return LiveProcesses.Contains(identity); - } - public List ActualItems = []; - public List? ReportedItems; - public HostOptions Options = new(0, 1, false, false); - public ProcessIdentity? Stopped; - public bool FailNextMount; - public bool FailAfterMount; - public Action? OnMountConfirmation; - public bool FailPendingMount; - public bool CloseNoOp; - public bool IgnoreRestoredWallpaper; - public int SetCalls; - public int RestoreCalls; - public int WallpaperReads; - public List ClosedProjects = []; - DesktopItem? pendingWallpaper; - public readonly TimeoutException MountFailure = new("wallpaper mount confirmation timed out"); - public readonly IOException PendingFailure = new("native SetWallpaper RPC deadline exceeded"); - public Exception? OptionsFailure; - public Exception? OptionsReadFailure; - public Exception? CloseFailure; - public Exception? RestoreFailure; - public Task GetOptions() { - if (OptionsReadFailure is not null) throw OptionsReadFailure; - return Task.FromResult(Options); - } - public Task> GetWallpapers() { - WallpaperReads++; - return Task.FromResult((ReportedItems ?? ActualItems).ToList()); - } - public async Task SetWallpaper(DesktopItem wallpaper, Action? requestSettled = null, - Action? requestDispatching = null) { - if (requestDispatching is not null) { - if (Dispatch is not null) await Dispatch(requestDispatching); - else { - if (IdentityFailure is not null) throw IdentityFailure; - requestDispatching(RunningProcess ?? throw new IOException("no request receiver")); - } - } - SetCalls++; - ActualItems.RemoveAll(x => x.Monitor == wallpaper.Monitor); - // RPC 截止后,服务器仍可在测试明确放行时完成挂载;不调用结算回调。 - if (FailPendingMount) { FailPendingMount = false; pendingWallpaper = wallpaper; throw PendingFailure; } - if (FailNextMount) { FailNextMount = false; requestSettled?.Invoke(); throw new IOException("player failed"); } - if (!IgnoreRestoredWallpaper || wallpaper.Path == "amadeus") ActualItems.Add(wallpaper); - requestSettled?.Invoke(); - if (FailAfterMount) { FailAfterMount = false; OnMountConfirmation?.Invoke(); throw MountFailure; } - return; - } - public void CompletePendingMount() { - var wallpaper = pendingWallpaper ?? throw new InvalidOperationException("no pending native request"); - ActualItems.RemoveAll(x => x.Monitor == wallpaper.Monitor); - ActualItems.Add(wallpaper); - pendingWallpaper = null; - } - public Task CloseProject(string project) { - ClosedProjects.Add(project); - if (CloseFailure is not null) throw CloseFailure; - if (!CloseNoOp) ActualItems.RemoveAll(x => Session.SamePath(x.Path, project)); - return Task.CompletedTask; - } - public Task SetSessionOptions() { - Options = Options with { Browser = 1, DesktopImage = false, LockImage = false }; - if (OptionsFailure is not null) throw OptionsFailure; - return Task.CompletedTask; - } - public Task RestoreOptions(HostOptions previous, bool images = true) { - RestoreCalls++; - if (RestoreFailure is not null) throw RestoreFailure; - Options = Options with { Browser = Options.Browser == 1 ? previous.Browser : Options.Browser, - DesktopImage = images && !Options.DesktopImage ? previous.DesktopImage : Options.DesktopImage, - LockImage = images && !Options.LockImage ? previous.LockImage : Options.LockImage }; - return Task.CompletedTask; - } - public Task StopOwnedProcess(ProcessIdentity process) { Stopped = process; return Task.CompletedTask; } -} diff --git a/wallpaper/windows/Amadeus.Wallpaper/Amadeus.Wallpaper.csproj b/wallpaper/windows/Amadeus.Wallpaper/Amadeus.Wallpaper.csproj deleted file mode 100644 index bc19d13f..00000000 --- a/wallpaper/windows/Amadeus.Wallpaper/Amadeus.Wallpaper.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - Exe - net8.0-windows - enable - enable - true - true - win-x64 - 8.0.31 - - - - - - - - - diff --git a/wallpaper/windows/Amadeus.Wallpaper/LivelyHost.cs b/wallpaper/windows/Amadeus.Wallpaper/LivelyHost.cs deleted file mode 100644 index df428c0b..00000000 --- a/wallpaper/windows/Amadeus.Wallpaper/LivelyHost.cs +++ /dev/null @@ -1,310 +0,0 @@ -using System.Diagnostics; -using System.Text.Json; -using System.Text.Json.Nodes; -using Google.Protobuf.WellKnownTypes; -using Grpc.Core; -using GrpcDotNetNamedPipes; -using Microsoft.Win32; -using Amadeus.Wallpaper.Protocol.Desktop; -using Amadeus.Wallpaper.Protocol.Display; -using Amadeus.Wallpaper.Protocol.Settings; -using Amadeus.Wallpaper.Protocol.Commands; - -namespace Amadeus.Wallpaper; - -public sealed class LivelyHost : IWallpaperHost -{ - public const string SupportedVersion = "2.2.1.0"; - readonly DesktopService.DesktopServiceClient desktop; - readonly DisplayService.DisplayServiceClient displays; - readonly SettingsService.SettingsServiceClient settings; - readonly CommandsService.CommandsServiceClient commands; - public ProcessIdentity? StartedProcess { get; private set; } - public static string DataDirectory => Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Lively Wallpaper"); - static DateTime Deadline => DateTime.UtcNow.AddSeconds(15); - static string LaunchBackup => Path.Combine(DataDirectory, "Amadeus.launch-settings.json"); - record ImageOptions(bool Desktop, bool Lock); - static void WriteSettings(JsonNode json) - { - var file = Path.Combine(DataDirectory, "Settings.json"); - var temporary = file + ".amadeus.tmp"; - using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None)) - { - JsonSerializer.Serialize(stream, json); - stream.Flush(true); - } - File.Move(temporary, file, true); - } - - // Lively restores its last scene during startup. Suppress its optional - // screenshot-to-system-wallpaper side effects BEFORE that happens, then - // restore these two settings after startup. A small durable backup covers - // interruption between the offline edit and the live RPC handover. - static void PrepareLaunchImages() - { - if (File.Exists(LaunchBackup)) return; - var file = Path.Combine(DataDirectory, "Settings.json"); - var json = JsonNode.Parse(File.ReadAllText(file))!; - var previous = new ImageOptions(json["DesktopAutoWallpaper"]?.GetValue() ?? false, - json["LockScreenAutoWallpaper"]?.GetValue() ?? false); - if (!previous.Desktop && !previous.Lock) return; - using (var backup = new FileStream(LaunchBackup, FileMode.CreateNew, FileAccess.Write, FileShare.None)) - { - JsonSerializer.Serialize(backup, previous); - backup.Flush(true); - } - json["DesktopAutoWallpaper"] = false; - json["LockScreenAutoWallpaper"] = false; - WriteSettings(json); - } - - public async Task RecoverLaunchImages() - { - if (!File.Exists(LaunchBackup)) return; - var previous = JsonSerializer.Deserialize(File.ReadAllText(LaunchBackup))!; - using var running = Running(); - if (running is not null) - { - var value = await settings.GetSettingsAsync(new Empty(), deadline: Deadline); - value.DesktopAutoWallpaper = previous.Desktop; - value.LockScreenAutoWallpaper = previous.Lock; - await settings.SetSettingsAsync(value, deadline: Deadline); - } - else - { - var file = Path.Combine(DataDirectory, "Settings.json"); - var json = JsonNode.Parse(File.ReadAllText(file))!; - json["DesktopAutoWallpaper"] = previous.Desktop; - json["LockScreenAutoWallpaper"] = previous.Lock; - WriteSettings(json); - } - File.Delete(LaunchBackup); - } - - public LivelyHost() - { - var pipe = new NamedPipeChannel(".", "Grpc_LIVELY:DESKTOPWALLPAPERSYSTEM" + Environment.UserName); - desktop = new(pipe); - displays = new(pipe); - settings = new(pipe); - commands = new(pipe); - } - - public static Process? Running() => Process.GetProcessesByName("Lively") - .FirstOrDefault(p => p.SessionId == Process.GetCurrentProcess().SessionId); - - public bool IsProcessRunning(ProcessIdentity identity) - { - Process process; - try { process = Process.GetProcessById(identity.Id); } - catch (ArgumentException) { return false; } - using (process) - { - try { return !process.HasExited && process.StartTime.ToUniversalTime().Ticks == identity.Started; } - catch (InvalidOperationException) when (process.HasExited) { return false; } - } - } - - public static string? FindExecutable() - { - using var running = Running(); - if (running is not null) return running.MainModule?.FileName; - const string uninstall = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"; - foreach (var hive in new[] { Registry.CurrentUser, Registry.LocalMachine }) - { - using var root = hive.OpenSubKey(uninstall); - foreach (var name in root?.GetSubKeyNames() ?? []) - { - using var key = root!.OpenSubKey(name); - if (!(key?.GetValue("DisplayName") as string ?? "").StartsWith("Lively Wallpaper")) continue; - var location = key?.GetValue("InstallLocation") as string; - if (location is not null && File.Exists(Path.Combine(location, "Lively.exe"))) - return Path.Combine(location, "Lively.exe"); - } - } - return new[] { - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Lively Wallpaper", "Lively.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Lively Wallpaper", "Lively.exe") - }.FirstOrDefault(File.Exists); - } - - public static bool PrepareFirstRun(string directory) - { - Directory.CreateDirectory(directory); - var file = Path.Combine(directory, "Settings.json"); - // An existing installation belongs to the user. Never overwrite its setup. - if (File.Exists(file)) return false; - using var stream = new FileStream(file, FileMode.CreateNew, FileAccess.Write, FileShare.None); - JsonSerializer.Serialize(stream, new { - AppVersion = SupportedVersion, IsFirstRun = false, Startup = false, - WebBrowser = 1, SysTrayIcon = false, DesktopAutoWallpaper = false, LockScreenAutoWallpaper = false - }); - stream.Flush(true); - return true; - } - - public async Task EnsureRunning() - { - try { await StartAndWait(); } - finally { await RecoverLaunchImages(); } - } - - async Task StartAndWait() - { - using var running = Running(); - List? expectedLayout = null; - if (running is null) - { - var executable = FindExecutable() ?? throw new InvalidOperationException( - "Lively Wallpaper is missing. Run the Windows wallpaper setup."); - CheckVersion(FileVersionInfo.GetVersionInfo(executable).FileVersion ?? ""); - PrepareFirstRun(DataDirectory); - PrepareLaunchImages(); - var layoutFile = Path.Combine(DataDirectory, "WallpaperLayout.json"); - if (File.Exists(layoutFile)) - { - using var layout = JsonDocument.Parse(File.ReadAllText(layoutFile)); - expectedLayout = layout.RootElement.EnumerateArray().Select(item => new DesktopItem( - item.GetProperty("LivelyScreen").GetProperty("DeviceId").GetString()!, - item.GetProperty("LivelyInfoPath").GetString()!)).ToList(); - } - using var started = Process.Start(new ProcessStartInfo(executable) { - UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden - }) ?? throw new IOException("Could not start Lively."); - StartedProcess = new(started.Id, started.StartTime.ToUniversalTime().Ticks); - } - var until = DateTime.UtcNow.AddSeconds(45); - Exception? last = null; - while (DateTime.UtcNow < until) - { - try - { - var stats = await desktop.GetCoreStatsAsync(new Empty(), deadline: DateTime.UtcNow.AddSeconds(2)); - CheckVersion(stats.AssemblyVersion); - if (stats.IsCoreInitialized) - { - // Core readiness precedes restoration of saved wallpapers. - // Do not snapshot an empty desktop while they are still loading. - var screens = await displays.GetScreensAsync(new Empty(), deadline: Deadline); - var connected = screens.Screens_.Select(x => x.DeviceId).ToHashSet(); - var current = await GetWallpapers(); - if (expectedLayout is null || expectedLayout.Where(x => connected.Contains(x.Monitor)) - .All(old => current.Any(x => x.Monitor == old.Monitor && Session.SamePath(x.Path, old.Path)))) - return; - } - } - catch (RpcException e) { last = e; } - await Task.Delay(200); - } - throw new TimeoutException("Lively did not become ready.", last); - } - - static void CheckVersion(string version) - { - if (version != SupportedVersion) - throw new NotSupportedException($"Managed wallpaper supports Lively {SupportedVersion}; found {version}. Existing installation was not changed."); - } - - public async Task GetOptions() - { - var value = await settings.GetSettingsAsync(new Empty(), deadline: Deadline); - return new(value.WallpaperArrangement, value.WebBrowser, value.DesktopAutoWallpaper, value.LockScreenAutoWallpaper); - } - - public async Task SetSessionOptions() - { - var value = await settings.GetSettingsAsync(new Empty(), deadline: Deadline); - if (value.WebBrowser == 1 && !value.DesktopAutoWallpaper && !value.LockScreenAutoWallpaper) return; - value.WebBrowser = 1; - // Keep the Windows wallpaper/slideshow underneath intact. Lively's - // screenshot-to-wallpaper features must not replace it during our session. - value.DesktopAutoWallpaper = false; - value.LockScreenAutoWallpaper = false; - await settings.SetSettingsAsync(value, deadline: Deadline); - } - - public async Task RestoreOptions(HostOptions previous, bool images = true) - { - var value = await settings.GetSettingsAsync(new Empty(), deadline: Deadline); - var before = value.Clone(); - if (value.WebBrowser == 1) value.WebBrowser = previous.Browser; - if (images && !value.DesktopAutoWallpaper) value.DesktopAutoWallpaper = previous.DesktopImage; - if (images && !value.LockScreenAutoWallpaper) value.LockScreenAutoWallpaper = previous.LockImage; - if (!value.Equals(before)) await settings.SetSettingsAsync(value, deadline: Deadline); - } - - public async Task> GetWallpapers() - { - using var call = desktop.GetWallpapers(new Empty(), deadline: Deadline); - var result = new List(); - while (await call.ResponseStream.MoveNext(CancellationToken.None)) - result.Add(new(call.ResponseStream.Current.Screen.DeviceId, call.ResponseStream.Current.LivelyInfoPath)); - return result; - } - - public async Task PrimaryMonitor() - { - var value = await displays.GetScreensAsync(new Empty(), deadline: Deadline); - return value.Screens_.Single(x => x.IsPrimary).DeviceId; - } - - public async Task WriteProject(string url) - { - var value = await settings.GetSettingsAsync(new Empty(), deadline: Deadline); - var directory = Path.Combine(value.WallpaperDir, "wallpapers", "amadeus-managed"); - Directory.CreateDirectory(directory); - var metadata = new { - AppVersion = SupportedVersion, Title = "Amadeus", Desc = "Managed by Amadeus", Author = "Amadeus", - Type = 3, FileName = url, IsAbsolutePath = true, Arguments = "", Contact = "", License = "AGPL-3.0" - }; - File.WriteAllText(Path.Combine(directory, "LivelyInfo.json"), JsonSerializer.Serialize(metadata)); - return directory; - } - - public async Task SetWallpaper(DesktopItem wallpaper, Action? requestSettled = null, - Action? requestDispatching = null) - { - var request = new SetWallpaperRequest { - LivelyInfoPath = wallpaper.Path, MonitorId = wallpaper.Monitor - }; - if (requestDispatching is null) await desktop.SetWallpaperAsync(request, deadline: Deadline); - else await WallpaperRequest.Send("Grpc_LIVELY:DESKTOPWALLPAPERSYSTEM" + Environment.UserName, request, requestDispatching); - // 只确认原生请求不再等待;下面的场景读回仍须独立成功。 - requestSettled?.Invoke(); - var until = DateTime.UtcNow.AddSeconds(30); - while (DateTime.UtcNow < until) - { - if ((await GetWallpapers()).Any(x => Session.SamePath(x.Path, wallpaper.Path) && x.Monitor == wallpaper.Monitor)) return; - await Task.Delay(200); - } - throw new TimeoutException("Lively did not confirm the wallpaper mount."); - } - - public async Task CloseProject(string project) => - await desktop.CloseWallpaperLibraryAsync(new CloseRequest { LivelyInfoPath = project }, deadline: Deadline); - - public async Task Screenshot(string file) - { - await desktop.TakeScreenshotAsync(new ScreenshotRequest { - MonitorId = await PrimaryMonitor(), SavePath = Path.GetFullPath(file) - }, deadline: Deadline); - if (!File.Exists(file)) throw new IOException("The Lively player did not produce a screenshot."); - } - - public async Task StopOwnedProcess(ProcessIdentity identity) - { - Process process; - try { process = Process.GetProcessById(identity.Id); } - catch (ArgumentException) { return; } - using (process) - { - if (process.StartTime.ToUniversalTime().Ticks != identity.Started) return; - var request = new AutomationCommandRequest(); - request.Args.AddRange(new[] { "app", "--shutdown", "true" }); - try { await commands.AutomationCommandAsync(request, deadline: Deadline); } - catch (RpcException) when (process.HasExited) { return; } - await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(15)); - } - } -} diff --git a/wallpaper/windows/Amadeus.Wallpaper/Program.cs b/wallpaper/windows/Amadeus.Wallpaper/Program.cs deleted file mode 100644 index 99befd42..00000000 --- a/wallpaper/windows/Amadeus.Wallpaper/Program.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Text.Json; -using Amadeus.Wallpaper; - -if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Windows wallpaper helper only."); -void Report(object value) -{ - try { Console.WriteLine(JsonSerializer.Serialize(value)); Console.Out.Flush(); } - catch (IOException) { /* The owner may have died; closed telemetry must not interrupt restoration. */ } -} -var state = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Amadeus", "wallpaper"); -var journal = Path.Combine(state, "session.json"); -var host = new LivelyHost(); -var session = new Session(host, journal); -try -{ - var command = args.FirstOrDefault() ?? "inspect"; - if (command == "screenshot" && args.Length == 2) - { - await host.Screenshot(args[1]); - Report(new { screenshot = Path.GetFullPath(args[1]) }); - return 0; - } - if (command == "inspect") - { - using var running = LivelyHost.Running(); - Report(new { executable = LivelyHost.FindExecutable(), running = running is not null, - recoveryPending = File.Exists(journal), - wallpapers = running is null ? null : await host.GetWallpapers(), - options = running is null ? null : await host.GetOptions() }); - return 0; - } - if (command == "prepare") - { - Report(new { prepared = LivelyHost.PrepareFirstRun(LivelyHost.DataDirectory) }); - return 0; - } - if (command is not ("run" or "recover")) throw new ArgumentException("Expected inspect, prepare, run or recover."); - if (command == "run" && (args.Length != 2 || !Uri.TryCreate(args[1], UriKind.Absolute, out var url) - || url.Scheme != "http" || url.Host != "127.0.0.1" || url.AbsolutePath != "/wallpaper/lively/index.html")) - throw new ArgumentException("Expected the local Amadeus wallpaper wrapper URL."); - Directory.CreateDirectory(state); - // Cross-checkout exclusion: an independent cleanup process may still own the desktop. - using var sessionLock = new FileStream(Path.Combine(state, "session.lock"), FileMode.OpenOrCreate, - FileAccess.ReadWrite, FileShare.None); - if (command == "recover" && !File.Exists(journal)) - { - await host.RecoverLaunchImages(); - Report(new { status = "clean" }); - return 0; - } - // 启动宿主前先验证 Pending 日志及其原请求接收进程。 - session.PrepareRecovery(); - await host.EnsureRunning(); - await session.Restore(host.StartedProcess); - if (command == "recover") { Report(new { status = "restored" }); return 0; } - // Recovery may have stopped the old owned host. - await host.EnsureRunning(); - var snapshot = new Snapshot(await host.WriteProject(args[1]), await host.PrimaryMonitor(), - await host.GetOptions(), await host.GetWallpapers(), host.StartedProcess); - await session.RunMounted(snapshot, async () => { - Report(new { status = "mounted" }); - // EOF also fires if Electron crashes. This process stays alive to restore. - using var lively = LivelyHost.Running() ?? throw new IOException("Lively exited after mounting."); - var input = Console.In.ReadLineAsync(); - var hostExited = lively.WaitForExitAsync(); - if (await Task.WhenAny(input, hostExited) == hostExited) - throw new IOException("Lively exited unexpectedly; the wallpaper recovery journal is retained."); - }); - Report(new { status = "restored" }); - return 0; -} -catch (Exception error) -{ - // 未接管清理的初始化错误才允许释放自有宿主。 - try { await session.ReleaseUnstartedHost(host.StartedProcess); } - catch (Exception cleanup) { Console.Error.WriteLine(cleanup.Message); } - Report(new { status = "error", error = error.Message, recoveryPending = File.Exists(journal) }); - return 1; -} diff --git a/wallpaper/windows/Amadeus.Wallpaper/Proto/commands.proto b/wallpaper/windows/Amadeus.Wallpaper/Proto/commands.proto deleted file mode 100644 index 1923b931..00000000 --- a/wallpaper/windows/Amadeus.Wallpaper/Proto/commands.proto +++ /dev/null @@ -1,6 +0,0 @@ -syntax = "proto3"; -package Lively.Grpc.Common.Proto.Commands; -option csharp_namespace = "Amadeus.Wallpaper.Protocol.Commands"; -import "google/protobuf/empty.proto"; -service CommandsService { rpc AutomationCommand(AutomationCommandRequest) returns (google.protobuf.Empty); } -message AutomationCommandRequest { repeated string args = 1; } diff --git a/wallpaper/windows/Amadeus.Wallpaper/Proto/desktop.proto b/wallpaper/windows/Amadeus.Wallpaper/Proto/desktop.proto deleted file mode 100644 index 08b4e162..00000000 --- a/wallpaper/windows/Amadeus.Wallpaper/Proto/desktop.proto +++ /dev/null @@ -1,19 +0,0 @@ -// Wire-compatible subset of Lively 2.2.1.0's desktop service. -// These declarations describe the external IPC boundary, not a wallpaper host. -syntax = "proto3"; -package Lively.Grpc.Common.Proto.Desktop; -option csharp_namespace = "Amadeus.Wallpaper.Protocol.Desktop"; -import "google/protobuf/empty.proto"; -service DesktopService { - rpc GetWallpapers(google.protobuf.Empty) returns (stream Wallpaper); - rpc SetWallpaper(SetWallpaperRequest) returns (google.protobuf.Empty); - rpc CloseWallpaperLibrary(CloseRequest) returns (google.protobuf.Empty); - rpc GetCoreStats(google.protobuf.Empty) returns (CoreStats); - rpc TakeScreenshot(ScreenshotRequest) returns (google.protobuf.Empty); -} -message Screen { string device_id = 1; bool is_primary = 6; } -message Wallpaper { string lively_info_path = 1; Screen screen = 2; } -message SetWallpaperRequest { string lively_info_path = 1; string monitor_id = 2; } -message CloseRequest { string lively_info_path = 2; } -message CoreStats { string base_directory = 1; string assembly_version = 2; bool is_core_initialized = 3; } -message ScreenshotRequest { string monitor_id = 1; string save_path = 2; } diff --git a/wallpaper/windows/Amadeus.Wallpaper/Proto/display.proto b/wallpaper/windows/Amadeus.Wallpaper/Proto/display.proto deleted file mode 100644 index 15289101..00000000 --- a/wallpaper/windows/Amadeus.Wallpaper/Proto/display.proto +++ /dev/null @@ -1,7 +0,0 @@ -syntax = "proto3"; -package Lively.Grpc.Common.Proto.Display; -option csharp_namespace = "Amadeus.Wallpaper.Protocol.Display"; -import "google/protobuf/empty.proto"; -service DisplayService { rpc GetScreens(google.protobuf.Empty) returns (Screens); } -message Screens { repeated Screen screens = 1; } -message Screen { string device_id = 1; bool is_primary = 6; } diff --git a/wallpaper/windows/Amadeus.Wallpaper/Proto/settings.proto b/wallpaper/windows/Amadeus.Wallpaper/Proto/settings.proto deleted file mode 100644 index 1b586879..00000000 --- a/wallpaper/windows/Amadeus.Wallpaper/Proto/settings.proto +++ /dev/null @@ -1,17 +0,0 @@ -syntax = "proto3"; -package Lively.Grpc.Common.Proto.Settings; -option csharp_namespace = "Amadeus.Wallpaper.Protocol.Settings"; -import "google/protobuf/empty.proto"; -service SettingsService { - rpc GetSettings(google.protobuf.Empty) returns (Settings); - rpc SetSettings(Settings) returns (google.protobuf.Empty); -} -// Protobuf retains ALL unknown fields when this message is sent back. Always -// read the current message before changing fields; never construct a replacement. -message Settings { - int32 wallpaper_arrangement = 16; - int32 web_browser = 28; - string wallpaper_dir = 33; - bool lock_screen_auto_wallpaper = 48; - bool desktop_auto_wallpaper = 49; -} diff --git a/wallpaper/windows/Amadeus.Wallpaper/Session.cs b/wallpaper/windows/Amadeus.Wallpaper/Session.cs deleted file mode 100644 index 4ff43dfe..00000000 --- a/wallpaper/windows/Amadeus.Wallpaper/Session.cs +++ /dev/null @@ -1,180 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Amadeus.Wallpaper; - -public record DesktopItem(string Monitor, string Path); -public record HostOptions(int Arrangement, int Browser, bool DesktopImage, bool LockImage); -public record ProcessIdentity(int Id, long Started); -public enum MountPhase { Preparing, Pending, Settled } -public record Snapshot(string Project, string Monitor, HostOptions Options, - List Wallpapers, ProcessIdentity? OwnedProcess, bool Mounted = false, - MountPhase? Phase = null, ProcessIdentity? RequestProcess = null); - -public interface IWallpaperHost -{ - Task GetOptions(); - Task> GetWallpapers(); - bool IsProcessRunning(ProcessIdentity process); - // 原生请求正常返回后、场景确认前回调;请求异常结束不得回调。 - Task SetWallpaper(DesktopItem wallpaper, Action? requestSettled = null, - Action? requestDispatching = null); - Task CloseProject(string project); - Task SetSessionOptions(); - Task RestoreOptions(HostOptions previous, bool images = true); - Task StopOwnedProcess(ProcessIdentity process); -} - -// 单个会话持有持久化的原状态;任何桌面变更之前先保存日志,恢复核验通过后才删除。 -public sealed class Session(IWallpaperHost host, string journal) -{ - static readonly JsonSerializerOptions Json = new() { - WriteIndented = true, Converters = { new JsonStringEnumConverter(allowIntegerValues: false) } - }; - ProcessIdentity? managedCleanupProcess; - public static bool SamePath(string left, string right) => - string.Equals(left, right, StringComparison.OrdinalIgnoreCase); - - public Task ReleaseUnstartedHost(ProcessIdentity? process) => - process is not null && process != managedCleanupProcess && !File.Exists(journal) - ? host.StopOwnedProcess(process) : Task.CompletedTask; - - public async Task Mount(Snapshot snapshot) - { - if (File.Exists(journal)) throw new InvalidOperationException("Recover the previous wallpaper session first."); - snapshot = snapshot with { Mounted = false, Phase = MountPhase.Preparing, RequestProcess = null }; - Save(snapshot); - managedCleanupProcess = snapshot.OwnedProcess; - try - { - await host.SetSessionOptions(); - await host.SetWallpaper(new(snapshot.Monitor, snapshot.Project), () => { - // 正常 RPC 返回只排除本请求仍在加载,不代表挂载成功。 - var settled = snapshot with { Phase = MountPhase.Settled }; - Save(settled); - snapshot = settled; - }, process => { - // 实际管道已连接、请求字节尚未发出;先持久保存接收进程身份。 - var pending = snapshot with { Phase = MountPhase.Pending, RequestProcess = process }; - Save(pending); - snapshot = pending; - }); - Save(snapshot with { Mounted = true, Phase = MountPhase.Settled }); - } - catch (Exception primary) - { - // 未结算的请求可能迟到;关闭当前项目不能取消它,保留日志和原错。 - if (snapshot.Phase == MountPhase.Pending) throw; - try { await Restore(); } - catch (Exception cleanup) { throw new AggregateException("Wallpaper mount and rollback failed.", primary, cleanup); } - throw; - } - } - - public async Task RunMounted(Snapshot snapshot, Func body) - { - // Mount 负责自己的失败回滚;只有挂载成功后才进入本轮 finally。 - await Mount(snapshot); - Exception? primary = null; - try { await body(); } - catch (Exception error) { primary = error; throw; } - finally - { - try { await Restore(); } - catch (Exception cleanup) when (primary is not null) - { - throw new AggregateException("Wallpaper body and cleanup failed.", primary, cleanup); - } - } - } - - public void PrepareRecovery() - { - if (!File.Exists(journal)) return; - var snapshot = Read(); - if (snapshot.Phase != MountPhase.Pending) return; - if (snapshot.RequestProcess is null) - throw new InvalidDataException("Invalid wallpaper recovery journal: Pending requires a request receiver identity."); - if (host.IsProcessRunning(snapshot.RequestProcess)) - throw new InvalidOperationException("Wallpaper mount request may still complete in its original Lively process. Fully exit Lively, then reopen managed wallpaper or run recover."); - // 原请求不可能再迟到;这只允许尝试恢复,仍须核验原场景。 - Save(snapshot with { Mounted = false, Phase = MountPhase.Settled }); - } - - public async Task Restore(ProcessIdentity? restartedProcess = null) - { - PrepareRecovery(); - if (!File.Exists(journal)) return; - var snapshot = Read(); - managedCleanupProcess = restartedProcess ?? snapshot.OwnedProcess; - if (snapshot.Phase == MountPhase.Preparing) - { - // 只可能部分写入设置,尚未发送挂载请求;不重设或关闭任何壁纸。 - await host.RestoreOptions(snapshot.Options); - var preparedOptions = await host.GetOptions(); - var preparedWallpapers = await host.GetWallpapers(); - var changed = preparedOptions.Arrangement != snapshot.Options.Arrangement || preparedWallpapers.Any(x => - !snapshot.Wallpapers.Any(old => old.Monitor == x.Monitor && SamePath(old.Path, x.Path))); - if (snapshot.OwnedProcess is not null && !changed) - await host.StopOwnedProcess(restartedProcess ?? snapshot.OwnedProcess); - File.Delete(journal); - return; - } - var current = await host.GetWallpapers(); - var options = await host.GetOptions(); - var ours = current.Where(x => SamePath(x.Path, snapshot.Project)).ToList(); - // A user's new layout/selection takes precedence over the old snapshot. - var layoutUnchanged = options.Arrangement == snapshot.Options.Arrangement; - var affected = snapshot.Options.Arrangement == 0 - ? new HashSet([snapshot.Monitor]) - : snapshot.Wallpapers.Select(x => x.Monitor).Append(snapshot.Monitor).ToHashSet(); - var restorable = affected.Where(monitor => - ours.Any(x => x.Monitor == monitor) || - ((!snapshot.Mounted || restartedProcess is not null) && current.All(x => x.Monitor != monitor))) - .ToHashSet(); - // Span/duplicate is one logical wallpaper, even if RPC reports only one screen. - if (snapshot.Options.Arrangement != 0 && ours.Count > 0) restorable = affected; - if (ours.Count > 0 || (snapshot.Phase == MountPhase.Settled && !snapshot.Mounted)) - { - // A failed restore can be resumed after our layer has been removed. - Save(snapshot with { Mounted = false }); - await host.CloseProject(snapshot.Project); - } - if (layoutUnchanged) - { - // Recreate the user's scene with its original browser, while keeping - // screenshot-to-system-wallpaper disabled until it has loaded. - await host.RestoreOptions(snapshot.Options, images: false); - foreach (var wallpaper in snapshot.Wallpapers.Where(x => restorable.Contains(x.Monitor))) - await host.SetWallpaper(wallpaper); - } - await host.RestoreOptions(snapshot.Options); - var after = await host.GetWallpapers(); - if (after.Any(x => SamePath(x.Path, snapshot.Project) && !snapshot.Wallpapers.Any(old => - old.Monitor == x.Monitor && SamePath(old.Path, x.Path))) || (layoutUnchanged && - snapshot.Wallpapers.Where(x => restorable.Contains(x.Monitor)).Any(old => - !after.Any(x => x.Monitor == old.Monitor && SamePath(x.Path, old.Path))))) - throw new IOException("Wallpaper restoration was not confirmed; recovery journal retained."); - var userChanged = !layoutUnchanged || after.Any(x => - !snapshot.Wallpapers.Any(old => old.Monitor == x.Monitor && SamePath(old.Path, x.Path))); - // Never stop a host that the user had running, or one now showing their new choice. - if (snapshot.OwnedProcess is not null && !userChanged) - await host.StopOwnedProcess(restartedProcess ?? snapshot.OwnedProcess); - File.Delete(journal); - } - - Snapshot Read() => JsonSerializer.Deserialize(File.ReadAllText(journal), Json) - ?? throw new InvalidDataException("Invalid wallpaper recovery journal."); - - void Save(Snapshot snapshot) - { - Directory.CreateDirectory(Path.GetDirectoryName(journal)!); - var temporary = journal + ".tmp"; - using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None)) - { - JsonSerializer.Serialize(stream, snapshot, Json); - stream.Flush(true); - } - File.Move(temporary, journal, true); - } -} diff --git a/wallpaper/windows/Amadeus.Wallpaper/WallpaperRequest.cs b/wallpaper/windows/Amadeus.Wallpaper/WallpaperRequest.cs deleted file mode 100644 index 676da740..00000000 --- a/wallpaper/windows/Amadeus.Wallpaper/WallpaperRequest.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.ComponentModel; -using System.Diagnostics; -using System.IO.Pipes; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.ExceptionServices; -using System.Runtime.InteropServices; -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; -using Grpc.Core; -using GrpcDotNetNamedPipes; -using Microsoft.Win32.SafeHandles; -using Amadeus.Wallpaper.Protocol.Desktop; - -[assembly: InternalsVisibleTo("Amadeus.Wallpaper.Tests")] - -namespace Amadeus.Wallpaper; - -internal static class WallpaperRequest -{ - [DllImport("kernel32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - static extern bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out uint processId); - - static ProcessIdentity Identify(NamedPipeClientStream pipe) - { - if (!GetNamedPipeServerProcessId(pipe.SafePipeHandle, out var id)) - throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not identify the wallpaper request receiver."); - using var process = Process.GetProcessById(checked((int)id)); - return new(process.Id, process.StartTime.ToUniversalTime().Ticks); - } - - static void BindDefault(NamedPipeChannel channel, Action capture) => - Bind(channel, capture, typeof(NamedPipeChannel).GetProperty("PipeCallback", BindingFlags.Instance | BindingFlags.NonPublic)); - - internal static void Bind(NamedPipeChannel channel, Action capture, PropertyInfo? callback) - { - // 锁定 GrpcDotNetNamedPipes 3.1.0 的唯一内部适配点;缺失时必须阻止派发。 - if (callback?.PropertyType != typeof(Action) || callback.SetMethod is null) - throw new NotSupportedException("GrpcDotNetNamedPipes no longer exposes the required request pipe binding; wallpaper dispatch was blocked."); - callback.SetValue(channel, capture); - } - - internal static async Task Send(string pipeName, SetWallpaperRequest request, Action dispatching, - Func? identify = null, - Action>? bind = null, DateTime? deadline = null) - { - var channel = new NamedPipeChannel(".", pipeName); - NamedPipeClientStream? pipe = null; - (bind ?? BindDefault)(channel, connected => pipe = connected); - var dispatchGate = new object(); - var ended = false; - Exception? boundaryError = null; - var marshaller = Marshallers.Create(value => { - lock (dispatchGate) - { - if (ended) throw new InvalidOperationException("Wallpaper Set invocation ended before dispatch; no request was sent."); - try - { - // 该版本在同一连接 Connect 完成后才序列化,随后才写入请求;不得另开探测连接。 - var payload = value.ToByteArray(); - var identity = (identify ?? Identify)(pipe ?? throw new IOException("Wallpaper request pipe was not captured.")); - dispatching(identity); - return payload; - } - catch (Exception error) { boundaryError = error; throw; } - } - }, SetWallpaperRequest.Parser.ParseFrom); - var method = new Method(MethodType.Unary, - DesktopService.Descriptor.FullName, "SetWallpaper", marshaller, - Marshallers.Create(value => value.ToByteArray(), Empty.Parser.ParseFrom)); - try - { - try - { - using var call = channel.AsyncUnaryCall(method, null, - new CallOptions(deadline: deadline ?? DateTime.UtcNow.AddSeconds(15)), request); - await call.ResponseAsync; - } - finally - { - // ResponseAsync 超时不等待后台连接;关闭派发门,同时等已进入的保存边界结束。 - lock (dispatchGate) ended = true; - } - } - catch (Exception) when (boundaryError is not null) - { - // 传输库会包装部分 IO 异常;保留发送前身份或持久化失败的原始原因。 - ExceptionDispatchInfo.Capture(boundaryError).Throw(); - } - } -} diff --git a/wallpaper/windows/Amadeus.Wallpaper/packages.lock.json b/wallpaper/windows/Amadeus.Wallpaper/packages.lock.json deleted file mode 100644 index 351e993f..00000000 --- a/wallpaper/windows/Amadeus.Wallpaper/packages.lock.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "version": 1, - "dependencies": { - "net8.0-windows7.0": { - "Google.Protobuf": { - "type": "Direct", - "requested": "[3.32.0, )", - "resolved": "3.32.0", - "contentHash": "fsKxV5bhcvXmZi+cUo5+IxzRMBHwHeFO8G5utNa9f+Mu37kmfy8JcUVvWPt4cX7EuQWAjjHUjZqVl7nGSTRHRg==" - }, - "Grpc.Tools": { - "type": "Direct", - "requested": "[2.72.0, )", - "resolved": "2.72.0", - "contentHash": "BCiuQ03EYjLHCo9hqZmY5barsz5vvcz/+/ICt5wCbukaePHZmMPDGelKlkxWx3q+f5xOMNHa9zXQ2N6rQZ4B+w==" - }, - "GrpcDotNetNamedPipes": { - "type": "Direct", - "requested": "[3.1.0, )", - "resolved": "3.1.0", - "contentHash": "IEbpody7yYtL+oTNGRJspbK+Xne2fWzhiAOmJxJ8zoS3knFv0jDxv0WgJxO2cOCpoItn1cv4X4dloxSbFPB3eA==", - "dependencies": { - "Google.Protobuf": "3.29.3", - "Grpc.Core.Api": "2.67.0", - "System.Memory": "4.6.0" - } - }, - "Grpc.Core.Api": { - "type": "Transitive", - "resolved": "2.67.0", - "contentHash": "cL1/2f8kc8lsAGNdfCU25deedXVehhLA6GXKLLN4hAWx16XN7BmjYn3gFU+FBpir5yJynvDTHEypr3Tl0j7x/Q==" - }, - "System.Memory": { - "type": "Transitive", - "resolved": "4.6.0", - "contentHash": "OEkbBQoklHngJ8UD8ez2AERSk2g+/qpAaSWWCBFbpH727HxDq5ydVkuncBaKcKfwRqXGWx64dS6G1SUScMsitg==" - } - }, - "net8.0-windows7.0/win-x64": {} - } -} \ No newline at end of file diff --git a/wallpaper/windows/notices/DEPENDENCIES.md b/wallpaper/windows/notices/DEPENDENCIES.md deleted file mode 100644 index 807c8007..00000000 --- a/wallpaper/windows/notices/DEPENDENCIES.md +++ /dev/null @@ -1,26 +0,0 @@ -# Windows wallpaper helper dependencies - -The helper is Amadeus first-party code (AGPL-3.0). Its small protocol definitions -describe the Lively v2.2.1.0 external wire interface; Lively binaries are not -included. Windows Package Manager obtains unmodified Lively directly from its -publisher; Lively retains its own GPL-3.0 license and installer notices. - -Runtime packages (exact dependency graph and integrity hashes in -`Amadeus.Wallpaper/packages.lock.json`): - -- Google.Protobuf 3.32.0: Copyright 2008 Google Inc.; BSD-3-Clause. - https://github.com/protocolbuffers/protobuf/tree/4fbd1111a292d04746c732573025e3251de0bb9c -- Grpc.Core.Api 2.67.0: Copyright 2019 The gRPC Authors; Apache-2.0. - https://github.com/grpc/grpc-dotnet/tree/7c43ddb5d68008782dc0dba2d0feaa3ed91a9fb2 -- GrpcDotNetNamedPipes 3.1.0: Ben Olden-Cooligan; package copyright - Copyright 2020 Google LLC; Apache-2.0. - https://github.com/cyanfish/grpc-dotnet-namedpipes/tree/da307b551a9d56b62f5780e91a2d95a699f32430 -- System.Memory 4.6.0: Copyright Microsoft Corporation; MIT. - https://github.com/dotnet/maintenance-packages/tree/d0c2a5a83211e271826172a6b0510c25a52dbd53 -- Microsoft.NETCore.App.Runtime.win-x64 8.0.31: .NET Foundation and - Contributors; MIT and the accompanying .NET third-party notices. - -Grpc.Tools 2.72.0 is a build-time dependency and is not shipped. - -These notice files are copied into the published helper. Keep the .NET notices -in sync with RuntimeFrameworkVersion when updating its bundled runtime. diff --git a/wallpaper/windows/notices/dotnet-MIT.txt b/wallpaper/windows/notices/dotnet-MIT.txt deleted file mode 100644 index 984713a4..00000000 --- a/wallpaper/windows/notices/dotnet-MIT.txt +++ /dev/null @@ -1,23 +0,0 @@ -The MIT License (MIT) - -Copyright (c) .NET Foundation and Contributors - -All rights reserved. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/wallpaper/windows/notices/dotnet-THIRD-PARTY-NOTICES.txt b/wallpaper/windows/notices/dotnet-THIRD-PARTY-NOTICES.txt deleted file mode 100644 index 9b4e777c..00000000 --- a/wallpaper/windows/notices/dotnet-THIRD-PARTY-NOTICES.txt +++ /dev/null @@ -1,1272 +0,0 @@ -.NET Runtime uses third-party libraries or other resources that may be -distributed under licenses different than the .NET Runtime software. - -In the event that we accidentally failed to list a required notice, please -bring it to our attention. Post an issue or email us: - - dotnet@microsoft.com - -The attached notices are provided for information only. - -License notice for ASP.NET -------------------------------- - -Copyright (c) .NET Foundation. All rights reserved. -Licensed under the Apache License, Version 2.0. - -Available at -https://github.com/dotnet/aspnetcore/blob/main/LICENSE.txt - -License notice for Slicing-by-8 -------------------------------- - -http://sourceforge.net/projects/slicing-by-8/ - -Copyright (c) 2004-2006 Intel Corporation - All Rights Reserved - - -This software program is licensed subject to the BSD License, available at -http://www.opensource.org/licenses/bsd-license.html. - - -License notice for Unicode data -------------------------------- - -https://www.unicode.org/license.html - -Copyright © 1991-2022 Unicode, Inc. All rights reserved. -Distributed under the Terms of Use in https://www.unicode.org/copyright.html. - -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Unicode data files and any associated documentation -(the "Data Files") or Unicode software and any associated documentation -(the "Software") to deal in the Data Files or Software -without restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, and/or sell copies of -the Data Files or Software, and to permit persons to whom the Data Files -or Software are furnished to do so, provided that either -(a) this copyright and permission notice appear with all copies -of the Data Files or Software, or -(b) this copyright and permission notice appear in associated -Documentation. - -THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE -WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT OF THIRD PARTY RIGHTS. -IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS -NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL -DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, -DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER -TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THE DATA FILES OR SOFTWARE. - -Except as contained in this notice, the name of a copyright holder -shall not be used in advertising or otherwise to promote the sale, -use or other dealings in these Data Files or Software without prior -written authorization of the copyright holder. - -License notice for Zlib ------------------------ - -https://github.com/madler/zlib -https://zlib.net/zlib_license.html - -/* zlib.h -- interface of the 'zlib' general purpose compression library - version 1.3.1, January 22nd, 2024 - - Copyright (C) 1995-2022 Jean-loup Gailly and Mark Adler - - This software is provided 'as-is', without any express or implied - warranty. In no event will the authors be held liable for any damages - arising from the use of this software. - - Permission is granted to anyone to use this software for any purpose, - including commercial applications, and to alter it and redistribute it - freely, subject to the following restrictions: - - 1. The origin of this software must not be misrepresented; you must not - claim that you wrote the original software. If you use this software - in a product, an acknowledgment in the product documentation would be - appreciated but is not required. - 2. Altered source versions must be plainly marked as such, and must not be - misrepresented as being the original software. - 3. This notice may not be removed or altered from any source distribution. - - Jean-loup Gailly Mark Adler - jloup@gzip.org madler@alumni.caltech.edu - -*/ - -License notice for Mono -------------------------------- - -http://www.mono-project.com/docs/about-mono/ - -Copyright (c) .NET Foundation Contributors - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the Software), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -License notice for International Organization for Standardization ------------------------------------------------------------------ - -Portions (C) International Organization for Standardization 1986: - Permission to copy in any form is granted for use with - conforming SGML systems and applications as defined in - ISO 8879, provided this notice is included in all copies. - -License notice for Intel ------------------------- - -"Copyright (c) 2004-2006 Intel Corporation - All Rights Reserved - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, -this list of conditions and the following disclaimer in the documentation -and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -License notice for Xamarin and Novell -------------------------------------- - -Copyright (c) 2015 Xamarin, Inc (http://www.xamarin.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -Copyright (c) 2011 Novell, Inc (http://www.novell.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -Third party notice for W3C --------------------------- - -"W3C SOFTWARE AND DOCUMENT NOTICE AND LICENSE -Status: This license takes effect 13 May, 2015. -This work is being provided by the copyright holders under the following license. -License -By obtaining and/or copying this work, you (the licensee) agree that you have read, understood, and will comply with the following terms and conditions. -Permission to copy, modify, and distribute this work, with or without modification, for any purpose and without fee or royalty is hereby granted, provided that you include the following on ALL copies of the work or portions thereof, including modifications: -The full text of this NOTICE in a location viewable to users of the redistributed or derivative work. -Any pre-existing intellectual property disclaimers, notices, or terms and conditions. If none exist, the W3C Software and Document Short Notice should be included. -Notice of any changes or modifications, through a copyright statement on the new code or document such as "This software or document includes material copied from or derived from [title and URI of the W3C document]. Copyright © [YEAR] W3C® (MIT, ERCIM, Keio, Beihang)." -Disclaimers -THIS WORK IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE OR DOCUMENT WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS. -COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR DOCUMENT. -The name and trademarks of copyright holders may NOT be used in advertising or publicity pertaining to the work without specific, written prior permission. Title to copyright in this work will at all times remain with copyright holders." - -License notice for Bit Twiddling Hacks --------------------------------------- - -Bit Twiddling Hacks - -By Sean Eron Anderson -seander@cs.stanford.edu - -Individually, the code snippets here are in the public domain (unless otherwise -noted) — feel free to use them however you please. The aggregate collection and -descriptions are © 1997-2005 Sean Eron Anderson. The code and descriptions are -distributed in the hope that they will be useful, but WITHOUT ANY WARRANTY and -without even the implied warranty of merchantability or fitness for a particular -purpose. - -License notice for Brotli --------------------------------------- - -Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -compress_fragment.c: -Copyright (c) 2011, Google Inc. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -""AS IS"" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -decode_fuzzer.c: -Copyright (c) 2015 The Chromium Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -""AS IS"" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." - -License notice for Json.NET -------------------------------- - -https://github.com/JamesNK/Newtonsoft.Json/blob/master/LICENSE.md - -The MIT License (MIT) - -Copyright (c) 2007 James Newton-King - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -License notice for vectorized base64 encoding / decoding --------------------------------------------------------- - -Copyright (c) 2005-2007, Nick Galbreath -Copyright (c) 2013-2017, Alfred Klomp -Copyright (c) 2015-2017, Wojciech Mula -Copyright (c) 2016-2017, Matthieu Darbois -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - -- Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - -- Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS -IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED -TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A -PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED -TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -License notice for vectorized hex parsing --------------------------------------------------------- - -Copyright (c) 2022, Geoff Langdale -Copyright (c) 2022, Wojciech Mula -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - -- Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - -- Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS -IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED -TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A -PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED -TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -License notice for RFC 3492 ---------------------------- - -The punycode implementation is based on the sample code in RFC 3492 - -Copyright (C) The Internet Society (2003). All Rights Reserved. - -This document and translations of it may be copied and furnished to -others, and derivative works that comment on or otherwise explain it -or assist in its implementation may be prepared, copied, published -and distributed, in whole or in part, without restriction of any -kind, provided that the above copyright notice and this paragraph are -included on all such copies and derivative works. However, this -document itself may not be modified in any way, such as by removing -the copyright notice or references to the Internet Society or other -Internet organizations, except as needed for the purpose of -developing Internet standards in which case the procedures for -copyrights defined in the Internet Standards process must be -followed, or as required to translate it into languages other than -English. - -The limited permissions granted above are perpetual and will not be -revoked by the Internet Society or its successors or assigns. - -This document and the information contained herein is provided on an -"AS IS" basis and THE INTERNET SOCIETY AND THE INTERNET ENGINEERING -TASK FORCE DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING -BUT NOT LIMITED TO ANY WARRANTY THAT THE USE OF THE INFORMATION -HEREIN WILL NOT INFRINGE ANY RIGHTS OR ANY IMPLIED WARRANTIES OF -MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. - -Copyright(C) The Internet Society 1997. All Rights Reserved. - -This document and translations of it may be copied and furnished to others, -and derivative works that comment on or otherwise explain it or assist in -its implementation may be prepared, copied, published and distributed, in -whole or in part, without restriction of any kind, provided that the above -copyright notice and this paragraph are included on all such copies and -derivative works.However, this document itself may not be modified in any -way, such as by removing the copyright notice or references to the Internet -Society or other Internet organizations, except as needed for the purpose of -developing Internet standards in which case the procedures for copyrights -defined in the Internet Standards process must be followed, or as required -to translate it into languages other than English. - -The limited permissions granted above are perpetual and will not be revoked -by the Internet Society or its successors or assigns. - -This document and the information contained herein is provided on an "AS IS" -basis and THE INTERNET SOCIETY AND THE INTERNET ENGINEERING TASK FORCE -DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO -ANY WARRANTY THAT THE USE OF THE INFORMATION HEREIN WILL NOT INFRINGE ANY -RIGHTS OR ANY IMPLIED WARRANTIES OF MERCHANTABILITY OR FITNESS FOR A -PARTICULAR PURPOSE. - -License notice for Algorithm from RFC 4122 - -A Universally Unique IDentifier (UUID) URN Namespace ----------------------------------------------------- - -Copyright (c) 1990- 1993, 1996 Open Software Foundation, Inc. -Copyright (c) 1989 by Hewlett-Packard Company, Palo Alto, Ca. & -Digital Equipment Corporation, Maynard, Mass. -Copyright (c) 1998 Microsoft. -To anyone who acknowledges that this file is provided "AS IS" -without any express or implied warranty: permission to use, copy, -modify, and distribute this file for any purpose is hereby -granted without fee, provided that the above copyright notices and -this notice appears in all source code copies, and that none of -the names of Open Software Foundation, Inc., Hewlett-Packard -Company, Microsoft, or Digital Equipment Corporation be used in -advertising or publicity pertaining to distribution of the software -without specific, written prior permission. Neither Open Software -Foundation, Inc., Hewlett-Packard Company, Microsoft, nor Digital -Equipment Corporation makes any representations about the -suitability of this software for any purpose." - -License notice for The LLVM Compiler Infrastructure (Legacy License) --------------------------------------------------------------------- - -Developed by: - - LLVM Team - - University of Illinois at Urbana-Champaign - - http://llvm.org - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal with -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimers. - - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimers in the - documentation and/or other materials provided with the distribution. - - * Neither the names of the LLVM Team, University of Illinois at - Urbana-Champaign, nor the names of its contributors may be used to - endorse or promote products derived from this Software without specific - prior written permission. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE -SOFTWARE. - -License notice for Bob Jenkins ------------------------------- - -By Bob Jenkins, 1996. bob_jenkins@burtleburtle.net. You may use this -code any way you wish, private, educational, or commercial. It's free. - -License notice for Greg Parker ------------------------------- - -Greg Parker gparker@cs.stanford.edu December 2000 -This code is in the public domain and may be copied or modified without -permission. - -License notice for libunwind based code ----------------------------------------- - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -License notice for Printing Floating-Point Numbers (Dragon4) ------------------------------------------------------------- - -/****************************************************************************** - Copyright (c) 2014 Ryan Juckett - http://www.ryanjuckett.com/ - - This software is provided 'as-is', without any express or implied - warranty. In no event will the authors be held liable for any damages - arising from the use of this software. - - Permission is granted to anyone to use this software for any purpose, - including commercial applications, and to alter it and redistribute it - freely, subject to the following restrictions: - - 1. The origin of this software must not be misrepresented; you must not - claim that you wrote the original software. If you use this software - in a product, an acknowledgment in the product documentation would be - appreciated but is not required. - - 2. Altered source versions must be plainly marked as such, and must not be - misrepresented as being the original software. - - 3. This notice may not be removed or altered from any source - distribution. -******************************************************************************/ - -License notice for Printing Floating-point Numbers (Grisu3) ------------------------------------------------------------ - -Copyright 2012 the V8 project authors. All rights reserved. -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials provided - with the distribution. - * Neither the name of Google Inc. nor the names of its - contributors may be used to endorse or promote products derived - from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -License notice for xxHash -------------------------- - -xxHash - Extremely Fast Hash algorithm -Header File -Copyright (C) 2012-2021 Yann Collet - -BSD 2-Clause License (https://www.opensource.org/licenses/bsd-license.php) - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following disclaimer - in the documentation and/or other materials provided with the - distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -You can contact the author at: - - xxHash homepage: https://www.xxhash.com - - xxHash source repository: https://github.com/Cyan4973/xxHash - -License notice for Berkeley SoftFloat Release 3e ------------------------------------------------- - -https://github.com/ucb-bar/berkeley-softfloat-3 -https://github.com/ucb-bar/berkeley-softfloat-3/blob/master/COPYING.txt - -License for Berkeley SoftFloat Release 3e - -John R. Hauser -2018 January 20 - -The following applies to the whole of SoftFloat Release 3e as well as to -each source file individually. - -Copyright 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018 The Regents of the -University of California. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, - this list of conditions, and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions, and the following disclaimer in the - documentation and/or other materials provided with the distribution. - - 3. Neither the name of the University nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS "AS IS", AND ANY -EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, ARE -DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY -DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF -THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -License notice for xoshiro RNGs --------------------------------- - -Written in 2018 by David Blackman and Sebastiano Vigna (vigna@acm.org) - -To the extent possible under law, the author has dedicated all copyright -and related and neighboring rights to this software to the public domain -worldwide. This software is distributed without any warranty. - -See . - -License for fastmod (https://github.com/lemire/fastmod), ibm-fpgen (https://github.com/nigeltao/parse-number-fxx-test-data) and fastrange (https://github.com/lemire/fastrange) --------------------------------------- - - Copyright 2018 Daniel Lemire - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - -License for sse4-strstr (https://github.com/WojciechMula/sse4-strstr) --------------------------------------- - - Copyright (c) 2008-2016, Wojciech Mula - All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are - met: - - 1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS - IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A - PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED - TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR - PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -License notice for The C++ REST SDK ------------------------------------ - -C++ REST SDK - -The MIT License (MIT) - -Copyright (c) Microsoft Corporation - -All rights reserved. - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -License notice for MessagePack-CSharp -------------------------------------- - -MessagePack for C# - -MIT License - -Copyright (c) 2017 Yoshifumi Kawai - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -License notice for lz4net -------------------------------------- - -lz4net - -Copyright (c) 2013-2017, Milosz Krajewski - -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - -Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - -Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -License notice for Nerdbank.Streams ------------------------------------ - -The MIT License (MIT) - -Copyright (c) Andrew Arnott - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -License notice for RapidJSON ----------------------------- - -Tencent is pleased to support the open source community by making RapidJSON available. - -Copyright (C) 2015 THL A29 Limited, a Tencent company, and Milo Yip. All rights reserved. - -Licensed under the MIT License (the "License"); you may not use this file except -in compliance with the License. You may obtain a copy of the License at - -http://opensource.org/licenses/MIT - -Unless required by applicable law or agreed to in writing, software distributed -under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR -CONDITIONS OF ANY KIND, either express or implied. See the License for the -specific language governing permissions and limitations under the License. - -License notice for DirectX Math Library ---------------------------------------- - -https://github.com/microsoft/DirectXMath/blob/master/LICENSE - - The MIT License (MIT) - -Copyright (c) 2011-2020 Microsoft Corp - -Permission is hereby granted, free of charge, to any person obtaining a copy of this -software and associated documentation files (the "Software"), to deal in the Software -without restriction, including without limitation the rights to use, copy, modify, -merge, publish, distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice shall be included in all copies -or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, -INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF -CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE -OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -License notice for ldap4net ---------------------------- - -The MIT License (MIT) - -Copyright (c) 2018 Alexander Chermyanin - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -License notice for vectorized sorting code ------------------------------------------- - -MIT License - -Copyright (c) 2020 Dan Shechter - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -License notice for musl ------------------------ - -musl as a whole is licensed under the following standard MIT license: - -Copyright © 2005-2020 Rich Felker, et al. - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -License notice for "Faster Unsigned Division by Constants" ------------------------------- - -Reference implementations of computing and using the "magic number" approach to dividing -by constants, including codegen instructions. The unsigned division incorporates the -"round down" optimization per ridiculous_fish. - -This is free and unencumbered software. Any copyright is dedicated to the Public Domain. - - -License notice for mimalloc ------------------------------------ - -MIT License - -Copyright (c) 2019 Microsoft Corporation, Daan Leijen - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -License notice for The LLVM Project ------------------------------------ - -Copyright 2019 LLVM Project - -Licensed under the Apache License, Version 2.0 (the "License") with LLVM Exceptions; -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -https://llvm.org/LICENSE.txt - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - -License notice for Apple header files -------------------------------------- - -Copyright (c) 1980, 1986, 1993 - The Regents of the University of California. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: -1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. -3. All advertising materials mentioning features or use of this software - must display the following acknowledgement: - This product includes software developed by the University of - California, Berkeley and its contributors. -4. Neither the name of the University nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS -OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) -HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT -LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY -OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF -SUCH DAMAGE. - -License notice for JavaScript queues -------------------------------------- - -CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER. - -Statement of Purpose -The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). -Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. -For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. - -1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: -the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; -moral rights retained by the original author(s) and/or performer(s); -publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; -rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; -rights protecting the extraction, dissemination, use and reuse of data in a Work; -database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and -other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. -2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. -3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. -4. Limitations and Disclaimers. -a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. -b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. -c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. -d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. - - -License notice for FastFloat algorithm -------------------------------------- -MIT License -Copyright (c) 2021 csFastFloat authors -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - -License notice for MsQuic --------------------------------------- - -Copyright (c) Microsoft Corporation. -Licensed under the MIT License. - -Available at -https://github.com/microsoft/msquic/blob/main/LICENSE - -License notice for m-ou-se/floatconv -------------------------------- - -Copyright (c) 2020 Mara Bos -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -License notice for code from The Practice of Programming -------------------------------- - -Copyright (C) 1999 Lucent Technologies - -Excerpted from 'The Practice of Programming -by Brian W. Kernighan and Rob Pike - -You may use this code for any purpose, as long as you leave the copyright notice and book citation attached. - -Notice for Euclidean Affine Functions and Applications to Calendar -Algorithms -------------------------------- - -Aspects of Date/Time processing based on algorithm described in "Euclidean Affine Functions and Applications to Calendar -Algorithms", Cassio Neri and Lorenz Schneider. https://arxiv.org/pdf/2102.06959.pdf - -License notice for amd/aocl-libm-ose -------------------------------- - -Copyright (C) 2008-2020 Advanced Micro Devices, Inc. All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: -1. Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. -3. Neither the name of the copyright holder nor the names of its contributors - may be used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. -IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, -INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, -BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, -OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, -WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. - -License notice for fmtlib/fmt -------------------------------- - -Formatting library for C++ - -Copyright (c) 2012 - present, Victor Zverovich - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -License for Jb Evain ---------------------- - -Copyright (c) 2006 Jb Evain (jbevain@gmail.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - ---- Optional exception to the license --- - -As an exception, if, as a result of your compiling your source code, portions -of this Software are embedded into a machine-executable object form of such -source code, you may redistribute such embedded portions in such object form -without including the above copyright and permission notices. - - -License for MurmurHash3 --------------------------------------- - -https://github.com/aappleby/smhasher/blob/master/src/MurmurHash3.cpp - -MurmurHash3 was written by Austin Appleby, and is placed in the public -domain. The author hereby disclaims copyright to this source - -License for Fast CRC Computation --------------------------------------- - -https://github.com/intel/isa-l/blob/33a2d9484595c2d6516c920ce39a694c144ddf69/crc/crc32_ieee_by4.asm -https://github.com/intel/isa-l/blob/33a2d9484595c2d6516c920ce39a694c144ddf69/crc/crc64_ecma_norm_by8.asm - -Copyright(c) 2011-2015 Intel Corporation All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: - * Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in - the documentation and/or other materials provided with the - distribution. - * Neither the name of Intel Corporation nor the names of its - contributors may be used to endorse or promote products derived - from this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -License for C# Implementation of Fast CRC Computation ------------------------------------------------------ - -https://github.com/SixLabors/ImageSharp/blob/f4f689ce67ecbcc35cebddba5aacb603e6d1068a/src/ImageSharp/Formats/Png/Zlib/Crc32.cs - -Copyright (c) Six Labors. -Licensed under the Apache License, Version 2.0. - -Available at -https://github.com/SixLabors/ImageSharp/blob/f4f689ce67ecbcc35cebddba5aacb603e6d1068a/LICENSE diff --git a/wallpaper/windows/notices/protobuf-BSD-3-Clause.txt b/wallpaper/windows/notices/protobuf-BSD-3-Clause.txt deleted file mode 100644 index 19b305b0..00000000 --- a/wallpaper/windows/notices/protobuf-BSD-3-Clause.txt +++ /dev/null @@ -1,32 +0,0 @@ -Copyright 2008 Google Inc. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -Code generated by the Protocol Buffer compiler is owned by the owner -of the input file used when generating it. This code is not -standalone and requires a support library to be linked with it. This -support library is itself covered by the above license. From de4940ea2cecfc402db7fd046b1f6275c7ff1552 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Mon, 26 Oct 2026 10:11:19 +1300 Subject: [PATCH 2/9] docs: record wallpaper source qualification and audit limits --- .../issue264-python-wallpaper/README.md | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/evidence/issue264-python-wallpaper/README.md b/docs/evidence/issue264-python-wallpaper/README.md index 7070809b..69d253fb 100644 --- a/docs/evidence/issue264-python-wallpaper/README.md +++ b/docs/evidence/issue264-python-wallpaper/README.md @@ -72,10 +72,20 @@ model or microphone was used by the desktop probes. - Fresh-settings cold start: no first-run UI, WebView2 selected, independent autostart disabled, newly owned host exited, original profile/layout/running state restored in finally. +- Real Settings RPC: sending the unchanged **539-byte** raw Settings payload back + produced byte-identical readback. No setting values were changed by this check. +- Clean source/provenance gate at `5a279b8439f24b124c1205abb7bbaade54062bc0`: + **4,285 selected files, 161 excluded, 0 errors, 0 warnings**; code-only ZIP + created. Its helper bytes matched the checkout and old .NET sources were absent. +- Independently extracted sources: `npm ci`, build and Windows code-only + packaging passed with dotnet removed from PATH. The resulting package passed + the native tray/helper smoke and all **15** real model-less application checks. + This used the supplied external Python runtime and locked Node dependencies; + it does not prove that no modern .NET runtime is installed on this machine. - Changed Python lint and diff whitespace checks passed. -The full Python suite and final source-archive checks are recorded below once -complete. Individual totals above overlap and must not be added together. +The full Python suite is recorded below once complete. Individual totals above +overlap and must not be added together. Reproducible commands (use the existing project Python environment): @@ -123,6 +133,14 @@ Python/model/asset provisioning remains external, as before. ## Remaining release gates +- Dependency audit is not green: the unchanged Electron lock reports **2 high, + 1 moderate** findings (Electron, undici, fast-uri); the unchanged Pi lock reports + **1 high, 1 moderate** (undici and its dependent agent package). + `npm audit --audit-level=high` exits 1 in both directories. Both lockfiles are + byte-for-byte unchanged from the checked main baseline; this migration adds no + dependency version, audit exemption or relaxed gate. Relevant high advisories: + [Electron GHSA-jr5w-w6x2-84p9](https://github.com/advisories/GHSA-jr5w-w6x2-84p9), + [undici GHSA-xjj6-f33q-929c](https://github.com/advisories/GHSA-xjj6-f33q-929c). - The installed Lively 2.2.1.0 has the local null-display-name repair documented in #233. Its binary was not certified as an unmodified official release here. These real-desktop results are supplemental, not stock-Lively acceptance. From e3c85d25b3cb21feb7132d490384836ee112f304 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Mon, 26 Oct 2026 10:14:01 +1300 Subject: [PATCH 3/9] test(wallpaper): cover newer user image choices --- tests/test_windows_wallpaper_helper.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_windows_wallpaper_helper.py b/tests/test_windows_wallpaper_helper.py index cf031e3d..3cb3692d 100644 --- a/tests/test_windows_wallpaper_helper.py +++ b/tests/test_windows_wallpaper_helper.py @@ -518,7 +518,8 @@ def test_settings_rewrite_preserves_unknown_bytes_order_and_defaults(): @pytest.mark.parametrize("browser,desktop,lock", [(0, False, False), (2, True, True), (1, False, True)]) -def test_settings_restore_preserves_newer_user_choices(browser, desktop, lock): +@pytest.mark.parametrize("previous_images", [False, True]) +def test_settings_restore_preserves_newer_user_choices(browser, desktop, lock, previous_images): raw = h.scalar(28, browser) + h.scalar(49, int(desktop)) + h.scalar(48, int(lock)) + b"\x18\x81\x00" class SettingsRpc: def call(self, service, method, payload=b"", **kwargs): @@ -529,10 +530,11 @@ def call(self, service, method, payload=b"", **kwargs): return [b""] rpc = SettingsRpc() host = h.LivelyHost(None, Path("unused"), rpc) - host.restore_options(h.HostOptions(0, 0, True, True)) + host.restore_options(h.HostOptions(0, 0, previous_images, previous_images)) result = getattr(rpc, "result", raw) assert h.value(result, 28) == (0 if browser == 1 else browser) - assert h.value(result, 48) == h.value(result, 49) == 1 + assert bool(h.value(result, 48)) == (lock or previous_images) + assert bool(h.value(result, 49)) == (desktop or previous_images) assert b"\x18\x81\x00" in result From 0b05396f7d934c1af62a8713e9652cac65cb5f17 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Mon, 26 Oct 2026 10:16:21 +1300 Subject: [PATCH 4/9] docs: align wallpaper lifecycle references with Python migration --- .../issue264-python-wallpaper/README.md | 2 +- docs/windows_wallpaper_lifecycle.md | 29 ++++++++++--------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/evidence/issue264-python-wallpaper/README.md b/docs/evidence/issue264-python-wallpaper/README.md index 69d253fb..8734ea74 100644 --- a/docs/evidence/issue264-python-wallpaper/README.md +++ b/docs/evidence/issue264-python-wallpaper/README.md @@ -46,7 +46,7 @@ Electron 44.0.0. Existing local Python/Node environments were reused; no optiona model or microphone was used by the desktop probes. - Unchanged C# baseline: **50 contracts passed**, before source removal. -- Python helper contracts: **82 passed**. This includes real Windows message +- Python helper contracts: **85 passed**. This includes real Windows message pipes, exact receiver-before-dispatch, actual journal write obstruction, late server availability, deadline/cancellation before and after dispatch, persistence spanning a deadline, large separate payloads, exclusive locking, diff --git a/docs/windows_wallpaper_lifecycle.md b/docs/windows_wallpaper_lifecycle.md index 23500dc5..fa19ab22 100644 --- a/docs/windows_wallpaper_lifecycle.md +++ b/docs/windows_wallpaper_lifecycle.md @@ -94,9 +94,9 @@ monitor/path pairs, or a missing original scene that this cleanup attempted to restore, keeps the journal for retry. An original scene may itself use the same managed project; restoring that exact monitor/path pair remains valid. -`Session.Mount` owns its failed-mount rollback. The helper uses -`Session.RunMounted` to run its active body and arrange exactly one cleanup only -after Mount succeeds. If rollback or body cleanup also fails, both the primary +`Session.mount` owns its failed-mount rollback. The helper uses +`Session.run_mounted` to run its active body and arrange exactly one cleanup only +after mount succeeds. If rollback or body cleanup also fails, both the primary and cleanup errors are retained. The Session remembers the process identity whose cleanup it has taken over, including journal recovery. The outer error handler only releases a newly started host whose identity is still outside that @@ -147,10 +147,10 @@ first and preserves newer user choices rather than constructing a Settings model Old published journals without `Phase` retain their original semantics. A `Pending` journal without `RequestProcess` is invalid: recovery reports an error -without rewriting the journal or changing the desktop. That format and -`MountUnconfirmed` existed only in this PR's unpublished drafts; neither main nor -v0.17.0-alpha.0 wrote `Phase`. Their compatibility branches have been removed -because no existing local journal required them. No recovery data was deleted. +without rewriting the journal or changing the desktop. Identity-less Pending and +`MountUnconfirmed` existed only in unpublished #228 drafts and are unsupported. +The migration retains the published Preparing/Pending/Settled format as well as +legacy phase-less records. No recovery data is deleted to enable migration. Recover any pending journal with this helper before reverting to an older helper. Older binaries do not understand `Phase`; downgrade while a journal remains is @@ -165,8 +165,8 @@ not supported, and no recovery record is reset or deleted to enable it. 本次应恢复的原场景缺失,都会保留日志以便重试;原场景本身可以使用同一维护项目, 恢复该确切 monitor/path 配对仍然有效。 -`Session.Mount` 自行负责挂载失败回滚;helper 经 `Session.RunMounted` 运行挂载后 -的主体,只在 Mount 成功后安排一次清理。主操作和清理同时失败时保留两个异常。 +`Session.mount` 自行负责挂载失败回滚;helper 经 `Session.run_mounted` 运行挂载后 +的主体,只在 mount 成功后安排一次清理。主操作和清理同时失败时保留两个异常。 Session 已接管某个进程的清理(包括旧日志恢复),即记录其身份;外层异常处理只在 进程尚未被接管且没有日志时释放新启动的宿主。成功清理删除日志不代表可以再次停止 宿主;若之后启动的是另一启动身份的宿主,在接管前初始化失败仍可释放该新进程。 @@ -191,14 +191,15 @@ Lively 不会继续迟到挂载。 仅改 28/48/49,其他字段按原字节写回;false/default 写入省略字段,保留用户新选择。 已发布的无 `Phase` 旧日志保持原语义。缺少 `RequestProcess` 的 `Pending` 日志 -视为无效:恢复明确报错,不改写日志或改变桌面。该格式和 `MountUnconfirmed` 仅 -存在于本 PR 未发布的草稿;main 与 v0.17.0-alpha.0 均未写过 `Phase`。没有现存本地 -日志需要这些兼容分支,因此已将其移除;未删除任何恢复数据。 +视为无效:恢复明确报错,不改写日志或改变桌面。无身份 Pending 与 `MountUnconfirmed` +仅存在于 #228 未发布草稿,不支持这些格式。迁移保留已发布的三阶段格式和更早无 Phase +的记录,不通过删除恢复数据来允许迁移。 回退旧 helper 前,须先用本 helper 处理未完成日志。旧程序不理解 `Phase`;日志尚存 时不支持降级,不会为允许降级而重置或删除恢复记录。 -This change is limited to phase-aware rollback and error preservation. It does +#264 replaces the helper runtime while preserving phase-aware rollback and error +preservation. It does **not** repair stock Lively 2.2.1.0's reporting/close protocol: `GetWallpapers` can return a falsely empty stream, and project close can swallow errors and return success without removing a player. A close response plus an empty stream @@ -208,7 +209,7 @@ request owned-host shutdown while an unreported scene remains. This is a known unresolved limitation, not an accepted strong-recovery guarantee. Scene removal is not inferred from a PID, a layout file or a timing heuristic to hide it. -本次范围仅为按阶段回滚和保留异常,**未修复**原版 Lively 2.2.1.0 的报告及关闭 +本次替换 helper 运行时,保留按阶段回滚及异常合同,**未修复**原版 Lively 2.2.1.0 的报告及关闭 协议:`GetWallpapers` 可能假空,项目关闭也可能吞错后返回成功却没有移除播放器。 关闭回执加空流不能证明强恢复或播放器退出。对于原来为空的桌面,保留的 baseline 空报告行为仍可能在未报告场景存在时删除日志并请求停止自有宿主。这是明确未解决的 From 00a1f49f7fc3312d368adc281f709e5754281cb4 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Mon, 26 Oct 2026 10:24:09 +1300 Subject: [PATCH 5/9] test(wallpaper): bind shutdown to the owned pipe receiver --- .../issue264-python-wallpaper/README.md | 2 +- tests/test_windows_wallpaper_helper.py | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/evidence/issue264-python-wallpaper/README.md b/docs/evidence/issue264-python-wallpaper/README.md index 8734ea74..c04e0e6e 100644 --- a/docs/evidence/issue264-python-wallpaper/README.md +++ b/docs/evidence/issue264-python-wallpaper/README.md @@ -46,7 +46,7 @@ Electron 44.0.0. Existing local Python/Node environments were reused; no optiona model or microphone was used by the desktop probes. - Unchanged C# baseline: **50 contracts passed**, before source removal. -- Python helper contracts: **85 passed**. This includes real Windows message +- Python helper contracts: **87 passed**. This includes real Windows message pipes, exact receiver-before-dispatch, actual journal write obstruction, late server availability, deadline/cancellation before and after dispatch, persistence spanning a deadline, large separate payloads, exclusive locking, diff --git a/tests/test_windows_wallpaper_helper.py b/tests/test_windows_wallpaper_helper.py index 3cb3692d..53aea26e 100644 --- a/tests/test_windows_wallpaper_helper.py +++ b/tests/test_windows_wallpaper_helper.py @@ -563,6 +563,26 @@ def rpc_for(pipe): return h.Rpc(None, "synthetic", lambda *_: pipe) +@pytest.mark.parametrize("same_receiver", [False, True]) +def test_shutdown_dispatch_is_bound_to_owned_receiver(same_receiver): + pipe = MemoryPipe([b"\x04\x1a\x02\x10\x01\x02\x2a\x00"]) + owned = pipe.receiver() if same_receiver else h.ProcessIdentity(10, 20) + class ProcessWindows: + reads = 0 + def is_running(self, identity): + assert identity == owned + self.reads += 1 + return self.reads == 1 + host = h.LivelyHost(ProcessWindows(), Path("unused"), rpc_for(pipe)) + if same_receiver: + host.stop_owned_process(owned) + assert len(pipe.writes) == 1 and b"AutomationCommand" in pipe.writes[0] + else: + assert isinstance(failure(lambda: host.stop_owned_process(owned)), RuntimeError) + assert pipe.writes == [] + assert pipe.closed + + def test_transport_inline_separate_stream_and_trailers(): # Literal wire vectors are independent of the production envelope encoder. pipe = MemoryPipe([b"\x02\x12\x00\x06\x1a\x04\x08\x03\x10\x01abc\x04\x1a\x02\x08\x04", b"data", b"\x02\x2a\x00"]) From f740816322a191b72d21b2de21dc119d7f9003a2 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Mon, 26 Oct 2026 10:37:23 +1300 Subject: [PATCH 6/9] docs: record complete wallpaper regression results --- .../issue264-python-wallpaper/README.md | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/evidence/issue264-python-wallpaper/README.md b/docs/evidence/issue264-python-wallpaper/README.md index c04e0e6e..3463a1b8 100644 --- a/docs/evidence/issue264-python-wallpaper/README.md +++ b/docs/evidence/issue264-python-wallpaper/README.md @@ -84,8 +84,24 @@ model or microphone was used by the desktop probes. it does not prove that no modern .NET runtime is installed on this machine. - Changed Python lint and diff whitespace checks passed. -The full Python suite is recorded below once complete. Individual totals above -overlap and must not be added together. +Full Python coverage: **503 distinct test files, 6,703 passed, 19 skipped, +5 expected failures, 0 unexpected failures**. This was a split run, not a single +uninterrupted green command. The original per-file runner completed 289 files +before online `uv export --locked` stalled in `test_profile_contract.py`. +The owned runner tree was interrupted and its original log retained. An offline +locked export resolved in 4 ms; the profile's original 55 assertions then passed +in 5.53 seconds with `UV_OFFLINE=1`. + +The task-local `build/resume_wallpaper_regressions.py` recollected the full tree, +checked test discovery and used the unchanged `tools.run_tests._run_suite` for +the 214 unfinished files. Only the profile-contract file received the offline +environment; it retained its original `--locked` arguments. The resumed runner +returned 0, and its wallpaper module ran the final **87** cases. Reconciling the +two logs confirmed 503 unique completed files with no gap or duplicate completion. +No test assertion, timeout or skip rule was relaxed for the run. The online uv +wait is an environment limitation; no upstream uv defect is claimed. + +Individual totals above overlap and must not be added together. Reproducible commands (use the existing project Python environment): From d60a4f8300eeb6953eb5966212dc72db88e42726 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Mon, 26 Oct 2026 10:50:57 +1300 Subject: [PATCH 7/9] fix(wallpaper): preserve distinct Unicode project identities --- .../issue264-python-wallpaper/README.md | 46 +++++++++++++++++- docs/windows_wallpaper_lifecycle.md | 7 +++ .../resources/windows-wallpaper/helper.py | 23 ++++++++- tests/test_windows_wallpaper_helper.py | 47 +++++++++++++++++++ 4 files changed, 121 insertions(+), 2 deletions(-) diff --git a/docs/evidence/issue264-python-wallpaper/README.md b/docs/evidence/issue264-python-wallpaper/README.md index 3463a1b8..4e614599 100644 --- a/docs/evidence/issue264-python-wallpaper/README.md +++ b/docs/evidence/issue264-python-wallpaper/README.md @@ -133,7 +133,8 @@ recovery counts. Migration does not fix #233. The exact baseline was published with its original self-contained win-x64 settings, excluding PDBs as in the old package filter. Helper plus notices: **74,922,671 bytes / 194 files**. The replacement helper plus protocol notices is -**56,451 bytes / 3 files**; the setup script is separate in both comparisons. +**57,526 bytes / 3 files** after the review repair below (initial candidate: +56,451 bytes); the setup script is separate in both comparisons. This removes about 71.4 MiB of uncompressed helper resources. It is not a claim about compressed installer size or a complete standalone runtime distribution. @@ -147,6 +148,49 @@ self-contained runtime publish. First-use setup still provisions Lively through winget if missing. Lively's own prerequisites remain its publisher's concern. Python/model/asset provisioning remains external, as before. +## Independent review and repair — PR #269 + +The independent review targeted `f740816322a191b72d21b2de21dc119d7f9003a2` +and inspected the Pending dispatch boundary, persisted process identities, +raw Settings compatibility and Electron resource/interpreter integration. +Its independent native contract run passed the initial 87 tests. + +One P2 implementation regression was confirmed: Python `lower()` equated a path +segment U+0130 with U+0069 U+0307. Both names can exist as distinct Windows +directories. A synthetic restoration with exact-only project closing replaced +the newer user scene with the original scene, requested owned-host shutdown and +deleted the journal. The parent independently reproduced both the user-choice +failure and the comparison mismatch before changing the implementation. + +The repair uses the native Windows ordinal comparator with explicit UTF-16 +code-unit counts. It retains same-directory ASCII/accented case aliases and +supplementary-character prefixes, while preserving distinct Unicode projects. +The reviewer verified that .NET 8's managed comparer has additional aliases for +Deseret, Adlam and final sigma pairs that are physically distinct on Windows; +the implementation deliberately follows Windows path casing rather than claiming +universal .NET 8 comparer equivalence. No extra journal state or Settings model +was added. Portable lifecycle fixtures retain the equivalent ASCII subset and +reject unsupported Unicode comparisons outside Windows. + +Follow-up validation: + +- Before repair: two new regressions failed, including the actual newer-choice + overwrite. After repair and expanded filesystem cases: **92 helper tests + passed**, including all Pending/identity/raw-Settings contracts. +- Independent repair review: the complete 89-case intermediate suite passed; + the 77 portable contracts also passed through the non-Windows branch, with + its 12 native cases deselected. The original P2 was resolved and no additional + actionable product defect was established. The final three added native cases + cover the additional physically distinct pairs observed by that reviewer. +- Related Electron wallpaper/startup/stop tests: **33 passed, 0 skipped**. +- Installed-Lively normal exit, stdin loss, helper-crash recovery and actual + parent death passed again and restored the original reported state. +- Changed Python lint and whitespace checks passed. + +The 503-file/6,703-test full Python coverage and 703-test Electron suite above +remain evidence for the initial candidate. The repair received the focused +regressions listed here; PR CI evaluates the updated head separately. + ## Remaining release gates - Dependency audit is not green: the unchanged Electron lock reports **2 high, diff --git a/docs/windows_wallpaper_lifecycle.md b/docs/windows_wallpaper_lifecycle.md index fa19ab22..5be24763 100644 --- a/docs/windows_wallpaper_lifecycle.md +++ b/docs/windows_wallpaper_lifecycle.md @@ -70,6 +70,13 @@ their new selection wins. An already-running Lively is never shut down. A Lively process started by Amadeus is shut down only after restoration and only if no new user-selected scene requires it. +Project paths use Windows ordinal case comparison (`CompareStringOrdinal` with +explicit UTF-16 lengths). Unicode lowercasing can expand a character and merge +physically distinct paths, which must remain separate user choices. This follows +Windows path casing rather than claiming universal equivalence with .NET 8's +managed ordinal comparer. Portable offline lifecycle fixtures support the exact +ASCII subset; Unicode comparison requires Windows. + ## Recovery and ownership `%LOCALAPPDATA%\Amadeus\wallpaper\session.json` is the durable before-image. diff --git a/electron/resources/windows-wallpaper/helper.py b/electron/resources/windows-wallpaper/helper.py index 6a9328fd..8c7fd59a 100644 --- a/electron/resources/windows-wallpaper/helper.py +++ b/electron/resources/windows-wallpaper/helper.py @@ -9,6 +9,7 @@ import ctypes from ctypes import wintypes as w from dataclasses import asdict, dataclass, replace +from functools import cache import json import os from pathlib import Path @@ -190,8 +191,28 @@ def atomic_json(file: Path, item) -> None: os.replace(temporary, file) +@cache +def _ordinal_comparator(): + compare = ctypes.WinDLL("kernel32", use_last_error=True).CompareStringOrdinal + compare.argtypes = [w.LPCWSTR, ctypes.c_int, w.LPCWSTR, ctypes.c_int, w.BOOL] + compare.restype = ctypes.c_int + return compare + + def same_path(left: str, right: str) -> bool: - return left.lower() == right.lower() + if sys.platform != "win32": + # The helper runs only on Windows. Portable lifecycle fixtures use the + # identical ASCII subset; Unicode identity requires the native primitive. + if not left.isascii() or not right.isascii(): + raise OSError("Windows ordinal path comparison is unavailable.") + return left.lower() == right.lower() + # Windows counts UTF-16 code units, including surrogate pairs, not Python + # code points. Case conversion can expand strings and alias distinct paths. + result = _ordinal_comparator()(left, len(left.encode("utf-16-le", "surrogatepass")) // 2, + right, len(right.encode("utf-16-le", "surrogatepass")) // 2, True) + if not result: + raise ctypes.WinError(ctypes.get_last_error()) + return result == 2 # CSTR_EQUAL class Session: diff --git a/tests/test_windows_wallpaper_helper.py b/tests/test_windows_wallpaper_helper.py index 53aea26e..0356d765 100644 --- a/tests/test_windows_wallpaper_helper.py +++ b/tests/test_windows_wallpaper_helper.py @@ -671,6 +671,53 @@ def persist(identity): native = pytest.mark.skipif(sys.platform != "win32", reason="Native Windows process/message-pipe contract") +@native +@pytest.mark.parametrize("ours_name,user_name", [ + ("\u0130", "i\u0307"), + ("\U00010400", "\U00010428"), + ("\U0001e900", "\U0001e922"), + ("\u03c2", "\u03a3"), +]) +def test_distinct_unicode_project_keeps_new_user_wallpaper_and_host(f, tmp_path, ours_name, user_name): + ours = tmp_path / ours_name / "wallpapers" / "amadeus-managed" + user_project = tmp_path / user_name / "wallpapers" / "amadeus-managed" + ours.mkdir(parents=True) + user_project.mkdir(parents=True) + assert not os.path.samefile(ours, user_project) + host, session = f + original(host) + snapshot = replace(before(host, host.running), Project=str(ours)) + session.mount(snapshot) + user_choice = h.DesktopItem("primary", str(user_project)) + host.set_wallpaper(user_choice) + + def exact_close(project): + host.closed.append(project) + host.items = [item for item in host.items if item.Path != project] + host.close_project = exact_close + session.restore() + assert host.items == [user_choice] + assert host.stopped is None and host.closed == [] and not session.journal.exists() + + +@native +def test_windows_path_case_aliases_and_complete_utf16_lengths(tmp_path): + project = tmp_path / "Gallery" / "\u00c9" / "\U0001f600A" + project.mkdir(parents=True) + aliases = [ + project.with_name("\U0001f600a"), + project.parent.parent / "\u00e9" / project.name, + project.parent.parent.with_name("GALLERY") / "\u00e9" / "\U0001f600a", + ] + for alias in aliases: + assert os.path.samefile(project, alias) + assert h.same_path(str(project), str(alias)) + other = project.with_name("\U0001f600B") + other.mkdir() + assert not os.path.samefile(project, other) + assert not h.same_path(str(project), str(other)) + + @native def test_real_process_identity_matches_csharp_and_fences_old_pending_journal(f): windows = h.Windows() From 78b48a9c116fdffc6f1759ba2983fea245f1875b Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Mon, 26 Oct 2026 11:25:56 +1300 Subject: [PATCH 8/9] fix(wallpaper): isolate helper and preserve recovery after Electron exit --- .../issue264-python-wallpaper/README.md | 87 ++++++++++++-- docs/windows_wallpaper_lifecycle.md | 30 ++++- .../resources/windows-wallpaper/helper.py | 85 +++++++++----- electron/scripts/probe-windows-wallpaper.cjs | 2 +- electron/scripts/smoke-windows-wallpaper.cjs | 2 +- electron/src/main/windowsWallpaper.ts | 6 +- electron/tests/fixtures/wallpaper-parent.cjs | 28 +++++ .../fixtures/wallpaper_helper_fixture.py | 84 ++++++++++++++ electron/tests/windowsWallpaper.test.mjs | 40 ++++++- .../tests/windowsWallpaperParentExit.test.mjs | 109 ++++++++++++++++++ scripts/setup_windows_wallpaper.ps1 | 4 +- tests/test_windows_wallpaper_helper.py | 67 ++++++++++- tools/probes/windows_wallpaper_lifecycle.py | 8 +- 13 files changed, 493 insertions(+), 59 deletions(-) create mode 100644 electron/tests/fixtures/wallpaper-parent.cjs create mode 100644 electron/tests/fixtures/wallpaper_helper_fixture.py create mode 100644 electron/tests/windowsWallpaperParentExit.test.mjs diff --git a/docs/evidence/issue264-python-wallpaper/README.md b/docs/evidence/issue264-python-wallpaper/README.md index 4e614599..52ca3cad 100644 --- a/docs/evidence/issue264-python-wallpaper/README.md +++ b/docs/evidence/issue264-python-wallpaper/README.md @@ -8,9 +8,11 @@ This is a migration candidate, not a release qualification or merge approval. This is a Windows integration/dependency replacement. Electron keeps its lifecycle entry points, status and shutdown ownership. The independent helper -uses the backend interpreter selected by `pythonRuntime.ts`, runs with `-I`, +uses the backend interpreter selected by `pythonRuntime.ts`, runs with `-I -S`, and ships with Electron resources. It imports only the Python standard library -and stays outside the backend's cleanup tree. No new package is required. +and stays outside the backend's cleanup tree. A detached Electron launch avoids +libuv's kill-on-parent-exit Job while retaining normal pipe/shutdown ownership. +No new package is required. The original lifecycle state machine and #233 reporting/close limitations remain. PascalCase journals and string phases are retained. The same-pipe receiver @@ -65,7 +67,7 @@ model or microphone was used by the desktop probes. window and reported the error. The stop endpoint checked authentication and Origin, and cleanup restored the original desktop. - Local installed-Lively lifecycle: normal exit, stdin loss, helper termination - plus journal recovery, and actual parent death all restored the original + plus journal recovery, and Python parent death all restored the original reported scene/options/running state, with a live renderer connection for each mounted scenario. The first run exposed a missing probe report directory; it was fixed at the probe layer and the complete probe rerun passed. @@ -118,7 +120,7 @@ npx electron-builder --config electron-builder-code-only.cjs --win --x64 --dir - # From the repository root, with the native Electron smoke wrapper used by CI: .venv/Scripts/python.exe tools/smoke_electron_model_less.py --packaged-executable electron/build/win-unpacked/amadeus-desktop.exe .venv/Scripts/python.exe tools/probes/windows_wallpaper_lifecycle.py -.venv/Scripts/python.exe tools/probes/windows_wallpaper_cold_start.py +.venv/Scripts/python.exe -I -S tools/probes/windows_wallpaper_cold_start.py .venv/Scripts/python.exe tools/probes/windows_wallpaper_counterexample.py .venv/Scripts/python.exe tools/build_source_release.py ``` @@ -133,8 +135,9 @@ recovery counts. Migration does not fix #233. The exact baseline was published with its original self-contained win-x64 settings, excluding PDBs as in the old package filter. Helper plus notices: **74,922,671 bytes / 194 files**. The replacement helper plus protocol notices is -**57,526 bytes / 3 files** after the review repair below (initial candidate: -56,451 bytes); the setup script is separate in both comparisons. +**57,526 bytes / 3 files** at the first review repair `d60a4f83` (initial candidate: +56,451 bytes); the setup script is separate in both comparisons. The later +audit follow-up below adds process-snapshot discovery to this resource. This removes about 71.4 MiB of uncompressed helper resources. It is not a claim about compressed installer size or a complete standalone runtime distribution. @@ -183,7 +186,7 @@ Follow-up validation: actionable product defect was established. The final three added native cases cover the additional physically distinct pairs observed by that reviewer. - Related Electron wallpaper/startup/stop tests: **33 passed, 0 skipped**. -- Installed-Lively normal exit, stdin loss, helper-crash recovery and actual +- Installed-Lively normal exit, stdin loss, helper-crash recovery and Python parent death passed again and restored the original reported state. - Changed Python lint and whitespace checks passed. @@ -191,6 +194,76 @@ The 503-file/6,703-test full Python coverage and 703-test Electron suite above remain evidence for the initial candidate. The repair received the focused regressions listed here; PR CI evaluates the updated head separately. +## Audit follow-up after `d60a4f83` + +Three integration findings were addressed before continuing the draft: + +- **F1, discovery across sessions:** `ProcessIdToSessionId` requires access to + the target process. Discovery now reads SessionId from the native + SystemProcessInformation snapshot and opens only current-session Lively + candidates for creation identity. Snapshot failures and denied selected + identities still fail visibly; an access error never becomes process-exit + evidence. Fake API cases cover foreign-session exclusion, selected-identity + denial, snapshot failure and buffer resizing. A native snapshot/self-session + check and real Lively inspect passed. A second account running Lively was + **not** exercised. +- **F2, site isolation:** every helper launch in Electron, setup, smoke and the + lifecycle probe now uses `-I -S` with the same runtime selection. `-I` alone + still executes the environment's `.pth` hooks. Source and packaged resource + tests create an isolated temporary venv and a controlled `.pth` marker: the + control executes it, while production setup/launch leave it untouched and + report `no_site=1` with no imported `site`. The development lifecycle harness + itself still needs its backend packages; its helper subprocesses use both + flags. The stdlib-only cold-start probe can also run with both flags. +- **F3, Electron parent death:** the production launch now uses `detached: true` + to avoid libuv's kill-on-parent-exit Job. Pipes and normal exit waiting remain + active. The new regression runs actual Electron, the production launcher, + a temporary Windows venv launcher and the real helper main/Session/journal + lock, substituting only a synthetic desktop. After `taskkill /F` without `/T` + and observed Electron exit, the supervisor allows restoration to finish. + The non-detached control loses the helper and retains its journal; the fixed + launch restores the synthetic original scene and deletes the journal. + Normal shutdown also passes. Test cleanup binds termination to the helper's + PID and exact creation ticks. No real desktop is modified by this test. + +The earlier "actual parent death" claim referred to a Python subprocess parent. +It did not test Electron/libuv's Job behavior and was insufficient evidence for +Electron crash recovery. Historical wording above and in the lifecycle guide +has been corrected. Detachment does not promise survival after an explicit +tree kill, logoff or power loss; durable next-start recovery remains necessary. + +Follow-up results: + +- **95 helper tests passed**, including all Pending dispatch, old identity, + PascalCase journal and raw Settings regressions. +- **706 Electron tests passed, 0 skipped**, including the three real-parent + modes and both resource paths' controlled `.pth` checks. +- Independent read-only review found no actionable defect and independently + passed all 95 helper tests plus the 25 isolation/lifecycle/parent-exit cases. + It verified native snapshot ABI offsets, own-session membership and bounds + rejection. This is advisory review, not a GitHub approval. +- Build, changed Python lint and whitespace checks passed. +- Updated Windows code-only package and native tray/helper smoke passed; + packaged helper bytes match the current source. +- Actual packaged application without models: **15 checks passed**, including + frontend/backend exit. +- Installed-Lively normal exit, pipe loss, helper-crash recovery and Python + parent death passed again using helper subprocesses with `-I -S`. +- The stdlib-only cold-start probe passed under `-I -S`, including original + settings/layout/running-state restoration. + +Current helper plus protocol notices: **58,761 bytes / 3 files**, excluding the +common setup script. A read-only live-host comparison on the updated helper, +alternating flags after one warmup and ten samples per mode, measured medians +**239.5 ms (`-I`)** and **218 ms (`-I -S`)**. This is local diagnostic evidence; +the different environment/startup paths do not justify attributing all of the +initial 195-to-269 ms difference to site initialization. + +Primary references: [Windows process-session access](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-processidtosessionid), +[system process snapshot layout](https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntquerysysteminformation), +[Python `-S`](https://docs.python.org/3.12/using/cmdline.html#cmdoption-S), +[Node 22.21.1/libuv Windows process implementation](https://github.com/nodejs/node/blob/v22.21.1/deps/uv/src/win/process.c#L988-L1034). + ## Remaining release gates - Dependency audit is not green: the unchanged Electron lock reports **2 high, diff --git a/docs/windows_wallpaper_lifecycle.md b/docs/windows_wallpaper_lifecycle.md index 5be24763..c2a2e0c3 100644 --- a/docs/windows_wallpaper_lifecycle.md +++ b/docs/windows_wallpaper_lifecycle.md @@ -248,7 +248,7 @@ snapshot. The `recover` command also handles an interrupted launch preparation. Manual recovery, after closing Amadeus: ```powershell -.venv\Scripts\python.exe -I electron\resources\windows-wallpaper\helper.py recover +.venv\Scripts\python.exe -I -S electron\resources\windows-wallpaper\helper.py recover ``` `recover` is an operator diagnostic/recovery command, also used by the desktop @@ -268,13 +268,25 @@ cancellation/time-budget policy remains a release-acceptance item. The helper is `electron/resources/windows-wallpaper/helper.py`, shipped with Electron under `resources/windows-wallpaper`, together with setup and protocol notices. Source runs use that same checked-in resource. Electron passes its -backend interpreter selected by `pythonRuntime.ts` and invokes `python -I`; +backend interpreter selected by `pythonRuntime.ts` and invokes `python -I -S`; no additional Python package, .NET SDK, NuGet build or bundled .NET runtime is -required. The helper remains an independent child of Electron, outside the -backend's process-tree/Job cleanup. macOS/Linux and external-host paths do not +required. `-S` prevents the selected environment's site initialization and +`.pth` hooks from running. Electron launches the helper with `detached: true` +and retains its pipes and normal shutdown wait. This avoids libuv's +kill-on-parent-exit Job, allowing EOF-triggered recovery after Electron alone +is force-killed, including when the selected interpreter is a venv launcher. +The helper is separately launched from the backend and its cleanup tree. +This does not protect against explicit process-tree termination, logoff or +power loss; the durable journal still supplies next-start recovery. +macOS/Linux and external-host paths do not run Windows setup. Experimental logs, backups and screenshots stay under build/, outside the explicitly selected packaged resource files. +Lively discovery reads process/session membership from the native system +snapshot before opening current-session candidates for creation-time identity +checks. An inaccessible foreign-session process does not block discovery; +snapshot failures and unverified current-session identities remain errors. + [.NET 8 support ends on 2026-11-10](https://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core). This motivates #264. If replacement cannot be qualified before that date, the release branch must first retarget the old helper and CI to net10.0 as a @@ -431,13 +443,19 @@ it in cleanup): ```powershell .venv\Scripts\python.exe tools\probes\windows_wallpaper_lifecycle.py -.venv\Scripts\python.exe tools\probes\windows_wallpaper_cold_start.py +.venv\Scripts\python.exe -I -S tools\probes\windows_wallpaper_cold_start.py ``` The lifecycle probe requires a real renderer connection, then verifies the original scene/options/running state after normal exit, parent-pipe loss, and -helper termination plus journal recovery. It also kills an actual parent so +helper termination plus journal recovery. It also kills a Python parent so both stdin and stdout readers disappear, and checks restoration still completes. +That scenario does not exercise Electron/libuv's Job behavior. +`electron/tests/windowsWallpaperParentExit.test.mjs` separately force-kills an +actual Electron parent without `/T`. It uses the production launcher and Session +with a synthetic desktop, proving journal removal and restoration after parent +exit, normal shutdown, and a non-detached control that reproduces interrupted +cleanup. It exercises a temporary venv launcher and never changes the desktop. The cold-start probe backs up the user's settings and layout, starts installed Lively with a fresh configuration, checks that no first-run UI opens, mounts a test scene, verifies release of the diff --git a/electron/resources/windows-wallpaper/helper.py b/electron/resources/windows-wallpaper/helper.py index 8c7fd59a..38627ef7 100644 --- a/electron/resources/windows-wallpaper/helper.py +++ b/electron/resources/windows-wallpaper/helper.py @@ -1,4 +1,4 @@ -"""Independent Windows wallpaper helper; standard library only, run with python -I. +"""Independent Windows wallpaper helper; standard library only, run with python -I -S. Lively 2.2.1.0 uses GrpcDotNetNamedPipes, not HTTP/2. The bounded wire adapter below follows transport.proto at da307b551a9d56b62f5780e91a2d95a699f32430. @@ -350,11 +350,17 @@ class OVERLAPPED(ctypes.Structure): ("Offset", w.DWORD), ("OffsetHigh", w.DWORD), ("hEvent", w.HANDLE)] -class PROCESSENTRY32W(ctypes.Structure): - _fields_ = [("dwSize", w.DWORD), ("cntUsage", w.DWORD), ("th32ProcessID", w.DWORD), - ("th32DefaultHeapID", ctypes.c_size_t), ("th32ModuleID", w.DWORD), ("cntThreads", w.DWORD), - ("th32ParentProcessID", w.DWORD), ("pcPriClassBase", w.LONG), ("dwFlags", w.DWORD), - ("szExeFile", w.WCHAR * 260)] +class UNICODE_STRING(ctypes.Structure): + _fields_ = [("Length", w.USHORT), ("MaximumLength", w.USHORT), ("Buffer", w.LPVOID)] + + +class SYSTEM_PROCESS_PREFIX(ctypes.Structure): + # Documented SystemProcessInformation prefix, through SessionId. Remaining + # fields/threads are skipped using NextEntryOffset, never guessed by size. + _fields_ = [("NextEntryOffset", w.ULONG), ("NumberOfThreads", w.ULONG), + ("Reserved1", w.BYTE * 48), ("ImageName", UNICODE_STRING), + ("BasePriority", w.LONG), ("UniqueProcessId", w.HANDLE), + ("InheritedFromUniqueProcessId", w.HANDLE), ("HandleCount", w.ULONG), ("SessionId", w.ULONG)] class Windows: @@ -375,13 +381,13 @@ def __init__(self): "GetProcessTimes": (w.BOOL, [w.HANDLE] + [ctypes.POINTER(w.FILETIME)] * 4), "ProcessIdToSessionId": (w.BOOL, [w.DWORD, ctypes.POINTER(w.DWORD)]), "QueryFullProcessImageNameW": (w.BOOL, [w.HANDLE, w.DWORD, w.LPWSTR, ctypes.POINTER(w.DWORD)]), - "CreateToolhelp32Snapshot": (w.HANDLE, [w.DWORD, w.DWORD]), - "Process32FirstW": (w.BOOL, [w.HANDLE, ctypes.POINTER(PROCESSENTRY32W)]), - "Process32NextW": (w.BOOL, [w.HANDLE, ctypes.POINTER(PROCESSENTRY32W)]), } for name, (result, args) in signatures.items(): function = getattr(self.api, name) function.restype, function.argtypes = result, args + self.process_api = ctypes.WinDLL("ntdll", use_last_error=True) + self.process_api.NtQuerySystemInformation.argtypes = [w.ULONG, w.LPVOID, w.ULONG, ctypes.POINTER(w.ULONG)] + self.process_api.NtQuerySystemInformation.restype = w.LONG def check(self, result): if not result: @@ -441,31 +447,48 @@ def username(self) -> str: self.check(api.GetUserNameW(buffer, ctypes.byref(size))) return buffer.value + def processes(self): + size = 65536 + while True: + buffer, used = ctypes.create_string_buffer(size), w.ULONG() + status = self.process_api.NtQuerySystemInformation(5, buffer, size, ctypes.byref(used)) & 0xFFFFFFFF + if status == 0xC0000004: # STATUS_INFO_LENGTH_MISMATCH: snapshot grew. + size = max(size * 2, used.value) + continue + if status: + raise OSError(f"Windows process snapshot failed (NTSTATUS 0x{status:08x}).") + break + base, offset = ctypes.addressof(buffer), 0 + while True: + if offset + ctypes.sizeof(SYSTEM_PROCESS_PREFIX) > used.value or used.value > size: + raise OSError("Invalid Windows process snapshot bounds.") + entry = SYSTEM_PROCESS_PREFIX.from_buffer(buffer, offset) + name = entry.ImageName + if name.Length: + if not name.Buffer or name.Buffer < base or name.Buffer + name.Length > base + used.value: + raise OSError("Invalid Windows process image-name bounds.") + image = ctypes.wstring_at(name.Buffer, name.Length // ctypes.sizeof(w.WCHAR)) + else: + image = "" + yield entry.UniqueProcessId, entry.SessionId, image + if not entry.NextEntryOffset: + return + if entry.NextEntryOffset < ctypes.sizeof(SYSTEM_PROCESS_PREFIX): + raise OSError("Invalid Windows process snapshot offset.") + offset += entry.NextEntryOffset + def running(self) -> ProcessIdentity | None: current = w.DWORD() self.check(self.api.ProcessIdToSessionId(os.getpid(), ctypes.byref(current))) - snapshot = self.api.CreateToolhelp32Snapshot(2, 0) - if snapshot == ctypes.c_void_p(-1).value: - raise ctypes.WinError(ctypes.get_last_error()) - try: - entry = PROCESSENTRY32W() - entry.dwSize = ctypes.sizeof(entry) - found = self.api.Process32FirstW(snapshot, ctypes.byref(entry)) - while found: - if entry.szExeFile.lower() == "lively.exe": - session = w.DWORD() - self.check(self.api.ProcessIdToSessionId(entry.th32ProcessID, ctypes.byref(session))) - if session.value == current.value: - try: - return self.identity(entry.th32ProcessID) - except ProcessLookupError: - pass - found = self.api.Process32NextW(snapshot, ctypes.byref(entry)) - if ctypes.get_last_error() != 18: - raise ctypes.WinError(ctypes.get_last_error()) - return None - finally: - self.close(snapshot) + # Session membership comes from the system snapshot, without opening + # foreign processes. A selected same-session identity must still verify. + for pid, session, image in self.processes(): + if session == current.value and image.lower() == "lively.exe": + try: + return self.identity(pid) + except ProcessLookupError: + pass + return None def executable(self, pid: int) -> str: handle = self.open_process(pid) diff --git a/electron/scripts/probe-windows-wallpaper.cjs b/electron/scripts/probe-windows-wallpaper.cjs index a8125809..f9493418 100644 --- a/electron/scripts/probe-windows-wallpaper.cjs +++ b/electron/scripts/probe-windows-wallpaper.cjs @@ -12,7 +12,7 @@ const root = path.resolve(__dirname, '../..') const output = path.join(root, 'build/windows-wallpaper') const helper = path.join(root, 'electron/resources/windows-wallpaper/helper.py') const runtime = import(pathToFileURL(path.join(root, 'electron/dist/main/pythonRuntime.js')).href) -const executeHelper = async args => execute((await runtime).projectPythonCommand(root), ['-I', helper, ...args], { windowsHide: true }) +const executeHelper = async args => execute((await runtime).projectPythonCommand(root), ['-I', '-S', helper, ...args], { windowsHide: true }) const inspect = async () => JSON.parse((await executeHelper(['inspect'])).stdout.trim()) process.env.NODE_ENV = 'production' // This probe explicitly selects wallpaper regardless of the saved GUI mode. diff --git a/electron/scripts/smoke-windows-wallpaper.cjs b/electron/scripts/smoke-windows-wallpaper.cjs index 3584ccd2..d3182e56 100644 --- a/electron/scripts/smoke-windows-wallpaper.cjs +++ b/electron/scripts/smoke-windows-wallpaper.cjs @@ -43,7 +43,7 @@ app.whenReady().then(async () => { // Read-only. Never installs Lively, mounts a scene or modifies the CI desktop. const { projectPythonCommand } = await import(pathToFileURL(path.join(main, 'pythonRuntime.js')).href) const python = projectPythonCommand(path.resolve(__dirname, '../..')) - const inspected = JSON.parse(execFileSync(python, ['-I', helper, 'inspect'], { encoding: 'utf8', windowsHide: true }).trim()) + const inspected = JSON.parse(execFileSync(python, ['-I', '-S', helper, 'inspect'], { encoding: 'utf8', windowsHide: true }).trim()) assert.equal(typeof inspected.running, 'boolean') console.log('PASS packaged Windows startup choices and external host ownership: native tray, missing-icon recovery, setup resources and helper runtime') app.quit() diff --git a/electron/src/main/windowsWallpaper.ts b/electron/src/main/windowsWallpaper.ts index 78bf2c4f..db38a71a 100644 --- a/electron/src/main/windowsWallpaper.ts +++ b/electron/src/main/windowsWallpaper.ts @@ -170,7 +170,11 @@ export function windowsWallpaperDependencies(projectRoot: string, resourcesPath: child.once('exit', code => code === 0 ? resolve() : reject(new Error(`Windows wallpaper setup failed: ${detail}`))) }), launch: url => { - const child = spawn(python, ['-I', helper, 'run', url], { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }) + // Keep recovery outside libuv's kill-on-parent-exit Job. Retain the pipes + // and exit wait so normal shutdown still waits for desktop restoration. + const child = spawn(python, ['-I', '-S', helper, 'run', url], { + detached: true, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], + }) let resolveReady: () => void let rejectReady: (error: Error) => void const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject }) diff --git a/electron/tests/fixtures/wallpaper-parent.cjs b/electron/tests/fixtures/wallpaper-parent.cjs new file mode 100644 index 00000000..04184647 --- /dev/null +++ b/electron/tests/fixtures/wallpaper-parent.cjs @@ -0,0 +1,28 @@ +// Actual Electron/libuv parent; no BrowserWindow, backend or real Lively. +const { app } = require('electron') +const fs = require('node:fs') +const path = require('node:path') +const childProcess = require('node:child_process') +const { pathToFileURL } = require('node:url') +const [root, python, mode] = process.argv.slice(2) +app.setPath('userData', path.join(root, 'profile')) +app.disableHardwareAcceleration() +void app.whenReady().then(async () => { + const { loadTypeScript } = await import(pathToFileURL(path.resolve(__dirname, '../helpers/loadTypeScript.mjs')).href) + const mocks = mode === 'attached-control' ? { + 'node:child_process': { ...childProcess, spawn: (command, args, options) => + childProcess.spawn(command, args, { ...options, detached: false }) }, + } : {} + const { windowsWallpaperDependencies } = loadTypeScript( + pathToFileURL(path.resolve(__dirname, '../../src/main/windowsWallpaper.ts')), mocks) + const helper = windowsWallpaperDependencies(root, '', false, python) + .launch('http://127.0.0.1:1/wallpaper/lively/index.html') + fs.writeFileSync(path.join(root, 'parent.json'), JSON.stringify({ parent: process.pid, launcher: helper.process.pid })) + await helper.ready + console.log(JSON.stringify({ ready: true })) + if (mode === 'normal') { + helper.process.stdin.end() + await helper.done + app.exit(0) + } +}).catch(error => { console.error(error); app.exit(1) }) diff --git a/electron/tests/fixtures/wallpaper_helper_fixture.py b/electron/tests/fixtures/wallpaper_helper_fixture.py new file mode 100644 index 00000000..f7be9298 --- /dev/null +++ b/electron/tests/fixtures/wallpaper_helper_fixture.py @@ -0,0 +1,84 @@ +"""Run the real helper/Session with a synthetic desktop in an isolated profile.""" +import importlib.util +import json +import os +from pathlib import Path +import sys +import time + +state = Path(os.environ["AMADEUS_WALLPAPER_TEST_STATE"]) +spec = importlib.util.spec_from_file_location("real_wallpaper_helper", os.environ["AMADEUS_WALLPAPER_TEST_HELPER"]) +h = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = h +spec.loader.exec_module(h) +os.environ["LOCALAPPDATA"] = str(state / "local") + + +class FixtureWindows(h.Windows): + def running(self): + return self.identity(os.getpid()) + + +class FixtureHost: + instance = None + started_process = None + + def __init__(self, windows): + FixtureHost.instance = self + self.windows = windows + self.items = [h.DesktopItem("primary", "old")] + self.options = h.HostOptions(0, 1, False, False) + + def ensure_running(self): + pass + + def recover_launch_images(self): + pass + + def write_project(self, url): + return "amadeus-managed" + + def primary_monitor(self): + return "primary" + + def get_options(self): + return self.options + + def get_wallpapers(self): + return list(self.items) + + def set_session_options(self): + pass + + def restore_options(self, previous, images=True): + self.options = previous + + def set_wallpaper(self, wallpaper, request_settled=None, request_dispatching=None): + if request_dispatching: + request_dispatching(self.windows.identity(os.getpid())) + self.items = [wallpaper] + if request_settled: + request_settled() + + def close_project(self, project): + # Restoration can legitimately outlive the parent's entire exit. The + # supervisor releases this gate only after observing Electron's exit. + (state / "cleanup-entered").touch() + until = time.monotonic() + 30 + while not (state / "cleanup-allow").exists(): + if time.monotonic() >= until: + raise TimeoutError("test cleanup gate was not released") + time.sleep(0.01) + self.items = [item for item in self.items if item.Path != project] + + +h.Windows, h.LivelyHost = FixtureWindows, FixtureHost +identity = FixtureWindows().identity(os.getpid()) +(state / "started.json").write_text(json.dumps(dict(Id=identity.Id, Started=str(identity.Started))), encoding="utf-8") +code = h.main() +if code == 0: + journal = state / "local/Amadeus/wallpaper/session.json" + assert not journal.exists() + assert FixtureHost.instance.items == [h.DesktopItem("primary", "old")] + (state / "restored.json").write_text(json.dumps(dict(restored=True, journalRemoved=True)), encoding="utf-8") +raise SystemExit(code) diff --git a/electron/tests/windowsWallpaper.test.mjs b/electron/tests/windowsWallpaper.test.mjs index 2549411e..cf9a6b08 100644 --- a/electron/tests/windowsWallpaper.test.mjs +++ b/electron/tests/windowsWallpaper.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' +import { execFileSync } from 'node:child_process' import { loadTypeScript } from './helpers/loadTypeScript.mjs' import { managesWindowsWallpaper, recoverWindowsWallpaperHostExit, stopBackendWallpaperAfterHostExit, stopWallpaperForRenderer, windowsWallpaperDependencies, WindowsWallpaperSession } from '../src/main/windowsWallpaper.ts' @@ -15,20 +16,49 @@ for (const packaged of [false, true]) { const resources = path.join(root, 'resources') const helperDir = packaged ? path.join(resources, 'windows-wallpaper') : path.join(root, 'electron/resources/windows-wallpaper') await fs.mkdir(helperDir, { recursive: true }) + const basePython = projectPythonCommand(path.resolve(import.meta.dirname, '../..')) + const runtime = path.join(root, 'runtime') + execFileSync(basePython, ['-I', '-S', '-m', 'venv', '--without-pip', runtime], { windowsHide: true }) + const configuredPython = path.join(runtime, process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python') + const python = projectPythonCommand(root, { AMADEUS_PYTHON: configuredPython }) + const site = execFileSync(python, ['-I', '-c', 'import site; print(site.getsitepackages()[0])'], { + encoding: 'utf8', windowsHide: true, + }).trim() + const marker = path.join(root, 'pth-executed') + await fs.writeFile(path.join(site, 'wallpaper-isolation.pth'), + `import pathlib; pathlib.Path(${JSON.stringify(marker)}).write_text('ran')\n`) + execFileSync(python, ['-I', '-c', 'pass'], { windowsHide: true }) + assert.equal(await fs.readFile(marker, 'utf8'), 'ran', 'control must execute venv .pth with -I alone') + await fs.unlink(marker) const trace = path.join(root, 'trace.json') await fs.writeFile(path.join(helperDir, 'helper.py'), `import json, sys from pathlib import Path -Path(${JSON.stringify(trace)}).write_text(json.dumps(dict(isolated=sys.flags.isolated, executable=sys.executable, file=__file__, args=sys.argv[1:]))) -print('{"status":"mounted"}', flush=True) -sys.stdin.readline() +Path(${JSON.stringify(trace)}).write_text(json.dumps(dict(isolated=sys.flags.isolated, no_site=sys.flags.no_site, site_loaded='site' in sys.modules, executable=sys.executable, file=__file__, args=sys.argv[1:]))) +if sys.argv[1] == 'inspect': + print('{"executable":"fixture"}', flush=True) +else: + print('{"status":"mounted"}', flush=True) + sys.stdin.readline() `) - const python = projectPythonCommand(path.resolve(import.meta.dirname, '../..')) - const helper = windowsWallpaperDependencies(root, resources, packaged, python).launch('http://127.0.0.1:1234/wallpaper/lively/index.html') + const dependencies = windowsWallpaperDependencies(root, resources, packaged, python) + if (process.platform === 'win32') { + const scripts = packaged ? helperDir : path.join(root, 'scripts') + await fs.mkdir(scripts, { recursive: true }) + await fs.copyFile(new URL('../../scripts/setup_windows_wallpaper.ps1', import.meta.url), path.join(scripts, 'setup_windows_wallpaper.ps1')) + await dependencies.prepare() + const prepared = JSON.parse(await fs.readFile(trace, 'utf8')) + assert.equal(prepared.no_site, 1) + assert.equal(prepared.site_loaded, false) + } + const helper = dependencies.launch('http://127.0.0.1:1234/wallpaper/lively/index.html') await helper.ready helper.process.stdin.end() await helper.done const observed = JSON.parse(await fs.readFile(trace, 'utf8')) assert.equal(observed.isolated, 1) + assert.equal(observed.no_site, 1) + assert.equal(observed.site_loaded, false) + await assert.rejects(fs.access(marker), { code: 'ENOENT' }) assert.equal(path.resolve(observed.executable), path.resolve(python)) assert.equal(path.resolve(observed.file), path.join(helperDir, 'helper.py')) assert.deepEqual(observed.args, ['run', 'http://127.0.0.1:1234/wallpaper/lively/index.html']) diff --git a/electron/tests/windowsWallpaperParentExit.test.mjs b/electron/tests/windowsWallpaperParentExit.test.mjs new file mode 100644 index 00000000..a20c605b --- /dev/null +++ b/electron/tests/windowsWallpaperParentExit.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import fs from 'node:fs/promises' +import path from 'node:path' +import os from 'node:os' +import { createRequire } from 'node:module' +import { spawn, execFileSync } from 'node:child_process' +import { once } from 'node:events' +import { loadTypeScript } from './helpers/loadTypeScript.mjs' + +const require = createRequire(import.meta.url) +const { projectPythonCommand } = loadTypeScript(new URL('../src/main/pythonRuntime.ts', import.meta.url)) +const sourceHelper = path.resolve(import.meta.dirname, '../resources/windows-wallpaper/helper.py') +const processCheck = `import ctypes, importlib.util, sys +from ctypes import wintypes as w +spec = importlib.util.spec_from_file_location('check_helper', sys.argv[1]) +h = importlib.util.module_from_spec(spec); sys.modules[spec.name] = h; spec.loader.exec_module(h) +identity = h.ProcessIdentity(int(sys.argv[2]), int(sys.argv[3])) +windows = h.Windows() +if len(sys.argv) == 4: + print(int(windows.is_running(identity))) +else: + handle = windows.api.OpenProcess(0x100001 | 0x1000, False, identity.Id) + if handle: + try: + created, exited, kernel, user = (w.FILETIME() for _ in range(4)) + windows.check(windows.api.GetProcessTimes(handle, ctypes.byref(created), ctypes.byref(exited), ctypes.byref(kernel), ctypes.byref(user))) + actual = ((created.dwHighDateTime << 32) | created.dwLowDateTime) + h.DOTNET_FILETIME_OFFSET + if actual == identity.Started and windows.api.WaitForSingleObject(handle, 0) != 0: + windows.api.TerminateProcess.argtypes = [w.HANDLE, w.UINT] + windows.api.TerminateProcess.restype = w.BOOL + terminated = windows.api.TerminateProcess(handle, 1) + if not terminated and windows.api.WaitForSingleObject(handle, 0) != 0: + windows.check(terminated) + windows.api.WaitForSingleObject(handle, 10000) + finally: + windows.close(handle) +` + +async function waitFor(check, description, timeout = 20000) { + const until = Date.now() + timeout + while (Date.now() < until) { + if (await check()) return + await new Promise(resolve => setTimeout(resolve, 25)) + } + throw new Error(`Timed out: ${description}`) +} +async function exists(file) { + try { await fs.access(file); return true } catch (error) { if (error.code === 'ENOENT') return false; throw error } +} + +for (const mode of ['attached-control', 'crash', 'normal']) { + test(`real Electron parent lifetime preserves helper recovery (${mode})`, { + skip: process.platform !== 'win32', timeout: 60000, + }, async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'amadeus-wallpaper-parent-')) + let parent, identity, python + try { + const basePython = projectPythonCommand(path.resolve(import.meta.dirname, '../..')) + execFileSync(basePython, ['-I', '-S', '-m', 'venv', '--without-pip', path.join(root, 'runtime')], { windowsHide: true }) + python = path.join(root, 'runtime/Scripts/python.exe') + const helperDir = path.join(root, 'electron/resources/windows-wallpaper') + await fs.mkdir(helperDir, { recursive: true }) + await fs.copyFile(new URL('./fixtures/wallpaper_helper_fixture.py', import.meta.url), path.join(helperDir, 'helper.py')) + if (mode === 'normal') await fs.writeFile(path.join(root, 'cleanup-allow'), '') + const env = { ...process.env, AMADEUS_WALLPAPER_TEST_STATE: root, AMADEUS_WALLPAPER_TEST_HELPER: sourceHelper } + delete env.ELECTRON_RUN_AS_NODE + parent = spawn(require('electron'), [path.resolve(import.meta.dirname, 'fixtures/wallpaper-parent.cjs'), root, python, mode], { + env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], + }) + let output = '', errors = '' + parent.stdout.on('data', data => { output += data }) + parent.stderr.on('data', data => { errors += data }) + const exited = once(parent, 'exit') + await waitFor(() => output.includes('"ready":true') || parent.exitCode !== null, 'Electron helper ready') + assert(output.includes('"ready":true'), output + errors) + identity = JSON.parse(await fs.readFile(path.join(root, 'started.json'), 'utf8')) + const pids = JSON.parse(await fs.readFile(path.join(root, 'parent.json'), 'utf8')) + assert.equal(pids.parent, parent.pid) + assert.notEqual(pids.launcher, identity.Id, 'exercise the Windows venv launcher and its interpreter child') + const alive = () => execFileSync(python, ['-I', '-S', '-c', processCheck, sourceHelper, String(identity.Id), identity.Started], { + encoding: 'utf8', windowsHide: true, timeout: 10000, + }).trim() === '1' + if (mode !== 'normal') { + execFileSync('taskkill.exe', ['/PID', String(parent.pid), '/F'], { windowsHide: true }) + await exited + await fs.writeFile(path.join(root, 'cleanup-allow'), '') + } else { + assert.equal((await exited)[0], 0, errors) + } + if (mode === 'attached-control') { + await waitFor(() => !alive(), 'attached helper killed by Electron Job') + assert.equal(await exists(path.join(root, 'restored.json')), false) + assert.equal(await exists(path.join(root, 'local/Amadeus/wallpaper/session.json')), true) + } else { + await waitFor(() => exists(path.join(root, 'restored.json')), 'real Session restore after Electron exit') + assert.deepEqual(JSON.parse(await fs.readFile(path.join(root, 'restored.json'), 'utf8')), { restored: true, journalRemoved: true }) + await waitFor(() => !alive(), 'restored helper exited') + } + } finally { + if (parent && parent.exitCode === null) { + try { execFileSync('taskkill.exe', ['/PID', String(parent.pid), '/F'], { windowsHide: true }) } catch {} + } + if (!identity && await exists(path.join(root, 'started.json'))) identity = JSON.parse(await fs.readFile(path.join(root, 'started.json'), 'utf8')) + if (identity && python) execFileSync(python, ['-I', '-S', '-c', processCheck, sourceHelper, String(identity.Id), identity.Started, 'terminate-owned'], { windowsHide: true, timeout: 15000 }) + await fs.rm(root, { recursive: true, force: true }) + } + }) +} diff --git a/scripts/setup_windows_wallpaper.ps1 b/scripts/setup_windows_wallpaper.ps1 index 641bbf9d..d73b0740 100644 --- a/scripts/setup_windows_wallpaper.ps1 +++ b/scripts/setup_windows_wallpaper.ps1 @@ -6,7 +6,7 @@ if (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'helper.py')) { $helper = Join-Path $PSScriptRoot 'helper.py' } if ($env:OS -ne 'Windows_NT') { throw 'This setup is Windows-only.' } -$inspection = (& $Python -I $helper inspect | Select-Object -Last 1) | ConvertFrom-Json +$inspection = (& $Python -I -S $helper inspect | Select-Object -Last 1) | ConvertFrom-Json if ($LASTEXITCODE -ne 0) { throw 'Could not inspect the wallpaper host.' } if (!$inspection.executable) { if (Get-AppxPackage '*LivelyWallpaper*') { @@ -19,6 +19,6 @@ if (!$inspection.executable) { # Do not launch Lively until its first-run configuration has been prepared. & winget.exe install --id rocksdanister.LivelyWallpaper --version 2.2.1.0 --exact --source winget --silent --accept-source-agreements --accept-package-agreements --disable-interactivity --override '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER /NOAUTOLAUNCH /MERGETASKS=!desktopicon,!windowsstartup' if ($LASTEXITCODE -ne 0) { throw "Lively installation failed: $LASTEXITCODE" } - & $Python -I $helper prepare + & $Python -I -S $helper prepare if ($LASTEXITCODE -ne 0) { throw 'Lively first-run preparation failed.' } } diff --git a/tests/test_windows_wallpaper_helper.py b/tests/test_windows_wallpaper_helper.py index 0356d765..87004ab7 100644 --- a/tests/test_windows_wallpaper_helper.py +++ b/tests/test_windows_wallpaper_helper.py @@ -494,7 +494,7 @@ def running(self): def test_broken_parent_stdout_does_not_change_helper_exit_status(): code = "import os, runpy, sys; helper = runpy.run_path(sys.argv[1]); os.read(sys.stdin.fileno(), 1); helper['report'](dict(status='restored'))" - child = subprocess.Popen([sys.executable, "-I", "-c", code, str(HELPER)], stdin=subprocess.PIPE, + child = subprocess.Popen([sys.executable, "-I", "-S", "-c", code, str(HELPER)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) child.stdout.close() child.stdin.write(b"x") @@ -671,6 +671,71 @@ def persist(identity): native = pytest.mark.skipif(sys.platform != "win32", reason="Native Windows process/message-pipe contract") +def test_running_filters_snapshot_sessions_before_opening_processes(): + windows = h.Windows.__new__(h.Windows) + class Kernel: + def ProcessIdToSessionId(self, pid, output): + assert pid == os.getpid(), "foreign process session query requires access rights" + output._obj.value = 7 + return True + windows.api = Kernel() + windows.processes = lambda: iter([(201, 8, "Lively.exe"), (202, 7, "Lively.exe")]) + opened = [] + def identify(pid): + assert pid == 202, "foreign-session Lively must not be opened" + opened.append(pid) + return h.ProcessIdentity(pid, 123) + windows.identity = identify + assert windows.running() == h.ProcessIdentity(202, 123) + assert opened == [202] + + +def test_running_keeps_same_session_identity_and_snapshot_failures_visible(): + windows = h.Windows.__new__(h.Windows) + class Kernel: + def ProcessIdToSessionId(self, pid, output): + output._obj.value = 7 + return True + windows.api = Kernel() + windows.processes = lambda: iter([(202, 7, "Lively.exe")]) + denied = PermissionError("same-session process identity unavailable") + windows.identity = lambda _: (_ for _ in ()).throw(denied) + assert failure(windows.running) is denied + unavailable = OSError("snapshot unavailable") + windows.processes = lambda: (_ for _ in ()).throw(unavailable) + assert failure(windows.running) is unavailable + + +@native +def test_process_snapshot_reads_session_membership_without_process_handles(): + import ctypes + windows = h.Windows.__new__(h.Windows) + names = [(201, 8, "Lively.exe"), (202, 7, "Lively.exe")] + class SnapshotApi: + calls = 0 + def NtQuerySystemInformation(self, kind, buffer, size, used): + assert kind == 5 + self.calls += 1 + if self.calls == 1: + used._obj.value = size + 1 + return 0xC0000004 + header = ctypes.sizeof(h.SYSTEM_PROCESS_PREFIX) + for index, (pid, session, image) in enumerate(names): + entry = h.SYSTEM_PROCESS_PREFIX.from_buffer(buffer, index * header) + entry.NextEntryOffset = header if index == 0 else 0 + entry.UniqueProcessId, entry.SessionId = pid, session + encoded = image.encode("utf-16-le") + name_offset = 2 * header + index * 64 + entry.ImageName.Length = len(encoded) + entry.ImageName.Buffer = ctypes.addressof(buffer) + name_offset + ctypes.memmove(entry.ImageName.Buffer, encoded, len(encoded)) + used._obj.value = 2 * header + 128 + return 0 + windows.process_api = SnapshotApi() + assert list(windows.processes()) == names + assert windows.process_api.calls == 2 + + @native @pytest.mark.parametrize("ours_name,user_name", [ ("\u0130", "i\u0307"), diff --git a/tools/probes/windows_wallpaper_lifecycle.py b/tools/probes/windows_wallpaper_lifecycle.py index de379467..6cbc5730 100644 --- a/tools/probes/windows_wallpaper_lifecycle.py +++ b/tools/probes/windows_wallpaper_lifecycle.py @@ -17,7 +17,7 @@ ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) -HELPER = [sys.executable, "-I", str(ROOT / "electron/resources/windows-wallpaper/helper.py")] +HELPER = [sys.executable, "-I", "-S", str(ROOT / "electron/resources/windows-wallpaper/helper.py")] def inspect() -> dict: @@ -97,7 +97,7 @@ def main() -> int: report["experiments"].append({"scenario": scenario, "renderer_connected": True, "after": after, "passed": True}) print(f"PASS {scenario}: original wallpaper and host restored", flush=True) # Unlike closing stdin alone, real parent death also closes the stdout - # reader. Verify that a broken diagnostic pipe cannot abort cleanup. + # reader. This is a Python parent, not an Electron/libuv Job test. parent_script = ''' import json, os, subprocess, sys child = subprocess.Popen(sys.argv[1:], stdin=subprocess.PIPE, stdout=subprocess.PIPE, @@ -121,8 +121,8 @@ def main() -> int: after = inspect() assert not after["recoveryPending"] and after["wallpapers"] == baseline["wallpapers"], after assert after["options"] == baseline["options"] and after["running"] == baseline["running"], after - report["experiments"].append({"scenario": "actual-parent-crash", "after": after, "passed": True}) - print("PASS actual-parent-crash: restored after stdin and stdout readers vanished", flush=True) + report["experiments"].append({"scenario": "python-parent-crash", "after": after, "passed": True}) + print("PASS python-parent-crash: restored after stdin and stdout readers vanished", flush=True) output.write_text(json.dumps(report, indent=2), encoding="utf-8") print(output, flush=True) return 0 From 482f2365677110e8a19ebcda2399edecae6743b3 Mon Sep 17 00:00:00 2001 From: Lucas1479 Date: Mon, 26 Oct 2026 20:24:48 +1300 Subject: [PATCH 9/9] fix(wallpaper): use windowed Python for detached sessions --- .../issue264-python-wallpaper/README.md | 51 ++++++++++++++++++- docs/windows_wallpaper_lifecycle.md | 12 ++++- electron/src/main/windowsWallpaper.ts | 8 ++- .../fixtures/wallpaper_helper_fixture.py | 8 +++ electron/tests/windowsWallpaper.test.mjs | 4 +- 5 files changed, 78 insertions(+), 5 deletions(-) diff --git a/docs/evidence/issue264-python-wallpaper/README.md b/docs/evidence/issue264-python-wallpaper/README.md index 52ca3cad..1de6f9ed 100644 --- a/docs/evidence/issue264-python-wallpaper/README.md +++ b/docs/evidence/issue264-python-wallpaper/README.md @@ -8,7 +8,7 @@ This is a migration candidate, not a release qualification or merge approval. This is a Windows integration/dependency replacement. Electron keeps its lifecycle entry points, status and shutdown ownership. The independent helper -uses the backend interpreter selected by `pythonRuntime.ts`, runs with `-I -S`, +uses the backend runtime selected by `pythonRuntime.ts`, runs with `-I -S`, and ships with Electron resources. It imports only the Python standard library and stays outside the backend's cleanup tree. A detached Electron launch avoids libuv's kill-on-parent-exit Job while retaining normal pipe/shutdown ownership. @@ -264,6 +264,55 @@ Primary references: [Windows process-session access](https://learn.microsoft.com [Python `-S`](https://docs.python.org/3.12/using/cmdline.html#cmdoption-S), [Node 22.21.1/libuv Windows process implementation](https://github.com/nodejs/node/blob/v22.21.1/deps/uv/src/win/process.c#L988-L1034). +## N1: detached venv console regression after `78b48a9c` + +The detached console venv launcher can allocate a console for its interpreter +child even with `windowsHide`. This is an Electron launch integration defect +introduced by the F3 repair. The prior parent-exit tests exercised the venv +process structure but did not assert absence of a console; all 16 hosted checks +at `78b48a9c` passed despite the defect. + +Before changing production code, the real helper fixture gained a native +`GetConsoleWindow() == NULL` assertion. The actual Electron crash case then +failed on the old launch, confirming the missing contract locally. That test +uses an isolated synthetic desktop and owned temporary processes. No claim is +made that manually closing the unwanted terminal was reproduced or caused +desktop loss. + +The session launcher now prefers `pythonw.exe` beside a selected Windows +`python.exe`, when present. The original runtime selection, `-I -S`, detachment, +explicit pipes and normal shutdown wait are retained. Setup keeps its original +interpreter. No base-interpreter search or dependency is added. Missing siblings, +custom executable names and other platforms retain the configured command; +custom distributions without the windowed entry point need separate window +qualification. + +The existing native parent tests now assert no associated console, usable +stdin/stdout/stderr, isolated mode and absent site initialization before mounting. +All three real Electron parent modes passed after the repair. Both source and +packaged resource tests verify the windowed interpreter path, original setup +interpreter, controlled `.pth` exclusion and mounted/stdin/exit round trip. + +N1 follow-up validation: + +- **95 helper contracts passed** and the full Electron suite passed + **706 tests, 0 skipped**. No helper runtime logic or durable/wire format changed. +- Independent read-only review passed all **25** focused Node cases, verified + that an actual windowed interpreter's stderr traceback still rejects both + readiness and completion, and checked missing-sibling/custom-name selection. + No actionable defect was reported; fallback console behavior is unqualified. +- Build and Windows code-only packaging passed. Packaged native tray/helper + smoke passed, with helper bytes matching source. Python lint and whitespace + checks passed. +- Actual packaged application without models passed all **15** checks, + including frontend/backend exit. +- Actual Electron + installed-Lively startup, scene connection, control-panel + open/close and quit restoration passed using the new windowed session launch. + The previously documented local-Lively qualification limit still applies. + +Primary references: [CPython 3.12.10 launcher startup flags](https://github.com/python/cpython/blob/v3.12.10/PC/launcher.c#L751-L767), +[native console association check](https://learn.microsoft.com/en-us/windows/console/getconsolewindow). + ## Remaining release gates - Dependency audit is not green: the unchanged Electron lock reports **2 high, diff --git a/docs/windows_wallpaper_lifecycle.md b/docs/windows_wallpaper_lifecycle.md index c2a2e0c3..9e8ac6d1 100644 --- a/docs/windows_wallpaper_lifecycle.md +++ b/docs/windows_wallpaper_lifecycle.md @@ -267,14 +267,20 @@ cancellation/time-budget policy remains a release-acceptance item. The helper is `electron/resources/windows-wallpaper/helper.py`, shipped with Electron under `resources/windows-wallpaper`, together with setup and protocol -notices. Source runs use that same checked-in resource. Electron passes its -backend interpreter selected by `pythonRuntime.ts` and invokes `python -I -S`; +notices. Source runs use that same checked-in resource. Electron uses the +backend runtime selected by `pythonRuntime.ts` with `-I -S`; no additional Python package, .NET SDK, NuGet build or bundled .NET runtime is required. `-S` prevents the selected environment's site initialization and `.pth` hooks from running. Electron launches the helper with `detached: true` and retains its pipes and normal shutdown wait. This avoids libuv's kill-on-parent-exit Job, allowing EOF-triggered recovery after Electron alone is force-killed, including when the selected interpreter is a venv launcher. +For a selected Windows `python.exe`, the session uses the same directory's +`pythonw.exe` when present. A detached venv console launcher can otherwise +allocate a console in its interpreter child despite `windowsHide`. The windowed +entry point keeps the same runtime and explicit stdin/stdout/stderr pipes; +setup uses the original interpreter. If the sibling is absent, the selected +command is retained; custom runtime distributions need their own window checks. The helper is separately launched from the backend and its cleanup tree. This does not protect against explicit process-tree termination, logoff or power loss; the durable journal still supplies next-start recovery. @@ -456,6 +462,8 @@ actual Electron parent without `/T`. It uses the production launcher and Session with a synthetic desktop, proving journal removal and restoration after parent exit, normal shutdown, and a non-detached control that reproduces interrupted cleanup. It exercises a temporary venv launcher and never changes the desktop. +The actual helper process must also report no associated console, usable +stdin/stdout/stderr and isolated mode without site initialization. The cold-start probe backs up the user's settings and layout, starts installed Lively with a fresh configuration, checks that no first-run UI opens, mounts a test scene, verifies release of the diff --git a/electron/src/main/windowsWallpaper.ts b/electron/src/main/windowsWallpaper.ts index db38a71a..4dacba0d 100644 --- a/electron/src/main/windowsWallpaper.ts +++ b/electron/src/main/windowsWallpaper.ts @@ -1,4 +1,5 @@ import { spawn, type ChildProcess } from 'node:child_process' +import fs from 'node:fs' import path from 'node:path' export function managesWindowsWallpaper(platform: string, environment: NodeJS.ProcessEnv): boolean { @@ -172,7 +173,12 @@ export function windowsWallpaperDependencies(projectRoot: string, resourcesPath: launch: url => { // Keep recovery outside libuv's kill-on-parent-exit Job. Retain the pipes // and exit wait so normal shutdown still waits for desktop restoration. - const child = spawn(python, ['-I', '-S', helper, 'run', url], { + // A detached venv python.exe can create a console in its interpreter child + // despite windowsHide. Use the same runtime's windowed sibling when present. + const windowed = path.join(path.dirname(python), 'pythonw.exe') + const command = process.platform === 'win32' && path.basename(python).toLowerCase() === 'python.exe' + && fs.existsSync(windowed) ? windowed : python + const child = spawn(command, ['-I', '-S', helper, 'run', url], { detached: true, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], }) let resolveReady: () => void diff --git a/electron/tests/fixtures/wallpaper_helper_fixture.py b/electron/tests/fixtures/wallpaper_helper_fixture.py index f7be9298..9b75e2db 100644 --- a/electron/tests/fixtures/wallpaper_helper_fixture.py +++ b/electron/tests/fixtures/wallpaper_helper_fixture.py @@ -1,4 +1,6 @@ """Run the real helper/Session with a synthetic desktop in an isolated profile.""" +import ctypes +from ctypes import wintypes as w import importlib.util import json import os @@ -75,6 +77,12 @@ def close_project(self, project): h.Windows, h.LivelyHost = FixtureWindows, FixtureHost identity = FixtureWindows().identity(os.getpid()) (state / "started.json").write_text(json.dumps(dict(Id=identity.Id, Started=str(identity.Started))), encoding="utf-8") +console_api = ctypes.WinDLL("kernel32", use_last_error=True) +console_api.GetConsoleWindow.argtypes = [] +console_api.GetConsoleWindow.restype = w.HWND +assert not console_api.GetConsoleWindow(), "Managed wallpaper helper must not allocate a console window" +assert sys.stdin is not None and sys.stdout is not None and sys.stderr is not None +assert sys.flags.isolated and sys.flags.no_site and "site" not in sys.modules code = h.main() if code == 0: journal = state / "local/Amadeus/wallpaper/session.json" diff --git a/electron/tests/windowsWallpaper.test.mjs b/electron/tests/windowsWallpaper.test.mjs index cf9a6b08..841dd8e4 100644 --- a/electron/tests/windowsWallpaper.test.mjs +++ b/electron/tests/windowsWallpaper.test.mjs @@ -49,6 +49,7 @@ else: const prepared = JSON.parse(await fs.readFile(trace, 'utf8')) assert.equal(prepared.no_site, 1) assert.equal(prepared.site_loaded, false) + assert.equal(path.resolve(prepared.executable), path.resolve(python)) } const helper = dependencies.launch('http://127.0.0.1:1234/wallpaper/lively/index.html') await helper.ready @@ -59,7 +60,8 @@ else: assert.equal(observed.no_site, 1) assert.equal(observed.site_loaded, false) await assert.rejects(fs.access(marker), { code: 'ENOENT' }) - assert.equal(path.resolve(observed.executable), path.resolve(python)) + const helperPython = process.platform === 'win32' ? path.join(runtime, 'Scripts/pythonw.exe') : python + assert.equal(path.resolve(observed.executable), path.resolve(helperPython)) assert.equal(path.resolve(observed.file), path.join(helperDir, 'helper.py')) assert.deepEqual(observed.args, ['run', 'http://127.0.0.1:1234/wallpaper/lively/index.html']) } finally { await fs.rm(root, { recursive: true, force: true }) }