From 4e948b28c7f832ee05bc88a490c5ad566835aef6 Mon Sep 17 00:00:00 2001 From: RomainROCH <153511345+RomainROCH@users.noreply.github.com> Date: Sun, 10 May 2026 11:20:42 +0200 Subject: [PATCH 1/7] chore: add CLAUDE.md project context, ignore .claude/ local state --- .gitignore | Bin 191 -> 231 bytes CLAUDE.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 5025e8417c3a5bc9f7005ed560298508809b513a..3e1beb30af95532f5fc66efa777ad251f8c0301d 100644 GIT binary patch delta 12 Scmdnb_?&UV{)tmXfCvC3V+Cvg delta 6 NcmaFPxSw&tegFzn0{8#` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4f3a0a8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,42 @@ +# --- CLAUDE.md pour Wardoff (racine du repo) --- + +# Projet + +Application Rust Windows : bloqueur de shutdown en 4 couches. +MVP tagué `v0.1.0-mvp`. Repo public, licence MIT. + +# Build et vérification + +``` +cargo build --release +cargo test +cargo clippy -- -D warnings +``` + +Exécute les trois après chaque changement. Binary cible < 2MB, zéro runtime deps. + +# Architecture + +4 couches de blocage shutdown — consulter `src/` directement. +Architecture détaillée : @docs/ARCHITECTURE.md +Business model : @docs/BUSINESS_MODEL.md + +# Tests + +`cargo test` couvre les tests unitaires et tourne sans privilèges. +Les couches 3 (Update Orchestrator) et 4 (remote shutdown) ne s'arment qu'en session élevée — leur validation est manuelle. +Procédure complète et matrice : @docs/MANUAL_VALIDATION_PLAN.md + +# Git + +- Branche principale : `main` +- Tags sémantiques : `v0.x.y` +- Commits atomiques +- Format Conventional Commits : `type: sujet` en minuscules. Types utilisés : `feat`, `fix`, `docs`, `chore`, `refactor`, `test`, `merge`. Sujet à l'impératif présent, sans point final. + +# Ne PAS faire + +- Pas de portabilité cross-platform — Windows only, c'est intentionnel +- Pas de telemetry, pas de paywall, pas de tracking +- Ne pas ajouter de dépendances sans demander +- Ne pas utiliser `unwrap()` en production From 59d59df4e9de7fc44b56dc76bf1591b98302e847 Mon Sep 17 00:00:00 2001 From: RomainROCH Date: Sun, 4 Oct 2026 03:28:12 +0200 Subject: [PATCH 2/7] fix: use owned requests for idle-sleep protection --- .github/copilot-instructions.md | 10 +- CHANGELOG.md | 5 +- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- Cargo.lock | 2 +- Cargo.toml | 1 + PLAN.md | 2 +- README.md | 7 +- docs/ARCHITECTURE.md | 46 ++- docs/COMPARISON.md | 6 +- docs/MVP_VALIDATION_MATRIX.md | 100 ++++++- docs/OUTREACH_DRAFTS.md | 16 +- docs/RELEASE_NOTES_DRAFT.md | 2 +- docs/WINDOWS_SHUTDOWN_LAYERS.md | 26 +- src/blocker/mod.rs | 11 +- src/blocker/shutdown.rs | 18 +- src/blocker/sleep.rs | 498 ++++++++++++++++++++++---------- src/cli.rs | 4 +- src/logger/mod.rs | 2 +- src/main.rs | 6 + tests/smoke_test.ps1 | 57 +++- 21 files changed, 597 insertions(+), 226 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d6a4d69..3129fea 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -50,7 +50,7 @@ If a user asks only **"what's next?"**, answer from repo docs instead of inventi ## High-level architecture -- `Wardoff` is a Windows-only Rust utility for preventing or aborting unwanted shutdown, reboot, sleep, hibernate, and related power transitions. The intended target is `x86_64-pc-windows-msvc`. +- `Wardoff` is a Windows-only Rust utility for protecting supported shutdown/reboot paths and preventing idle sleep and automatic display timeout. The intended target is `x86_64-pc-windows-msvc`. - The product has two control surfaces that should stay aligned: - a tray/background app with Block/Allow state and optional hidden mode - a CLI exposing `--block`, `--allow`, `--status`, `--hide`, `--log`, `--tail`, `--autostart on|off`, and `--version`, with `--status` expected to produce JSON for scripting @@ -66,7 +66,7 @@ If a user asks only **"what's next?"**, answer from repo docs instead of inventi 4. remote shutdown protection via repeated `AbortSystemShutdown(NULL)` polling - Be explicit that Layer 3 can skip cleanly on editions such as LTSC where `\Microsoft\Windows\UpdateOrchestrator\Reboot` is absent; that is normal behavior, not a failure. - For detailed layer-by-layer behavior and the cautious wording around ETW/local shutdown handling, read `docs/WINDOWS_SHUTDOWN_LAYERS.md`. -- Sleep/hibernate/display blocking is a separate toggle and is expected to use `SetThreadExecutionState(...)`. +- Idle-sleep/display-timeout prevention follows Block/Allow through process-owned Power Requests. Follow `docs/ARCHITECTURE.md` and `docs/MVP_VALIDATION_MATRIX.md` for lifecycle, resume behavior and Windows limits; do not promise an explicit Sleep/Hibernate veto. - Autostart is handled via Task Scheduler rather than the `Run` registry key so elevated scenarios can be handled correctly. ## Implemented module structure @@ -78,7 +78,7 @@ If a user asks only **"what's next?"**, answer from repo docs instead of inventi - `src/blocker/local.rs`: current local shutdown worker implementation; keep its user-facing documentation conservative and aligned with MVP scope decisions - `src/blocker/update.rs`: UpdateOrchestrator task monitoring and restore logic - `src/blocker/remote.rs`: Layer 4 `AbortSystemShutdownW(None)` polling loop -- `src/blocker/sleep.rs`: `SetThreadExecutionState(...)` blocker +- `src/blocker/sleep.rs`: owned system/display Power Requests - `src/blocker/abort.rs`: shared shutdown-abort privilege and result helpers - `src/autostart.rs`: scheduled-task autostart management - `src/instance.rs` and `src/ipc.rs`: single-instance ownership plus the named-pipe control and read-only status paths @@ -91,7 +91,7 @@ If a user asks only **"what's next?"**, answer from repo docs instead of inventi - Re-read `PLAN.md` before making major structural decisions. Use it as the roadmap and scope boundary document, while `README.md` stays the quickest current-state summary. - Preserve the distinction between MVP and later phases: - - MVP is the safe, official-API release: standard shutdown blocking, UpdateOrchestrator handling, remote abort loop, tray basics, CLI basics, Task Scheduler autostart, sleep/hibernate/display blocking, and simple file logging + - MVP is the safe, official-API release: standard shutdown blocking, UpdateOrchestrator handling, remote abort loop, tray basics, CLI basics, Task Scheduler autostart, idle-sleep/display-timeout prevention, and simple file logging - aggressive IFEO mode, Windows Event Log, profiles, timer, and toast notifications belong to later phases unless the plan is updated - Be explicit about elevation boundaries. Update protection and aggressive `shutdown.exe` interception are planned as admin-only features; tray, UI, and CLI flows should surface that requirement clearly rather than failing silently. - Prefer official Windows APIs first. Aggressive behavior is opt-in and should carry strong warnings because it may trigger EDR tooling. @@ -113,7 +113,7 @@ Only these features belong in the current phase: - Layer 1: ShutdownBlockReasonCreate + WM_QUERYENDSESSION + SetProcessShutdownParameters - Layer 3: Task Scheduler UpdateOrchestrator\Reboot disable/re-check - Layer 4: AbortSystemShutdown polling loop -- Sleep/hibernate/screensaver blocking via SetThreadExecutionState +- Idle-sleep/display-timeout prevention via Power Requests; no screensaver, lock or explicit Sleep/Hibernate veto - Tray icon: Block/Allow toggle, right-click menu (Block, Allow, Shutdown, Reboot, Sleep, Hibernate, Quit) - CLI: --block, --allow, --status (JSON output), --hide, --log, --tail, --autostart on|off, --version - Auto-start via Task Scheduler diff --git a/CHANGELOG.md b/CHANGELOG.md index 0358b0c..3fce0e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Changed +- Replaced the sleep layer's periodic `SetThreadExecutionState` worker with owned system/display Power Requests and an identifiable Wardoff reason. Activation failures now reach the coordinator synchronously, and Allow/exit release the request object. +- Kept the existing tray wake-restore flow and added only a request-pair renewal to the existing resume callback when the runtime remains in Block, based on the documented termination of requests at user-initiated sleep. No additional resume state machine, display notifications or polling was introduced. +- Corrected sleep claims throughout maintained docs, CLI help and runtime logs: protection concerns idle sleep and automatic display timeout, subject to Windows policy. Explicit Sleep/Hibernate are not vetoed; Modern Standby on battery has additional limits. The original v0.1.0 hibernation claim below was too broad. - Hardened the session-scoped `WardoffControl` and `WardoffStatus` pipes against same-session squatting by claiming first pipe instances with bounded startup retries, failing closed until both IPC servers own their initial instances, and probing the singleton before `wardoff --status` opens IPC endpoints. - Scoped the singleton mutex plus the `WardoffControl` and `WardoffStatus` named pipes to the current Windows session so cross-session mutex or pipe squatting no longer breaks Wardoff's single-instance model across all sessions. - Hardened structured JSONL logging under `%LOCALAPPDATA%\Wardoff\logs` so Wardoff now creates its managed log directories one component at a time, refuses reparse points in the managed log tree and rotated files, and uses reparse-aware no-follow opens for active and read-only log file access. @@ -34,7 +37,7 @@ All notable changes to this project will be documented in this file. - Layer 1 interactive shutdown and sign-out blocking - Layer 3 protection for `\Microsoft\Windows\UpdateOrchestrator\Reboot` - Layer 4 best-effort remote shutdown abort polling -- Sleep, hibernate, and display-idle blocking via `SetThreadExecutionState(...)` +- Idle-sleep and display-timeout prevention via `SetThreadExecutionState(...)` (wording corrected: the original release description overstated hibernation protection) - Tray UI with Block, Allow, Shutdown, Reboot, Sleep, Hibernate, and Quit actions - CLI support for `--block`, `--allow`, `--hide`, `--status`, `--log`, `--tail`, and `--autostart` - Structured rotating JSONL logging diff --git a/CLAUDE.md b/CLAUDE.md index 4f3a0a8..403e931 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ Business model : @docs/BUSINESS_MODEL.md `cargo test` couvre les tests unitaires et tourne sans privilèges. Les couches 3 (Update Orchestrator) et 4 (remote shutdown) ne s'arment qu'en session élevée — leur validation est manuelle. -Procédure complète et matrice : @docs/MANUAL_VALIDATION_PLAN.md +Procédure complète et matrice : @docs/MVP_VALIDATION_MATRIX.md # Git diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 411539a..bc7f88b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,7 @@ Keep documentation and PR descriptions aligned with the current supported MVP su - interactive shutdown/sign-out blocking is implemented - Update Orchestrator reboot-task protection is implemented - remote shutdown abort polling is implemented -- sleep/hibernate/display-idle blocking is implemented +- idle-sleep and automatic display-timeout prevention are implemented; explicit Sleep/Hibernate remain controlled by Windows - tray, CLI, logging, IPC, autostart, and single-instance coordination are implemented Do **not** claim the following as implemented unless your change really adds and validates them: diff --git a/Cargo.lock b/Cargo.lock index e6b4967..17d0228 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1446,7 +1446,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wardoff" -version = "0.1.0" +version = "0.2.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index c1cd0c2..38f6d15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ windows = { version = "0.62.2", features = [ "Win32_System_Diagnostics_Etw", "Win32_System_Shutdown", "Win32_System_Power", + "Win32_System_SystemServices", "Win32_System_IO", "Win32_System_Pipes", "Win32_System_Time", diff --git a/PLAN.md b/PLAN.md index ee5c62c..4372e0c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -22,7 +22,7 @@ The branch already contains a working runtime plus CLI centered on one main prom - Layer 1 interactive shutdown and sign-out blocking - Layer 3 protection for `\Microsoft\Windows\UpdateOrchestrator\Reboot` when that task exists on the machine - Layer 4 best-effort remote shutdown abort polling -- sleep, hibernate, and display-idle blocking +- idle-sleep and automatic display-timeout prevention through Power Requests (no explicit Sleep/Hibernate veto) - tray UI with Block/Allow state and power actions - CLI control surface - structured rotating JSONL logs diff --git a/README.md b/README.md index 8149682..f0db140 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![Rust](https://img.shields.io/badge/language-Rust-orange) ![Windows](https://img.shields.io/badge/platform-Windows%2010%2B-blue) -*Open-source Windows shutdown/reboot/sleep blocker* +*Open-source Windows shutdown/reboot protection and idle-sleep prevention* Wardoff is a Windows-native Rust utility for people who want a visible, scriptable way to keep a machine in a protected **Block** state during gaming sessions, overnight jobs, remote work, or maintenance windows. The project is intentionally honest about what the current MVP does today and what still belongs to later releases. @@ -17,7 +17,7 @@ Wardoff currently ships a working tray/runtime app plus CLI with these MVP-level - Layer 1 interactive shutdown and sign-out blocking through `WM_QUERYENDSESSION`, `ShutdownBlockReasonCreate()`, and `SetProcessShutdownParameters()` - Layer 3 protection for the scheduled task `Microsoft\Windows\UpdateOrchestrator\Reboot` when that task exists - Layer 4 best-effort remote shutdown abort polling with `AbortSystemShutdownW(None)` -- sleep, hibernate, and display-idle blocking via `SetThreadExecutionState(...)` +- idle-sleep and automatic display-timeout prevention through Windows Power Requests, subject to Windows power policy - structured rotating JSONL logging under `%LOCALAPPDATA%\Wardoff\logs\` - single-instance coordination so later CLI calls can control the primary runtime - Task Scheduler autostart management @@ -53,6 +53,9 @@ For why IFEO stays out of the current MVP, see [`docs/IFEO_WARNING.md`](docs/IFE ## Honest limits +- **Sleep protection means idle-sleep prevention.** Explicit Sleep or Hibernate, power-button actions and lid closure can still suspend the PC. Wardoff does not block the screen saver or promise to prevent session locking. +- Windows power policy and request overrides can ignore keep-awake requests. On Modern Standby running on battery, system-required requests expire five minutes after the system sleep timeout expires. See the [power behavior matrix and Microsoft sources](docs/MVP_VALIDATION_MATRIX.md#idle-power-behavior-and-windows-limits). +- `layers.sleep=true` means Wardoff acquired its system/display request pair; it is not a guarantee that Windows will refuse every power transition. - **Wardoff does not promise to stop `shutdown /t 0 /f`.** A forced zero-second local shutdown is outside what a normal user-space app can reliably block. - Windows Update reboot-task protection requires administrator rights. - Remote shutdown abort logic only helps when Windows still exposes a timeout window and the process has the rights required for `AbortSystemShutdownW(None)`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2656fc9..282fe05 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -51,7 +51,7 @@ Contains the layered protection logic. - `local.rs` — local `shutdown.exe` ETW detection and best-effort abort attempts - `update.rs` — Layer 3 Update Orchestrator reboot-task protection - `remote.rs` — Layer 4 remote shutdown abort polling -- `sleep.rs` — sleep/hibernate/display-idle blocking +- `sleep.rs` — idle-sleep and automatic display-timeout prevention - `abort.rs` — shared `AbortSystemShutdownW(None)` helpers used by local and remote shutdown code ### `src/tray/` @@ -98,7 +98,7 @@ It holds: - `LocalShutdownBlocker` for local `shutdown.exe` handling - `UpdateRebootBlocker` for Update Orchestrator task protection - `RemoteShutdownBlocker` for remote shutdown polling -- `SleepBlocker` for execution-state-based power-state blocking +- `SleepBlocker` for process-owned idle-power requests It also owns the current `BlockerMode`: @@ -142,6 +142,46 @@ When the runtime returns to Allow mode, it: If a Block-mode transition fails partway through, `BlockerCoordinator` rolls back the already-started layers so the process does not remain half-armed. +## Idle-power request ownership (intentional architecture change) + +The sleep layer uses one process-owned Power Request object with +`PowerRequestSystemRequired` and `PowerRequestDisplayRequired`, replacing the +thread-affine `SetThreadExecutionState` worker and its 30-second refresh. +The purpose is explicit ownership, synchronous activation errors, and a Wardoff +reason in `powercfg /requests`; this does not add a veto over explicit sleep or +hibernate. The existing `windows` dependency provides these APIs. + +Block acquires both requests transactionally. Allow, rollback, normal exit and +forced-session cleanup release them and close the handle. The handle is private +to this process and is never duplicated or inherited. Windows closes process +handles on termination; forced termination must also be checked in the smoke +test. No power plan, registry, policy, button or lid setting is written. + +The existing resume callback is sufficient. Microsoft's +[PowerSetRequest contract](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-powersetrequest) +documents termination of requests at user-initiated sleep entry. When the +runtime remains in Block, that callback replaces the old owned request object +and reacquires the pair. It does not assume that the handle itself becomes +invalid. A tray power action already releases requests through Allow and +reacquires them through the existing restore-to-Block path. There is no new +resume state machine, display/presence subscription, timer, or battery-limit +bypass. Duplicate resume notifications must never accumulate request counts. +Physical S3/S4 and Modern Standby resume behavior remains a manual validation +requirement; API lifecycle tests are not evidence of an actual sleep transition. + +`layers.sleep` retains its JSON name and means that Wardoff acquired the request +pair, not that Windows guarantees every power transition is prevented. System +policy can ignore requests. A failed initial acquisition uses the coordinator's +existing rollback; a failed resume acquisition reports an inactive sleep layer +and an error without disabling unrelated shutdown protections. + +Away Mode is intentionally excluded: it substitutes apparent sleep on eligible +S3 systems, depends on the existing Allow Away Mode policy, and has no Modern +Standby coverage. Execution-required requests do not add the desired idle-sleep +guarantee. Neither the legacy API as a fallback nor obsolete suspend veto events +are combined with this backend. See the [validation matrix](MVP_VALIDATION_MATRIX.md) +for the power contract, source references, and hardware acceptance procedure. + ## Process model and single-instance behavior Wardoff is designed to have one primary runtime per Windows session boundary enforced by a named mutex. @@ -376,7 +416,7 @@ For current documentation and review, treat these as supported, visible runtime - Layer 1 interactive blocking - Layer 3 update reboot-task protection - Layer 4 remote abort polling -- sleep/hibernate/display-idle blocking +- idle-sleep and automatic display-timeout prevention - tray UI - CLI plus IPC control - structured JSONL logging diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 6c2b1f4..1fdb34c 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -6,7 +6,7 @@ | Product | Source model | Current focus | What Wardoff can honestly claim today | Important gaps or planned items | | --- | --- | --- | --- | --- | -| Wardoff | MIT, open source | Transparent shutdown and power-state control for modern Windows | Interactive shutdown/sign-out blocking, Update Orchestrator reboot-task protection, remote abort polling, sleep/hibernate/display-idle blocking, tray UI, CLI, JSONL logs, single-instance IPC, autostart | No IFEO mode, no Windows Event Log integration, and no promise to stop local `shutdown /t 0 /f` | +| Wardoff | MIT, open source | Transparent shutdown and power-state control for modern Windows | Interactive shutdown/sign-out blocking, Update Orchestrator reboot-task protection, remote abort polling, idle-sleep and automatic display-timeout prevention, tray UI, CLI, JSONL logs, single-instance IPC, autostart | No IFEO mode, no Windows Event Log integration, and no promise to stop local `shutdown /t 0 /f` | | ShutdownBlocker | Closed freeware | Traditional shutdown blocking utility | Comparable motivation around shutdown prevention | Closed implementation, older maintenance history, and behavior cannot be audited here | | ShutdownGuard | MIT, open source | Historical shutdown-blocking approach | Open-source reference point | Archived and unsupported | | Don't Sleep | Closed freeware | Keep-awake / power-state prevention | Strong overlap on sleep-prevention use cases | Not positioned as a transparent layered shutdown-control tool | @@ -21,7 +21,7 @@ Wardoff's current MVP is best described as a conservative, transparent Windows r - Layer 1 interactive shutdown and sign-out blocking - Layer 3 protection for `\Microsoft\Windows\UpdateOrchestrator\Reboot` - Layer 4 best-effort remote shutdown abort polling -- sleep, hibernate, and display-idle blocking via `SetThreadExecutionState(...)` +- idle-sleep and automatic display-timeout prevention through Power Requests, subject to Windows power policy - tray controls for Block, Allow, Shutdown, Reboot, Sleep, Hibernate, and Quit - CLI control surface for `--block`, `--allow`, `--hide`, `--status`, `--log`, `--tail`, and `--autostart` - structured rotating JSONL logs @@ -115,7 +115,7 @@ Wardoff's current 0.1.0 positioning is: - open and auditable - conservative in what it claims -- useful today for interactive shutdown blocking, update-task protection, remote abort polling, and sleep prevention +- useful today for interactive shutdown blocking, update-task protection, remote abort polling, and idle-sleep prevention - intentionally cautious about local `shutdown.exe` That last point is the most important comparison decision: diff --git a/docs/MVP_VALIDATION_MATRIX.md b/docs/MVP_VALIDATION_MATRIX.md index 093815c..c0555df 100644 --- a/docs/MVP_VALIDATION_MATRIX.md +++ b/docs/MVP_VALIDATION_MATRIX.md @@ -9,7 +9,7 @@ This matrix covers only the current MVP surface: - Layer 1 interactive shutdown and sign-out blocking - Layer 3 Update Orchestrator reboot-task protection - Layer 4 best-effort remote shutdown abort polling -- sleep, hibernate, and display-idle blocking +- idle-sleep and automatic display-timeout prevention, subject to Windows policy - tray behavior - CLI behavior - IPC and single-instance behavior @@ -31,7 +31,7 @@ Keep validation language conservative: | Logs | `%LOCALAPPDATA%\Wardoff\logs\wardoff.jsonl` | | Admin rights | Required for Layer 3, Layer 4, Update Orchestrator task checks, and autostart task changes | | Trusted install path | Required if you expect `wardoff --autostart on` to create or update the `Wardoff` task; from an untrusted or user-writable path Wardoff should refuse safely and leave the scheduled-task state unchanged | -| Disposable VM | Required for shutdown, reboot, sign-out, sleep, hibernate, or remote-shutdown checks that could disrupt the host | +| Disruptive tests | Use a disposable VM for shutdown/reboot/sign-out checks; use prepared test hardware or a capable VM for sleep/hibernate checks | ## Automated checks already covered by `tests\smoke_test.ps1` @@ -40,21 +40,27 @@ Keep validation language conservative: | Release build | `cargo build --release` succeeds | No | | Release artifact | `target\release\wardoff.exe` exists | No | | CLI help | `wardoff --help` exits `0` and mentions `wardoff` | No | -| CLI version | `wardoff --version` exits `0` and prints `wardoff 0.1.0` | No | +| CLI version | `wardoff --version` exits `0` and prints the package version | No | | Inactive status | `wardoff --status` exits `1` and returns `{"state":"inactive"}` when no instance is running | No | | Hidden block startup | `Start-Process ... --block --hide` keeps the runtime alive | No | | Active status JSON | `wardoff --status` exits `0`, returns `state=block`, and includes `layers.local_shutdown` as a boolean | No | -| Sleep/display request visibility | `powercfg /requests` mentions Wardoff while blocking is active, when the session allows that query | No | +| Sleep/display request visibility | `powercfg /requests` contains the Wardoff reason for both requests in Block; it does not prove a physical transition was prevented | Query may require elevation | +| Block/Allow/Block lifecycle | `layers.sleep` is true/false/true; requests disappear in Allow while the same runtime remains alive, then acquisition is reported again in Block | Query may require elevation | +| Abrupt termination | The owned test runtime is killed directly from Block, without Allow; its requests disappear | Query may require elevation | | Structured logging | `%LOCALAPPDATA%\Wardoff\logs\wardoff.jsonl` exists, appends new lines, and contains valid JSON | No | | Layer 3 status | Elevated status reports `layers.update=true` when `\Microsoft\Windows\UpdateOrchestrator\Reboot` exists; if the task is absent, note the normal skip/defer case instead of treating it as a failure | Yes | | Layer 4 status | Elevated status reports `layers.remote=true` | Yes | | Update task access | `schtasks /query /tn Microsoft\Windows\UpdateOrchestrator\Reboot` either succeeds or shows the task is absent on this machine; absence is a normal Layer 3 skip/defer case | Yes | | Autostart task lifecycle | From a trusted admin-writable location, `wardoff --autostart on` creates the `Wardoff` task and `--autostart off` removes it; from an untrusted or user-writable path Wardoff refuses safely and leaves the task state unchanged | Yes | -| Cleanup path | `wardoff --allow` plus process cleanup stops the background instance | No | +| Cleanup path | Direct termination from Block stops the owned background instance; a final cleanup helper handles test failures | No | | Sleep/display cleanup | `powercfg /requests` no longer mentions Wardoff after cleanup, when the session allows that query | No | The smoke test checks status visibility for local shutdown handling, but that is **not** the same as promising reliable interception of all local shutdown paths. +For an explicit cross target, set `CARGO_BUILD_TARGET=x86_64-pc-windows-msvc` +in the test process environment. The smoke script uses that target for its build +and resolves `target/x86_64-pc-windows-msvc/release/wardoff.exe` accordingly. + ## Manual non-disruptive checks Use a normal desktop session for these checks. They should not require actually shutting down, rebooting, sleeping, or hibernating the machine. @@ -95,14 +101,92 @@ Do these only in a disposable VM or similarly safe environment. | Area | Check | Expected result | | --- | --- | --- | | Layer 1 interactive shutdown | With Wardoff in Block mode, attempt a normal interactive shutdown or sign-out from Windows | Windows gives Wardoff the chance to block the interactive path | -| Windows Sleep blocking | With Wardoff in Block mode, trigger Sleep from the Windows power menu | The VM stays awake while Block mode is active | -| Windows Hibernate blocking | With Wardoff in Block mode, trigger Hibernate from the Windows power menu | The VM does not hibernate while Block mode is active | +| Windows explicit Sleep limit | With Wardoff in Block mode, trigger Sleep from the Windows power menu on a sleep-capable test machine | Windows may sleep; Wardoff does not veto the request. After resume, check that Block's idle-power requests are acquired again | +| Windows explicit Hibernate limit | With Wardoff in Block mode, trigger Hibernate on a test machine where it is already available | Windows may hibernate; Wardoff does not veto the request. After resume, check the mode, requests and logs | | Tray Sleep wake-restore | With Wardoff in Block mode, trigger Sleep from the tray menu, then wake the VM | Wardoff temporarily switches to Allow so Sleep can proceed, then returns to Block after wake and records the wake restore behavior with a `block_restored_after_wake` log event | | Tray Hibernate wake-restore | With Wardoff in Block mode, trigger Hibernate from the tray menu, then resume the VM | Wardoff temporarily switches to Allow so Hibernate can proceed, then returns to Block after resume and records the wake restore behavior with a `block_restored_after_wake` log event | -| Allow-mode release | Switch to Allow mode and retry Sleep or Hibernate | Windows proceeds normally once Wardoff is no longer blocking | +| Allow-mode release | Switch to Allow while the runtime remains alive, then wait for idle sleep/display timeout | Wardoff's requests are absent; Windows follows its existing settings and any requests from other applications | | Remote shutdown abort | From a second machine or remote management session, issue a timed remote shutdown while Wardoff is running elevated in Block mode | Wardoff attempts to abort the shutdown while Windows still exposes an abortable timeout window | | Tray power actions | If validating tray Shutdown or Reboot directly, do it only in the VM | The action reaches Windows without risking the host workstation | +## Idle-power behavior and Windows limits + +This is the supported contract, not a claim that every row was physically tested. +`blocked` means automatic inactivity prevention while Windows honors the requests; +it never means a universal veto. Request visibility alone is not a sleep test. + +| Transition / environment | Previous thread execution-state backend | Power Request backend | Guarantee | +| --- | --- | --- | --- | +| Automatic idle sleep on S3 | Idle prevention | System-required request | **blocked**, subject to policy | +| Automatic display timeout | Display-idle prevention | Display-required request, paired with system-required | **blocked**, subject to policy | +| Automatic hibernation | No general S4 veto | No general S4 veto | **cannot guarantee**; preventing initial idle sleep can indirectly avoid a later S3-to-S4 transition | +| Explicit Start-menu Sleep | User request takes precedence | User request takes precedence | **unsupported** as a veto | +| Explicit Hibernate | No documented veto | No documented veto | **unsupported** as a veto | +| Power button configured for Sleep | No guaranteed veto | No guaranteed veto | **unsupported** as a veto | +| Lid configured for Sleep | No guaranteed veto | No guaranteed veto | **unsupported** as a veto | +| Traditional S3 | Idle prevention | Idle prevention | Requires a capable hardware test | +| Modern Standby / AC | No universal claim from the old backend | Same request pair, governed by Windows | **best effort**; validate on Modern Standby hardware | +| Modern Standby / DC | No unlimited guarantee established | System-required requests end five minutes after the sleep timeout expires | **best effort**, not five minutes after activating Block | +| Critical battery / thermal or other safety transition | No guarantee | No guarantee | **cannot guarantee** | +| Screen saver, lock or explicit display-off action | No guarantee | Outside the idle-display contract | **unsupported** | +| Policy ignores requests / existing request override | No guarantee | No guarantee | **cannot guarantee**; Wardoff does not change the policy | +| Away Mode | Not requested | Not requested | No **redirected/substituted** transition is claimed | + +### Microsoft evidence and rejected alternatives + +- [SetThreadExecutionState](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setthreadexecutionstate) documents the inactivity flags, continuous lifetime, explicit-user-sleep limit and lack of screen-saver blocking. Periodic refresh does not turn this into a veto. +- [PowerSetRequest](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-powersetrequest) defines the request types, explicit-sleep termination and Modern Standby battery timeout. Execution-required requests concern process lifetime and add no general sleep veto. +- [PowerCreateRequest](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-powercreaterequest) and [PowerClearRequest](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-powerclearrequest) define object ownership and balanced counts. [Process termination](https://learn.microsoft.com/en-us/windows/win32/procthread/terminating-a-process) closes process handles. The combination supports cleanup on crash for Wardoff's private, non-shared handle; verify this separately from orderly cleanup. +- [Allow away mode](https://learn.microsoft.com/en-us/windows-hardware/customize/power-settings/sleep-settings-allow-away-mode) can disable Away Mode even when requested. On eligible S3 systems it substitutes apparent sleep with video/audio off, not a veto; it has no Modern Standby coverage or documented general Hibernate veto. It is inappropriate for the single predictable Block/Allow contract and is not enabled. Reading capabilities and AC/DC policy could diagnose eligibility; Wardoff does not add that machinery for an excluded feature. +- [Allow system required requests](https://learn.microsoft.com/en-us/windows-hardware/customize/power-settings/sleep-settings-allow-system-required-requests) and [powercfg](https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/powercfg-command-line-options) explain policy rejection, request listing and overrides. A successful API call does not override those settings. +- [Hibernate idle timeout](https://learn.microsoft.com/en-us/windows-hardware/customize/power-settings/sleep-settings-hibernate-idle-timeout) describes the separate later hibernation transition. +- [PBT_APMQUERYSUSPEND](https://learn.microsoft.com/en-us/windows/win32/power/pbt-apmquerysuspend) and [PBT_APMQUERYSUSPENDFAILED](https://learn.microsoft.com/en-us/windows/win32/power/pbt-apmquerysuspendfailed) lost support in Vista; neither is a modern veto. +- [GetPwrCapabilities](https://learn.microsoft.com/en-us/windows/win32/api/powerbase/nf-powerbase-getpwrcapabilities) and [SYSTEM_POWER_CAPABILITIES](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-system_power_capabilities) provide a documented capability query if later diagnostics need one. No architecture-specific backend is needed now: `powercfg /a` identifies the test machine, and the runtime uses the same two requests without a capability-detection subsystem. + +### Hardware procedure (manual, not CI) + +Use a prepared test PC, or a disposable VM only if it exposes the required power +states. Do not run disruptive tests on a workstation with unsaved work. Do not +enable Hibernate, change button/lid actions, set request overrides or edit a +power plan merely to make a row pass; use an already suitable test setup. + +1. Record Windows build, hardware, AC/DC source, binary hash and commit. Capture + `powercfg /a`, `/getactivescheme`, `/query`, `/requestsoverride` (without + arguments, read-only) and `/requests`. Record other applications' requests. +2. In Allow, confirm Wardoff remains alive with `layers.sleep=false` and no + Wardoff requests. Establish the existing idle/display behavior without + keyboard, mouse or remote-session activity that would reset the timers. +3. Enter Block. Confirm `layers.sleep=true` and the reason + `Wardoff Block mode: prevent idle sleep and automatic display timeout.` + in both requests. Wait past the configured idle/display timeouts; record + actual behavior, not just request listing. Repeat Block/Allow/Block. +4. Request Sleep from Windows, then wake the machine. Check mode, request pair + and logs; repeat for an already configured Sleep button, lid, and Hibernate. + Explicit sleep proceeding is expected, not a failed veto test. Record whether + the first observed resume renews the pair and whether subsequent notifications + leave exactly one pair. No real transition is proven by synthetic messages. +5. Test the existing tray Sleep/Hibernate flow from Block and from Allow. Block + should temporarily become Allow, then restore on wake; Allow should remain + Allow. Check `block_restored_after_wake` and request presence. Test an explicit + Allow command cancelling a pending restore where the timing permits it. +6. Include unattended/maintenance wake separately. This change retains the + existing resume notification semantics; it does not promise a new + presence-aware or display-aware policy. Record unexpected display behavior + rather than silently widening the resume system. +7. Repeat applicable cases on Modern Standby with AC and DC, including an AC/DC + change. Observe beyond the configured sleep timeout plus five minutes. Never + refresh in a loop to evade Windows' battery policy. Record missing hardware + as **not tested**, not as a pass or a claim that Modern Standby is unsupported. +8. Quit normally directly from Block, then separately terminate an owned test + process directly from Block. Verify its requests disappear in both cases. + Compare the power settings captured in step 1; no power setting should change. + Existing UpdateOrchestrator/autostart behavior is outside this sleep-layer + guarantee and should be tested separately in its documented environment. + +Power-request acquisition should also be exercised non-elevated; these APIs do +not document a special elevation requirement. Elevation needed to inspect +`powercfg /requests` is a diagnostic requirement, not a runtime requirement. + ## Acceptance checklist - [ ] `tests\smoke_test.ps1` passes, with any skips noted diff --git a/docs/OUTREACH_DRAFTS.md b/docs/OUTREACH_DRAFTS.md index eec7164..48b5b05 100644 --- a/docs/OUTREACH_DRAFTS.md +++ b/docs/OUTREACH_DRAFTS.md @@ -1,6 +1,14 @@ # Outreach drafts -Internal reference drafts for a small early-adopter outreach wave. Keep these aligned with `README.md`, `PLAN.md`, `docs/EARLY_ADOPTER_OUTREACH_PLAN.md`, and `docs/BUSINESS_MODEL.md`. Here's the project : https://github.com/RomainROCH/Wardoff +Internal reference drafts for a small early-adopter outreach wave. Keep these aligned with `README.md`, `PLAN.md`, `docs/MVP_VALIDATION_MATRIX.md`, and `docs/BUSINESS_MODEL.md`. Here's the project : https://github.com/RomainROCH/Wardoff + +## AlternativeTo (recommended copy; not published) + +**Short description:** Open-source Windows shutdown/reboot protection and idle-sleep prevention. + +**Description:** Wardoff is a lightweight native Windows utility written in Rust, with a tray icon and CLI for switching between Block and Allow. It combines interactive shutdown/sign-out protection, Windows Update reboot-task protection, best-effort remote shutdown aborts, and prevention of idle sleep and automatic display timeout. Power protection is subject to Windows policy: Wardoff does not veto explicit Sleep or Hibernate, configured lid/button actions, or safety transitions. Modern Standby battery limits also apply. The power-request layer makes no persistent changes to Windows power settings. + +**Tags to retain where available:** Windows, shutdown blocker, reboot prevention, prevent idle sleep, keep awake, system tray, command line, open source. The existing `prevent-sleep` tag remains justified for idle sleep when the description makes that scope explicit. Avoid tags claiming universal hibernation blocking or screen-saver prevention. This is a recommendation for the existing listing; no external listing was edited. ## r/rust @@ -8,7 +16,7 @@ Internal reference drafts for a small early-adopter outreach wave. Keep these al I built Wardoff as a Windows-only Rust utility for a narrow problem: keeping a machine in a visible Block state when I do not want Windows to shut down, sign out, sleep, or reboot unattended. -The current MVP is source-first and already has a tray app plus CLI. Today it does interactive shutdown/sign-out blocking, protects the `\Microsoft\Windows\UpdateOrchestrator\Reboot` task when that task exists, does best-effort remote shutdown abort polling, blocks sleep / hibernate / display idle, and writes structured JSONL logs. It is meant for technically comfortable Windows users, not as a polished mass-market app. +The current MVP is source-first and already has a tray app plus CLI. Today it does interactive shutdown/sign-out blocking, protects the `\Microsoft\Windows\UpdateOrchestrator\Reboot` task when that task exists, does best-effort remote shutdown abort polling, prevents idle sleep and automatic display timeout, subject to Windows power policy, and writes structured JSONL logs. It is meant for technically comfortable Windows users, not as a polished mass-market app. I am trying to keep the claims narrow and honest. I do **not** claim Wardoff stops `shutdown /t 0 /f`, and I do not present local `shutdown.exe` handling as solved. Right now the useful part is the conservative Windows behavior, the visible Block/Allow state, and the scriptable control surface. @@ -20,7 +28,7 @@ I would especially value feedback from Rust people who care about Windows API ed I built Wardoff because I wanted something simple and inspectable for the old problem of leaving a job running and coming back to find Windows restarted or went to sleep at the wrong time. -It is a Windows-only, source-first MIT project with a tray app and CLI. The current MVP gives me a visible Block/Allow state, autostart through Task Scheduler, machine-readable status output, and structured JSONL logs. It currently covers interactive shutdown/sign-out blocking, Update Orchestrator reboot-task protection when that scheduled task exists, best-effort remote shutdown abort polling, and sleep / hibernate / display-idle blocking. +It is a Windows-only, source-first MIT project with a tray app and CLI. The current MVP gives me a visible Block/Allow state, autostart through Task Scheduler, machine-readable status output, and structured JSONL logs. It currently covers interactive shutdown/sign-out blocking, Update Orchestrator reboot-task protection when that scheduled task exists, best-effort remote shutdown abort polling, and idle-sleep and automatic display-timeout prevention. I am deliberately keeping the wording narrow: I do **not** claim it stops `shutdown /t 0 /f`, and I am not presenting it as a polished enterprise product. The core behavior stays free and open; signed binaries are planned later as a convenience, not a paywall. @@ -42,6 +50,6 @@ If you try tools like this on Windows and have opinions about where the rough ed **Title:** `Show HN: Wardoff – Open-source Windows shutdown blocker in Rust` -I built Wardoff, a Windows-only Rust utility for keeping a machine in a visible Block state when I do not want shutdown, sign-out, sleep, hibernate, or certain reboot paths to interrupt work. The current MVP has a tray app, CLI control, structured JSONL logs, interactive shutdown/sign-out blocking, Update Orchestrator reboot-task protection when that task exists, remote shutdown abort polling, and sleep / hibernate / display-idle blocking. +I built Wardoff, a Windows-only Rust utility for keeping a machine in a visible Block state when I do not want supported shutdown, sign-out, idle-sleep, or reboot paths to interrupt work. The current MVP has a tray app, CLI control, structured JSONL logs, interactive shutdown/sign-out blocking, Update Orchestrator reboot-task protection when that task exists, remote shutdown abort polling, and idle-sleep and automatic display-timeout prevention. I am keeping the claims narrow: it is source-first today. An unsigned binary is also available on the Releases page. It is aimed at technically comfortable early adopters, and it does **not** claim to stop `shutdown /t 0 /f`. Source is MIT and free. Signed binaries are planned later as a convenience purchase, not a paywall. I would value feedback on Windows edge cases, docs clarity, and whether the current tray + CLI shape is the right one. Here's the project : https://github.com/RomainROCH/Wardoff diff --git a/docs/RELEASE_NOTES_DRAFT.md b/docs/RELEASE_NOTES_DRAFT.md index d049828..46e9ee3 100644 --- a/docs/RELEASE_NOTES_DRAFT.md +++ b/docs/RELEASE_NOTES_DRAFT.md @@ -28,7 +28,7 @@ ## Known limits - Layer 3 (UpdateOrchestrator) is automatically skipped on LTSC editions that lack the `UpdateOrchestrator\Reboot` scheduled task. This is normal. -- Sleep/hibernate blocking requires hardware support for S1-S3 standby states. Hyper-V VMs typically do not support these. +- Idle-power requests support S3 and Modern Standby, subject to Windows power policy. Explicit Sleep/Hibernate, lid and power-button actions are not vetoed. Hardware sleep behavior must be tested on a capable machine; VM request visibility alone is not proof. - `shutdown /t 0 /f` is not guaranteed blockable. This is a Windows limitation, not a Wardoff bug. - The product is source-first: no installer, no package manager distribution yet diff --git a/docs/WINDOWS_SHUTDOWN_LAYERS.md b/docs/WINDOWS_SHUTDOWN_LAYERS.md index b3f4108..27c82bd 100644 --- a/docs/WINDOWS_SHUTDOWN_LAYERS.md +++ b/docs/WINDOWS_SHUTDOWN_LAYERS.md @@ -12,7 +12,7 @@ Wardoff uses multiple layers because Windows shutdown, sign-out, reboot, sleep, | Layer 2 | Local `shutdown.exe` detection and best-effort abort | Implemented in code | Present in repo, but not promoted as a supported 0.1.0 headline feature | `src/blocker/local.rs`, `src/blocker/abort.rs`, `src/blocker/mod.rs` | Cannot honestly promise to stop `shutdown /t 0 /f` | | Layer 3 | Windows Update reboot-task protection | Implemented | Supported MVP behavior | `src/blocker/update.rs`, `src/blocker/mod.rs` | Requires elevation and periodic re-checks | | Layer 4 | Remote shutdown abort polling | Implemented | Supported MVP behavior | `src/blocker/remote.rs`, `src/blocker/abort.rs`, `src/blocker/mod.rs` | Only works while Windows still exposes an abortable window | -| Separate power-state guard | Sleep, hibernate, and display-idle prevention | Implemented | Supported MVP behavior | `src/blocker/sleep.rs`, `src/blocker/mod.rs` | Uses execution-state requests, not a shutdown veto | +| Separate power-state guard | Idle-sleep and automatic display-timeout prevention | Implemented | Subject to Windows power policy | `src/blocker/sleep.rs`, `src/blocker/mod.rs` | Power Requests do not veto explicit Sleep/Hibernate | `src/blocker/mod.rs` wires the runtime together through `BlockerCoordinator`, which activates Layer 2, Layer 3, Layer 4, sleep blocking, and finally Layer 1 when entering Block mode, then tears them down in reverse order when returning to Allow mode. @@ -24,7 +24,7 @@ Windows treats these as different categories of behavior: - local command-line shutdowns can start through `shutdown.exe` - Windows Update uses scheduled-task and service-driven restart flows - remote shutdown requests can create a pending shutdown that another process may still abort -- sleep and display-idle prevention are handled through execution-state hints instead of shutdown negotiation +- idle sleep and display-timeout prevention use Power Requests instead of shutdown negotiation Because of that split, one mechanism is never enough. @@ -194,23 +194,25 @@ Layer 4 helps only when: It does not rewind a shutdown after the no-return point. -## Separate power-state guard — Sleep, hibernate, and display idle +## Separate power-state guard — Idle sleep and display timeout This is not a shutdown layer, but it is a real part of the current runtime. Source: `src/blocker/sleep.rs` -- starts a dedicated worker thread in Block mode -- calls `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED)` -- refreshes the request every 30 seconds -- clears the request with `SetThreadExecutionState(ES_CONTINUOUS)` when Block mode ends -- logs activation, refresh failures, and cleanup failures to the JSONL logger +- synchronously acquires system-required and display-required Power Requests on one private handle +- rolls back partial acquisition and reports API errors +- clears acquired requests and closes the handle when Block mode ends +- uses the existing resume callback to replace requests terminated by explicit sleep; tray actions retain their existing Allow-to-Block restoration +- logs acquisition and cleanup failures to the JSONL logger Current scope: -- sleep blocking -- hibernate blocking -- display-idle prevention +- idle-sleep and automatic display-timeout prevention, subject to Windows policy +- no veto over explicit Sleep, Hibernate, configured lid/button actions, or critical battery transitions +- no Away Mode, periodic refresh, persistent power-setting changes, or extra resume state machine + +See [architecture](ARCHITECTURE.md#idle-power-request-ownership-intentional-architecture-change) for ownership and [validation limits](MVP_VALIDATION_MATRIX.md#idle-power-behavior-and-windows-limits) for S3/Modern Standby behavior and hardware tests. ## Coordinator and lifecycle wiring @@ -239,7 +241,7 @@ Supported and safe to describe as current user-facing MVP behavior: - Layer 1 interactive shutdown/sign-out blocking - Layer 3 Update Orchestrator reboot-task protection - Layer 4 remote shutdown abort polling -- sleep, hibernate, and display-idle prevention +- idle-sleep and automatic display-timeout prevention, subject to Windows power policy Present in source, but documented more cautiously: diff --git a/src/blocker/mod.rs b/src/blocker/mod.rs index 870da04..bdc3b2b 100644 --- a/src/blocker/mod.rs +++ b/src/blocker/mod.rs @@ -23,7 +23,7 @@ pub mod local; pub mod remote; /// Scaffolding for Layer 1 interactive shutdown blocking. pub mod shutdown; -/// Scaffolding for SetThreadExecutionState-based sleep blocking. +/// Process-owned requests for idle-sleep and automatic display-timeout prevention. pub mod sleep; /// Scaffolding for Layer 3 UpdateOrchestrator reboot protection. pub mod update; @@ -50,6 +50,7 @@ pub struct LayerStatus { local_shutdown: bool, update: bool, remote: bool, + /// Whether Wardoff acquired its request pair, not a guarantee against explicit sleep. sleep: bool, } @@ -144,6 +145,14 @@ impl BlockerCoordinator { BLOCKED_EVENT_COUNT.load(Ordering::Relaxed) } + /// Renews idle-power requests only when Block remains the requested mode. + pub(crate) fn renew_sleep_requests_after_resume(&mut self) -> Result<(), String> { + if self.mode == BlockerMode::Block { + self.sleep_blocker.renew_after_resume()?; + } + Ok(()) + } + /// Disables the layered shutdown and sleep blocking scaffolding. pub fn deactivate(&mut self) -> Result<(), String> { self.set_mode(BlockerMode::Allow).map(|_| ()) diff --git a/src/blocker/shutdown.rs b/src/blocker/shutdown.rs index f1024ed..1d38410 100644 --- a/src/blocker/shutdown.rs +++ b/src/blocker/shutdown.rs @@ -87,15 +87,15 @@ impl ShutdownBlocker { // Since Windows 8, PBT_APMSUSPEND/PBT_APMRESUMEAUTOMATIC/PBT_APMRESUMESUSPEND are no longer // broadcast to top-level windows automatically; the recipient must register explicitly, // otherwise WM_POWERBROADCAST never reaches `shutdown_window_proc` for those events. - let suspend_resume_notification = - match register_suspend_resume_notification(session_window) { - Ok(handle) => Some(handle), - Err(error) => { - destroy_window(session_window); - destroy_window(message_window); - return Err(error); - } - }; + let suspend_resume_notification = match register_suspend_resume_notification(session_window) + { + Ok(handle) => Some(handle), + Err(error) => { + destroy_window(session_window); + destroy_window(message_window); + return Err(error); + } + }; Ok(Self { message_window, diff --git a/src/blocker/sleep.rs b/src/blocker/sleep.rs index 14c1abb..4e37afc 100644 --- a/src/blocker/sleep.rs +++ b/src/blocker/sleep.rs @@ -1,87 +1,82 @@ use crate::logger::{self, EventSource}; -use log::{error, info, warn}; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - mpsc::{self, Receiver, RecvTimeoutError, Sender}, - Arc, -}; -use std::thread::{self, JoinHandle}; -use std::time::Duration; -use windows::core::{Error as WindowsError, Result as WindowsResult}; +use log::{info, warn}; +use windows::core::{w, PWSTR}; +use windows::Win32::Foundation::{CloseHandle, HANDLE}; use windows::Win32::System::Power::{ - SetThreadExecutionState, ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED, + PowerClearRequest, PowerCreateRequest, PowerRequestDisplayRequired, PowerRequestSystemRequired, + PowerSetRequest, POWER_REQUEST_TYPE, +}; +use windows::Win32::System::SystemServices::POWER_REQUEST_CONTEXT_VERSION; +use windows::Win32::System::Threading::{ + POWER_REQUEST_CONTEXT_SIMPLE_STRING, REASON_CONTEXT, REASON_CONTEXT_0, }; -const SLEEP_BLOCKER_THREAD_NAME: &str = "wardoff-sleep-blocker"; +const REQUEST_TYPES: [POWER_REQUEST_TYPE; 2] = + [PowerRequestSystemRequired, PowerRequestDisplayRequired]; -/// Coordinates SetThreadExecutionState-based sleep and hibernate blocking. +/// Owns idle-sleep/display-timeout requests, not a veto over explicit Sleep/Hibernate. +#[derive(Default)] pub struct SleepBlocker { - worker: Option, - active_state: Arc, + requests: PowerRequests, } impl SleepBlocker { - /// Starts sleep, hibernate, and display-idle blocking if it is not already active. + /// Acquires both requests synchronously, rolling back any partial acquisition. pub fn activate(&mut self) -> Result<(), String> { - if self.worker.is_some() { + if self.is_active() { return Ok(()); } - - let (stop_tx, stop_rx) = mpsc::channel(); - let active_state = Arc::clone(&self.active_state); - - match thread::Builder::new() - .name(SLEEP_BLOCKER_THREAD_NAME.to_string()) - .spawn(move || run_worker(active_state, stop_rx)) - { - Ok(join_handle) => { - self.worker = Some(SleepWorker { - stop_tx, - join_handle: Some(join_handle), - }); - Ok(()) - } - Err(error) => Err(format!( - "Sleep blocking could not start its worker thread: {error}" - )), + if let Err(error) = self.requests.activate() { + warn!("Could not acquire idle-power requests: {error}"); + logger::log_event("sleep_blocked", EventSource::Sleep, &error, false); + return Err(error); } + let message = "Wardoff acquired system/display Power Requests for idle-sleep and automatic display-timeout prevention. Windows policy and explicit Sleep/Hibernate can override this protection."; + info!("{message}"); + // Keep the existing event name for log consumers; success means request + // acquisition, not that an actual sleep transition was observed or vetoed. + logger::log_event("sleep_blocked", EventSource::Sleep, message, true); + Ok(()) } - /// Stops the worker thread and clears the execution-state request on the same thread. + /// Releases the requests and closes their private process-owned handle. pub fn deactivate(&mut self) { - self.begin_forced_shutdown_cleanup(); - - let Some(mut worker) = self.worker.take() else { + if self.requests.handle.is_none() { return; - }; - - if let Some(join_handle) = worker.join_handle.take() { - if join_handle.join().is_err() { - error!("Sleep-blocking worker thread panicked while stopping"); - logger::log_event( - "sleep_unblocked", - EventSource::Sleep, - "Sleep-blocking worker thread panicked while stopping.", - false, - ); + } + match self.requests.release() { + Ok(()) => logger::log_event( + "sleep_unblocked", + EventSource::Sleep, + "Wardoff released its idle-power requests and closed the request handle.", + true, + ), + Err(error) => { + warn!("Idle-power request cleanup failed: {error}"); + logger::log_event("sleep_unblocked", EventSource::Sleep, error, false); } } } - /// Requests the sleep-blocking worker to clear its execution-state request and exit. + /// Releases requests immediately without a worker thread to join. pub(crate) fn begin_forced_shutdown_cleanup(&mut self) { - let Some(worker) = self.worker.take() else { - return; - }; + self.deactivate(); + } - self.active_state.store(false, Ordering::Release); - let _ = worker.stop_tx.send(()); - self.worker = Some(worker); + /// Renews requests after the existing runtime resume notification. + pub(crate) fn renew_after_resume(&mut self) -> Result<(), String> { + // PowerSetRequest documents termination at user-initiated sleep entry: + // https://learn.microsoft.com/windows/win32/api/winbase/nf-winbase-powersetrequest + // The handle need not be invalid. Close the old object instead of + // incrementing possibly surviving counts or clearing already-ended ones. + // Tray power actions already reacquire via their Allow -> Block transition. + self.requests.close()?; + self.activate() } - /// Returns whether the sleep-blocking worker thread is currently active. + /// Reports acquisition of the pair; Windows policy may still ignore it. pub fn is_active(&self) -> bool { - self.active_state.load(Ordering::Acquire) + self.requests.is_active() } } @@ -91,131 +86,318 @@ impl Drop for SleepBlocker { } } -impl Default for SleepBlocker { +// Small private Win32 boundary for fault-injection tests. The production owner +// is monomorphized; there is no worker, shared state, or dynamic dispatch. +trait PowerApi { + type Handle: Copy; + fn create(&self) -> Result; + fn set(&self, handle: Self::Handle, kind: POWER_REQUEST_TYPE) -> Result<(), String>; + fn clear(&self, handle: Self::Handle, kind: POWER_REQUEST_TYPE) -> Result<(), String>; + fn close(&self, handle: Self::Handle) -> Result<(), String>; +} + +struct PowerRequests { + api: A, + handle: Option, + acquired: [bool; 2], +} + +impl Default for PowerRequests { fn default() -> Self { Self { - worker: None, - active_state: Arc::new(AtomicBool::new(false)), + api: A::default(), + handle: None, + acquired: [false; 2], } } } -/// Enables the execution state requests that keep the system awake while blocking is active. -pub fn enable_sleep_block() -> WindowsResult<()> { - let execution_state = ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED; +impl PowerRequests { + fn is_active(&self) -> bool { + self.handle.is_some() && self.acquired.iter().all(|acquired| *acquired) + } - if unsafe { SetThreadExecutionState(execution_state) }.0 == 0 { - Err(WindowsError::from_thread()) - } else { + fn activate(&mut self) -> Result<(), String> { + if self.is_active() { + return Ok(()); + } + let handle = self.api.create()?; + self.handle = Some(handle); + for (index, kind) in REQUEST_TYPES.into_iter().enumerate() { + if let Err(error) = self.api.set(handle, kind) { + return match self.release() { + Ok(()) => Err(error), + Err(cleanup_error) => Err(format!("{error}; cleanup: {cleanup_error}")), + }; + } + self.acquired[index] = true; + } Ok(()) } -} -/// Disables the execution state requests that were used to keep the system awake. -pub fn disable_sleep_block() -> WindowsResult<()> { - if unsafe { SetThreadExecutionState(ES_CONTINUOUS) }.0 == 0 { - Err(WindowsError::from_thread()) - } else { - Ok(()) + fn release(&mut self) -> Result<(), String> { + let mut errors = Vec::new(); + if let Some(handle) = self.handle { + for (kind, acquired) in REQUEST_TYPES.into_iter().zip(self.acquired) { + if acquired { + if let Err(error) = self.api.clear(handle, kind) { + errors.push(error); + } + } + } + } + // Closing still releases the object if a clear failed. Always attempt + // both types and CloseHandle, including after a partial acquisition. + if let Err(error) = self.close() { + errors.push(error); + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } } -} -/// Returns the interval used to refresh the execution state request. -pub fn refresh_interval() -> Duration { - Duration::from_secs(30) + fn close(&mut self) -> Result<(), String> { + self.acquired = [false; 2]; + match self.handle.take() { + Some(handle) => self.api.close(handle), + None => Ok(()), + } + } } -struct SleepWorker { - stop_tx: Sender<()>, - join_handle: Option>, +impl Drop for PowerRequests { + fn drop(&mut self) { + if let Err(error) = self.release() { + warn!("Idle-power request destruction failed: {error}"); + } + } } -struct ActiveStateGuard(Arc); +#[derive(Default)] +struct WindowsPowerApi; + +impl PowerApi for WindowsPowerApi { + type Handle = HANDLE; -impl ActiveStateGuard { - fn activate(active_state: Arc) -> Self { - active_state.store(true, Ordering::Release); - Self(active_state) + fn create(&self) -> Result { + let reason = w!("Wardoff Block mode: prevent idle sleep and automatic display timeout."); + let context = REASON_CONTEXT { + Version: POWER_REQUEST_CONTEXT_VERSION, + Flags: POWER_REQUEST_CONTEXT_SIMPLE_STRING, + Reason: REASON_CONTEXT_0 { + // Microsoft explicitly permits read-only strings here; the API + // only reads this static, NUL-terminated UTF-16 reason. + SimpleReasonString: PWSTR(reason.as_ptr().cast_mut()), + }, + }; + unsafe { PowerCreateRequest(&context) } + .map_err(|error| format!("PowerCreateRequest failed: {error}")) } -} -impl Drop for ActiveStateGuard { - fn drop(&mut self) { - self.0.store(false, Ordering::Release); + fn set(&self, handle: HANDLE, kind: POWER_REQUEST_TYPE) -> Result<(), String> { + unsafe { PowerSetRequest(handle, kind) } + .map_err(|error| format!("PowerSetRequest({kind:?}) failed: {error}")) + } + + fn clear(&self, handle: HANDLE, kind: POWER_REQUEST_TYPE) -> Result<(), String> { + unsafe { PowerClearRequest(handle, kind) } + .map_err(|error| format!("PowerClearRequest({kind:?}) failed: {error}")) + } + + fn close(&self, handle: HANDLE) -> Result<(), String> { + unsafe { CloseHandle(handle) }.map_err(|error| format!("CloseHandle failed: {error}")) } } -fn run_worker(active_state: Arc, stop_rx: Receiver<()>) { - if let Err(error) = enable_sleep_block() { - warn!( - "Sleep blocking could not activate SetThreadExecutionState; skipping sleep, hibernate, and display-idle protection: {error}" - ); - logger::log_event( - "sleep_blocked", - EventSource::Sleep, - format!( - "Wardoff could not activate SetThreadExecutionState and skipped sleep, hibernate, and display-idle blocking: {error}" - ), - false, - ); - return; - } - - let _active_guard = ActiveStateGuard::activate(active_state); - info!( - "Sleep, hibernate, and display-idle blocking are active and will refresh SetThreadExecutionState every {} seconds while Block mode is active.", - refresh_interval().as_secs() - ); - logger::log_event( - "sleep_blocked", - EventSource::Sleep, - format!( - "Wardoff activated SetThreadExecutionState refresh every {} seconds to block sleep, hibernate, and display idle.", - refresh_interval().as_secs() - ), - true, - ); - let mut stopped_cleanly = true; - - loop { - match stop_rx.recv_timeout(refresh_interval()) { - Ok(()) | Err(RecvTimeoutError::Disconnected) => break, - Err(RecvTimeoutError::Timeout) => { - if let Err(error) = enable_sleep_block() { - warn!( - "Sleep blocking stopped after its SetThreadExecutionState refresh failed: {error}" - ); - logger::log_event( - "sleep_blocked", - EventSource::Sleep, - format!( - "Wardoff stopped refreshing SetThreadExecutionState after an error: {error}" - ), - false, - ); - stopped_cleanly = false; - break; - } +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::collections::BTreeMap; + use std::rc::Rc; + + #[derive(Clone, Copy, Debug, PartialEq)] + enum Call { + Create, + Set(i32), + Clear(i32), + Close, + } + + #[derive(Default)] + struct State { + calls: Vec, + failures: Vec, + next_handle: u32, + live: BTreeMap, + } + + #[derive(Clone, Default)] + struct FakeApi(Rc>); + + impl FakeApi { + fn record(&self, call: Call) -> Result<(), String> { + let mut state = self.0.borrow_mut(); + state.calls.push(call); + if state.failures.contains(&call) { + Err(format!("injected {call:?} failure")) + } else { + Ok(()) } } } - if let Err(error) = disable_sleep_block() { - warn!( - "Sleep blocking could not clear its SetThreadExecutionState request during shutdown: {error}" - ); - logger::log_event( - "sleep_unblocked", - EventSource::Sleep, - format!("Wardoff could not clear SetThreadExecutionState during shutdown: {error}"), - false, - ); - } else if stopped_cleanly { - logger::log_event( - "sleep_unblocked", - EventSource::Sleep, - "Wardoff cleared SetThreadExecutionState as Block mode ended.", - true, - ); + impl PowerApi for FakeApi { + type Handle = u32; + fn create(&self) -> Result { + self.record(Call::Create)?; + let mut state = self.0.borrow_mut(); + state.next_handle += 1; + let handle = state.next_handle; + state.live.insert(handle, [0; 2]); + Ok(handle) + } + fn set(&self, handle: u32, kind: POWER_REQUEST_TYPE) -> Result<(), String> { + self.record(Call::Set(kind.0))?; + self.0.borrow_mut().live.get_mut(&handle).unwrap()[kind.0 as usize] += 1; + Ok(()) + } + fn clear(&self, handle: u32, kind: POWER_REQUEST_TYPE) -> Result<(), String> { + self.record(Call::Clear(kind.0))?; + let mut state = self.0.borrow_mut(); + let count = &mut state.live.get_mut(&handle).unwrap()[kind.0 as usize]; + assert!(*count > 0, "must not clear an unacquired request"); + *count -= 1; + Ok(()) + } + fn close(&self, handle: u32) -> Result<(), String> { + self.record(Call::Close)?; + assert!(self.0.borrow_mut().live.remove(&handle).is_some()); + Ok(()) + } + } + + fn requests() -> (PowerRequests, FakeApi) { + let requests = PowerRequests::::default(); + let api = requests.api.clone(); + (requests, api) + } + + #[test] + fn activation_reports_an_acquired_request_before_returning() { + // Non-disruptive Win32 integration: no sleep, policy writes or elevation. + let mut blocker = SleepBlocker::default(); + for _ in 0..10 { + blocker + .activate() + .expect("power requests should be available"); + assert!(blocker.is_active()); + blocker + .renew_after_resume() + .expect("renew the owned requests"); + assert!(blocker.is_active()); + blocker.deactivate(); + assert!(!blocker.is_active()); + } + } + + #[test] + fn repeated_block_allow_cycles_balance_counts_and_close_objects() { + let (mut requests, api) = requests(); + for _ in 0..100 { + requests.activate().unwrap(); + requests.activate().unwrap(); + assert!(requests.is_active()); + assert_eq!( + api.0.borrow().live.values().copied().collect::>(), + vec![[1, 1]] + ); + requests.release().unwrap(); + requests.release().unwrap(); + assert!(!requests.is_active()); + assert!(api.0.borrow().live.is_empty()); + } + } + + #[test] + fn failed_creation_does_not_claim_protection_or_close_an_invalid_handle() { + let (mut requests, api) = requests(); + api.0.borrow_mut().failures = vec![Call::Create]; + assert!(requests.activate().is_err()); + assert!(!requests.is_active()); + drop(requests); + assert_eq!(api.0.borrow().calls, vec![Call::Create]); + } + + #[test] + fn either_set_failure_rolls_back_only_acquired_requests_and_can_retry() { + for kind in REQUEST_TYPES { + let (mut requests, api) = requests(); + api.0.borrow_mut().failures = vec![Call::Set(kind.0)]; + assert!(requests.activate().is_err()); + assert!(!requests.is_active()); + assert!(api.0.borrow().live.is_empty()); + assert!(!api.0.borrow().calls.contains(&Call::Clear(kind.0))); + api.0.borrow_mut().failures.clear(); + requests.activate().unwrap(); + assert!(requests.is_active()); + drop(requests); + assert!(api.0.borrow().live.is_empty()); + } + } + + #[test] + fn cleanup_continues_after_clear_errors_and_reports_each_error() { + let (mut requests, api) = requests(); + requests.activate().unwrap(); + api.0.borrow_mut().failures = vec![Call::Clear(1), Call::Clear(0)]; + let error = requests.release().unwrap_err(); + assert!(error.contains("Clear(1)")); + assert!(error.contains("Clear(0)")); + assert!(api.0.borrow().live.is_empty()); + assert!(!requests.is_active()); + } + + #[test] + fn partial_acquisition_preserves_original_and_cleanup_errors() { + let (mut requests, api) = requests(); + api.0.borrow_mut().failures = vec![Call::Set(0), Call::Clear(1)]; + let error = requests.activate().unwrap_err(); + assert!(error.contains("Set(0)")); + assert!(error.contains("Clear(1)")); + assert!(api.0.borrow().live.is_empty()); + } + + #[test] + fn drop_releases_both_requests_without_explicit_deactivation() { + let (mut requests, api) = requests(); + requests.activate().unwrap(); + drop(requests); + assert!(api.0.borrow().live.is_empty()); + assert!(api + .0 + .borrow() + .calls + .ends_with(&[Call::Clear(1), Call::Clear(0), Call::Close])); + } + + #[test] + fn resume_replacement_handles_ended_or_surviving_counts_without_accumulation() { + for counts in [[0, 0], [1, 1]] { + let (mut requests, api) = requests(); + requests.activate().unwrap(); + *api.0.borrow_mut().live.values_mut().next().unwrap() = counts; + // This validates ownership, not real Windows suspend behavior. + for _ in 0..2 { + requests.close().unwrap(); + requests.activate().unwrap(); + assert_eq!( + api.0.borrow().live.values().copied().collect::>(), + vec![[1, 1]] + ); + } + } } } diff --git a/src/cli.rs b/src/cli.rs index ecdd2fc..108b5bb 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -41,7 +41,7 @@ enum CliAutostartState { name = "wardoff", author = "Romain ROCH", version = env!("CARGO_PKG_VERSION"), - about = "Open-source Windows shutdown/reboot/sleep blocker" + about = "Open-source Windows shutdown/reboot protection and idle-sleep prevention" )] pub struct WardoffCli { /// Enables blocking mode for the current session without showing a tray icon. @@ -236,7 +236,7 @@ mod tests { assert_eq!(error.kind(), ErrorKind::DisplayHelp); assert!(error .to_string() - .contains("Open-source Windows shutdown/reboot/sleep blocker")); + .contains("Open-source Windows shutdown/reboot protection and idle-sleep prevention")); } #[test] diff --git a/src/logger/mod.rs b/src/logger/mod.rs index 6ce6785..0823db1 100644 --- a/src/logger/mod.rs +++ b/src/logger/mod.rs @@ -43,7 +43,7 @@ pub enum EventSource { Remote, /// The Layer 1 shutdown blocker emitted the event. Shutdown, - /// The sleep and hibernate blocker emitted the event. + /// The idle-sleep/display-timeout request owner emitted the event. Sleep, /// The tray surface emitted the event. Tray, diff --git a/src/main.rs b/src/main.rs index 2ec3866..ec30e66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1090,6 +1090,12 @@ impl Application { } } blocker::shutdown::PowerBroadcastEvent::Resume => { + // The tray path is still Allow here and reacquires through its + // existing restore. Renew only a runtime that stayed in Block. + if let Err(error) = self.blocker_coordinator.renew_sleep_requests_after_resume() { + warn!("Could not renew idle-power requests after resume: {error}"); + logger::log_event("sleep_resume_failed", EventSource::Sleep, error, false); + } self.restore_block_after_wake_if_needed(); } } diff --git a/tests/smoke_test.ps1 b/tests/smoke_test.ps1 index 5e608f5..f5fc956 100644 --- a/tests/smoke_test.ps1 +++ b/tests/smoke_test.ps1 @@ -12,14 +12,18 @@ $repoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) $logPath = Join-Path (Join-Path $env:LOCALAPPDATA 'Wardoff') 'logs\wardoff.jsonl' function Resolve-BinaryPath { - $defaultBinaryPath = Join-Path $repoRoot 'target\release\wardoff.exe' + $releasePath = 'release\wardoff.exe' + if (-not [string]::IsNullOrWhiteSpace($env:CARGO_BUILD_TARGET)) { + $releasePath = Join-Path $env:CARGO_BUILD_TARGET $releasePath + } + $defaultBinaryPath = Join-Path (Join-Path $repoRoot 'target') $releasePath try { $metadataJson = & cargo metadata --format-version 1 --no-deps 2>$null if (-not [string]::IsNullOrWhiteSpace($metadataJson)) { $metadata = $metadataJson | ConvertFrom-Json -ErrorAction Stop if (-not [string]::IsNullOrWhiteSpace($metadata.target_directory)) { - return Join-Path $metadata.target_directory 'release\wardoff.exe' + return Join-Path $metadata.target_directory $releasePath } } } @@ -721,7 +725,7 @@ try { Invoke-TestCase 'Start-Process launches wardoff --block --hide in the background' { Stop-RepoWardoffProcesses $script:InitialLogLineCount = @(Get-StructuredLogLines).Count - $script:BackgroundProcess = Start-Process -FilePath $binaryPath -ArgumentList @('--block', '--hide') -WorkingDirectory $repoRoot -PassThru + $script:BackgroundProcess = Start-Process -FilePath $binaryPath -ArgumentList @('--block', '--hide') -WorkingDirectory $repoRoot -WindowStyle Hidden -PassThru Start-Sleep -Seconds 2 $script:BackgroundProcess.Refresh() Assert-Condition (-not $script:BackgroundProcess.HasExited) "Wardoff exited early with code $($script:BackgroundProcess.ExitCode)." @@ -734,6 +738,7 @@ try { Assert-Condition ($result.ExitCode -eq 0) "wardoff --status exited with code $($result.ExitCode) while Wardoff was running." Assert-Condition ($status.state -eq 'block') "wardoff --status returned state '$($status.state)' instead of 'block'." + Assert-Condition ($status.layers.sleep -eq $true) 'Block mode did not acquire both idle-power requests.' Assert-Condition ($null -ne $status.layers) 'wardoff --status did not include a layers object while Wardoff was running.' Assert-Condition ($null -ne $status.layers.local_shutdown) 'wardoff --status did not include the layers.local_shutdown field.' Assert-Condition ($status.layers.local_shutdown -is [bool]) 'wardoff --status returned a non-boolean layers.local_shutdown field.' @@ -760,7 +765,8 @@ try { if ($powercfgRequestsCheck.State -eq 'testable') { Invoke-TestCase 'powercfg /requests mentions Wardoff while blocking is active' { $result = $powercfgRequestsCheck.Result - Assert-Condition ($result.Output -match '(?i)wardoff') 'powercfg /requests did not mention Wardoff.' + $reason = 'Wardoff Block mode: prevent idle sleep and automatic display timeout.' + Assert-Condition ([regex]::Matches($result.Output, [regex]::Escape($reason)).Count -eq 2) 'Expected the Wardoff reason once for SYSTEM and once for DISPLAY.' } } elseif ($powercfgRequestsCheck.State -eq 'skip') { @@ -852,18 +858,45 @@ try { Skip-TestCase 'wardoff --autostart on creates the Wardoff task and --autostart off removes it' $adminOnlyMessage } - Invoke-TestCase 'The background Wardoff instance can be stopped and cleaned up' { + Invoke-TestCase 'Allow releases idle-power requests without stopping the runtime' { $repoProcesses = @(Get-RepoWardoffProcesses) - Assert-Condition ($repoProcesses.Count -gt 0) 'No background Wardoff process was running to stop.' + Assert-Condition ($repoProcesses.Count -eq 1) 'Expected one background Wardoff process.' + $allowResult = Invoke-ExternalCommand -FilePath $binaryPath -Arguments @('--allow') + Assert-Condition ($allowResult.ExitCode -eq 0) "wardoff --allow exited with code $($allowResult.ExitCode)." + $statusResult = Invoke-ExternalCommand -FilePath $binaryPath -Arguments @('--status') + Assert-Condition ($statusResult.ExitCode -eq 0) 'Status failed after Allow.' + $status = $statusResult.Output | ConvertFrom-Json + Assert-Condition ($status.state -eq 'allow' -and $status.layers.sleep -eq $false) 'Allow did not report released idle-power requests.' + $remaining = @(Get-RepoWardoffProcesses) + Assert-Condition ($remaining.Count -eq 1 -and $remaining[0].ProcessId -eq $repoProcesses[0].ProcessId) 'Allow replaced or stopped the runtime.' + } - try { - $allowResult = Invoke-ExternalCommand -FilePath $binaryPath -Arguments @('--allow') - Assert-Condition ($allowResult.ExitCode -eq 0) "wardoff --allow exited with code $($allowResult.ExitCode)." - } - catch { + $allowRequestsCheck = Resolve-PowercfgRequestsCheck + if ($allowRequestsCheck.State -eq 'testable') { + Invoke-TestCase 'powercfg /requests has no Wardoff entry in Allow mode' { + Assert-Condition ($allowRequestsCheck.Result.Output -notmatch '(?i)wardoff') 'Wardoff requests remained after Allow.' } + } + elseif ($allowRequestsCheck.State -eq 'skip') { + Skip-TestCase 'powercfg /requests has no Wardoff entry in Allow mode' $allowRequestsCheck.SkipReason + } + else { + Add-TestResult -Status 'FAIL' -Description 'powercfg /requests has no Wardoff entry in Allow mode' -Details $allowRequestsCheck.FailureMessage + } - Stop-RepoWardoffProcesses + Invoke-TestCase 'Repeated Block reacquires idle-power requests before abrupt process termination' { + foreach ($iteration in 1..2) { + $blockResult = Invoke-ExternalCommand -FilePath $binaryPath -Arguments @('--block') + Assert-Condition ($blockResult.ExitCode -eq 0) 'Block failed after Allow.' + $activeStatus = Wait-ForActiveWardoffStatus + Assert-Condition ($activeStatus.Status.layers.sleep -eq $true) 'Block did not reacquire idle-power requests.' + } + # Deliberately do not call the normal cleanup helper: it sends --allow first. + $repoProcesses = @(Get-RepoWardoffProcesses) + Assert-Condition ($repoProcesses.Count -eq 1) 'Expected one owned runtime to terminate.' + $ownedProcess = Get-Process -Id $repoProcesses[0].ProcessId -ErrorAction Stop + Stop-Process -InputObject $ownedProcess -Force -ErrorAction Stop + Assert-Condition ($ownedProcess.WaitForExit(5000)) 'The terminated runtime did not exit within five seconds.' Assert-Condition (@(Get-RepoWardoffProcesses).Count -eq 0) 'Wardoff was still running after cleanup.' } From 5826291b1f9a889304830c0a745130599f862b98 Mon Sep 17 00:00:00 2001 From: RomainROCH Date: Sun, 4 Oct 2026 03:30:57 +0200 Subject: [PATCH 3/7] fix: validate autostart with identification tokens --- src/autostart.rs | 53 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/src/autostart.rs b/src/autostart.rs index 01ef1e7..b38401d 100644 --- a/src/autostart.rs +++ b/src/autostart.rs @@ -21,7 +21,7 @@ use windows::Win32::Security::Authorization::{ ConvertSidToStringSidW, GetNamedSecurityInfoW, SE_FILE_OBJECT, }; use windows::Win32::Security::{ - AccessCheck, DuplicateTokenEx, GetTokenInformation, LookupAccountSidW, SecurityImpersonation, + AccessCheck, DuplicateTokenEx, GetTokenInformation, LookupAccountSidW, SecurityIdentification, TokenImpersonation, TokenLinkedToken, TokenUser, DACL_SECURITY_INFORMATION, GENERIC_MAPPING, GROUP_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PRIVILEGE_SET, PSECURITY_DESCRIPTOR, PSID, TOKEN_DUPLICATE, TOKEN_IMPERSONATE, TOKEN_LINKED_TOKEN, TOKEN_QUERY, TOKEN_USER, @@ -440,12 +440,21 @@ fn current_interactive_medium_token_for_access_check() -> Result Result { + unsafe { let mut impersonation_token = HANDLE::default(); DuplicateTokenEx( - linked_token.0, + source_token, TOKEN_QUERY, None, - SecurityImpersonation, + // AccessCheck only inspects the user's permissions. A linked UAC + // token can be identification-only and cannot be promoted to an + // impersonation level that would let us act as that user. + SecurityIdentification, TokenImpersonation, &mut impersonation_token, ) @@ -737,3 +746,41 @@ impl Drop for LocalAllocatedWideString { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn access_check_accepts_an_identification_level_source_token() { + let process_token = open_current_process_token( + TOKEN_QUERY | TOKEN_DUPLICATE, + "prepare the identification-token regression test", + ) + .expect("open test process token"); + let mut source_token = HANDLE::default(); + unsafe { + DuplicateTokenEx( + process_token.0, + TOKEN_QUERY | TOKEN_DUPLICATE, + None, + SecurityIdentification, + TokenImpersonation, + &mut source_token, + ) + } + .expect("create an identification-level source token"); + let source_token = HandleGuard(source_token); + + let access_token = duplicate_access_check_token(source_token.0) + .expect("identification-level tokens must support access checks"); + let executable = std::env::current_exe().expect("locate the running test executable"); + assert!(token_has_path_access( + access_token.0, + &executable, + FILE_GENERIC_READ.0, + "read the test executable", + ) + .expect("evaluate file access with the identification-level token")); + } +} From 4041db05b21cc898b5b93d2e72725fe98716564c Mon Sep 17 00:00:00 2001 From: RomainROCH Date: Sun, 4 Oct 2026 03:32:53 +0200 Subject: [PATCH 4/7] test: skip absent Update Orchestrator reboot task --- tests/smoke_test.ps1 | 46 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/tests/smoke_test.ps1 b/tests/smoke_test.ps1 index f5fc956..da45c0d 100644 --- a/tests/smoke_test.ps1 +++ b/tests/smoke_test.ps1 @@ -41,6 +41,35 @@ function Test-IsAdministrator { return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } +function Test-RebootTaskPresent { + $scheduler = $null + $folder = $null + $task = $null + try { + $scheduler = New-Object -ComObject 'Schedule.Service' + $scheduler.Connect() + $folder = $scheduler.GetFolder('\Microsoft\Windows\UpdateOrchestrator') + $task = $folder.GetTask('Reboot') + return $true + } + catch { + # Match the runtime's missing-file, missing-path and missing-object cases. + # Permission and other scheduler errors must still fail validation. + $hresult = $_.Exception.GetBaseException().HResult + if ($hresult -in @(-2147024894, -2147024893, -2147023728)) { + return $false + } + throw + } + finally { + foreach ($comObject in @($task, $folder, $scheduler)) { + if ($null -ne $comObject) { + [void][Runtime.InteropServices.Marshal]::ReleaseComObject($comObject) + } + } + } +} + $isAdmin = Test-IsAdministrator if (-not $isAdmin) { @@ -798,9 +827,20 @@ try { } if ($isAdmin) { - Invoke-TestCase 'wardoff --status reports Layer 3 UpdateOrchestrator protection as active when run as admin' { - $activeStatus = Wait-ForActiveWardoffStatus - Assert-Condition ($activeStatus.Status.layers.update -eq $true) 'wardoff --status did not report layers.update=true in an elevated session.' + $layer3Description = 'wardoff --status reports Layer 3 UpdateOrchestrator protection as active when run as admin' + try { + if (Test-RebootTaskPresent) { + Invoke-TestCase $layer3Description { + $activeStatus = Wait-ForActiveWardoffStatus + Assert-Condition ($activeStatus.Status.layers.update -eq $true) 'wardoff --status did not report layers.update=true in an elevated session.' + } + } + else { + Skip-TestCase $layer3Description 'The UpdateOrchestrator Reboot task is absent on this Windows installation.' + } + } + catch { + Add-TestResult -Status 'FAIL' -Description $layer3Description -Details $_.Exception.Message } Invoke-TestCase 'wardoff --status reports Layer 4 AbortSystemShutdown protection as active when run as admin' { From 5480b67b4ee00e399c6577ff8f2e8064fa486cd5 Mon Sep 17 00:00:00 2001 From: RomainROCH Date: Sun, 4 Oct 2026 03:53:50 +0200 Subject: [PATCH 5/7] docs: record virtual idle-power acceptance --- docs/IDLE_POWER_VALIDATION.md | 28 ++++++++++++++++++++++++++++ docs/MVP_VALIDATION_MATRIX.md | 2 ++ 2 files changed, 30 insertions(+) create mode 100644 docs/IDLE_POWER_VALIDATION.md diff --git a/docs/IDLE_POWER_VALIDATION.md b/docs/IDLE_POWER_VALIDATION.md new file mode 100644 index 0000000..bb31b89 --- /dev/null +++ b/docs/IDLE_POWER_VALIDATION.md @@ -0,0 +1,28 @@ +# Idle-power validation record + +Validation window: 2026-10-03 through 2026-10-04. + +This record covers the functional candidate at commit `4041db0` and the retained Windows release artifact with SHA-256 `8B9545310891A73899115F65474AE86BCC5BF78F09526FF6D4246CED1D1B06A9`. The disposable Windows 11 VM used QEMU 11.1.2 with q35, WHPX, one Westmere-v2 CPU, 3072 MiB, and no network device. The original power plan was preserved. + +The record combines the earlier lifecycle checks and the repaired-firmware transition run using the same release artifact. Raw measurements and failed diagnostic controls are retained locally; this table reports their observed results. + +| Requested case | Result | Observation | +| --- | --- | --- | +| 1. Block prevents automatic idle sleep | Pass in VM | After resume, Windows remained running with 228.625 seconds of measured keyboard/mouse inactivity, beyond the configured 120-second sleep timeout. The same runtime remained in Block. | +| 2. Block prevents automatic display timeout | Pass in VM | The native console-display state remained on during the same idle observation, beyond the configured 60-second display timeout. | +| 3. Start-menu Sleep proceeds in Block | Pass in VM | Actual S3 was observed at 01:27:37 UTC and wake at 01:27:41 UTC. No synthetic suspend notification or post-wake reset was used. | +| 4. Wake reacquires Block protection | Pass in VM | Before any post-wake UI input, the same runtime remained in Block with exactly one system/display request pair. Native resume notification and successful reacquisition were recorded at 01:27:42.837 UTC; the later idle observation confirmed effective protection. | +| 5. Allow permits automatic display-off and sleep | Pass in VM | Allow completed at 01:38:33.534 UTC. Native display-off occurred at 01:39:35.240 UTC, followed by suspend; actual S3 was observed at 01:39:40 UTC. No UI input or explicit Sleep command occurred during this interval. The existing inactivity interval already exceeded the sleep timeout. After wake, the same runtime remained in Allow with no requests. | +| 6. Requests appear in Block and disappear in Allow | Pass in VM | Both request categories contained the exact Wardoff Block reason, and both became empty in Allow while the runtime stayed alive. | +| 7. Quit directly from Block releases requests | Pass in VM | A visible tray Quit from Block stopped the owned runtime, returned status to inactive, and left no Wardoff request. | +| 8. Abrupt termination from Block releases requests | Pass in VM | The smoke test killed the owned Block-mode runtime directly and confirmed no residual Wardoff request. | + +The disposable guest fixture used 60-second display and 120-second sleep timeouts with hybrid sleep disabled. A matched OS-only firmware comparison reproduced lost PCI mappings after S3 with the baseline BIOS. Resetting the cached ECAM access path before PCI restoration recovered PCI BARs, AHCI, guest agents, and the display in the patched control. This was a VM firmware repair, separate from Wardoff's implementation. + +Supporting checks passed: 42 Rust tests with zero failures, formatting, the default GNU release build, and ordinary Clippy. The two Clippy diagnostics were reproduced on the pre-feature baseline. Guest smoke testing recorded 18 passes, zero failures, and three skips: two non-elevated cases and the absent Update Orchestrator Reboot task. The new task probe fails on unexpected scheduler errors. + +A completed independent source review found no actionable issue in the integration diff. A policy-rejected OpenCode review is excluded from acceptance evidence. Additional MSVC-target release attempts failed on local SDK autodetection; the retained tested Windows release artifact has an exact code/Cargo/tests binding to the candidate above. + +Physical host S3 and monitor behavior, Hibernate, and Modern Standby remain **not physically validated**. Hibernate and Modern Standby were not exercised in the VM either. An unattended wake initially left the console display off; ordinary secure-desktop input restored it. This record does not claim a universal Sleep/Hibernate veto or a new presence-aware wake policy. + +Cleanup verified the original plan active with identical settings, the temporary fixture deleted, the test runtimes and observers stopped, no remaining Wardoff request, and no remaining temporary lab tasks. diff --git a/docs/MVP_VALIDATION_MATRIX.md b/docs/MVP_VALIDATION_MATRIX.md index c0555df..01fb846 100644 --- a/docs/MVP_VALIDATION_MATRIX.md +++ b/docs/MVP_VALIDATION_MATRIX.md @@ -189,6 +189,8 @@ not document a special elevation requirement. Elevation needed to inspect ## Acceptance checklist +See the [idle-power validation record](IDLE_POWER_VALIDATION.md) for the 2026-10-03 through 2026-10-04 VM evidence and current pending cases. + - [ ] `tests\smoke_test.ps1` passes, with any skips noted - [ ] Manual non-disruptive checks completed - [ ] Admin-only checks completed or explicitly deferred From 4e5eeaf2def1a172a6c302a5ee9e1e05934f5542 Mon Sep 17 00:00:00 2001 From: RomainROCH Date: Sun, 4 Oct 2026 04:05:34 +0200 Subject: [PATCH 6/7] test: defer autostart smoke in service sessions --- docs/MVP_VALIDATION_MATRIX.md | 2 +- tests/smoke_test.ps1 | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/MVP_VALIDATION_MATRIX.md b/docs/MVP_VALIDATION_MATRIX.md index 01fb846..33a9f05 100644 --- a/docs/MVP_VALIDATION_MATRIX.md +++ b/docs/MVP_VALIDATION_MATRIX.md @@ -51,7 +51,7 @@ Keep validation language conservative: | Layer 3 status | Elevated status reports `layers.update=true` when `\Microsoft\Windows\UpdateOrchestrator\Reboot` exists; if the task is absent, note the normal skip/defer case instead of treating it as a failure | Yes | | Layer 4 status | Elevated status reports `layers.remote=true` | Yes | | Update task access | `schtasks /query /tn Microsoft\Windows\UpdateOrchestrator\Reboot` either succeeds or shows the task is absent on this machine; absence is a normal Layer 3 skip/defer case | Yes | -| Autostart task lifecycle | From a trusted admin-writable location, `wardoff --autostart on` creates the `Wardoff` task and `--autostart off` removes it; from an untrusted or user-writable path Wardoff refuses safely and leaves the task state unchanged | Yes | +| Autostart task lifecycle | From a trusted admin-writable location, `wardoff --autostart on` creates the `Wardoff` task and `--autostart off` removes it; from an untrusted or user-writable path Wardoff refuses safely and leaves the task state unchanged. Service-session smoke runners without an interactive user session skip this lifecycle case and require interactive manual or VM validation. | Yes | | Cleanup path | Direct termination from Block stops the owned background instance; a final cleanup helper handles test failures | No | | Sleep/display cleanup | `powercfg /requests` no longer mentions Wardoff after cleanup, when the session allows that query | No | diff --git a/tests/smoke_test.ps1 b/tests/smoke_test.ps1 index da45c0d..aa07752 100644 --- a/tests/smoke_test.ps1 +++ b/tests/smoke_test.ps1 @@ -852,7 +852,13 @@ try { $null = Invoke-ExternalCommand -FilePath 'schtasks' -Arguments @('/query', '/tn', 'Microsoft\Windows\UpdateOrchestrator\Reboot') } - Invoke-TestCase 'wardoff --autostart on creates the Wardoff task and --autostart off removes it' { + $smokeSessionId = [int](Get-Process -Id $PID -ErrorAction Stop).SessionId + $autostartDescription = 'wardoff --autostart on creates the Wardoff task and --autostart off removes it' + if ($smokeSessionId -eq 0) { + Skip-TestCase $autostartDescription "Skipped: smoke process is running in Windows session 0; an interactive user session is required (observed session ID $smokeSessionId)." + } + else { + Invoke-TestCase $autostartDescription { $taskOriginallyPresent = (Invoke-ExternalCommand -FilePath 'schtasks' -Arguments @('/query', '/tn', 'Wardoff')).ExitCode -eq 0 try { @@ -887,6 +893,7 @@ try { catch { } } + } } } else { From 465c24e4392d749e90ed9638f1ac61afbc409ac2 Mon Sep 17 00:00:00 2001 From: RomainROCH Date: Sun, 4 Oct 2026 04:26:21 +0200 Subject: [PATCH 7/7] test: defer autostart checks without a linked token --- docs/MVP_VALIDATION_MATRIX.md | 10 +++- tests/smoke_test.ps1 | 98 +++++++++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/docs/MVP_VALIDATION_MATRIX.md b/docs/MVP_VALIDATION_MATRIX.md index 33a9f05..324cbf1 100644 --- a/docs/MVP_VALIDATION_MATRIX.md +++ b/docs/MVP_VALIDATION_MATRIX.md @@ -51,7 +51,7 @@ Keep validation language conservative: | Layer 3 status | Elevated status reports `layers.update=true` when `\Microsoft\Windows\UpdateOrchestrator\Reboot` exists; if the task is absent, note the normal skip/defer case instead of treating it as a failure | Yes | | Layer 4 status | Elevated status reports `layers.remote=true` | Yes | | Update task access | `schtasks /query /tn Microsoft\Windows\UpdateOrchestrator\Reboot` either succeeds or shows the task is absent on this machine; absence is a normal Layer 3 skip/defer case | Yes | -| Autostart task lifecycle | From a trusted admin-writable location, `wardoff --autostart on` creates the `Wardoff` task and `--autostart off` removes it; from an untrusted or user-writable path Wardoff refuses safely and leaves the task state unchanged. Service-session smoke runners without an interactive user session skip this lifecycle case and require interactive manual or VM validation. | Yes | +| Autostart task lifecycle | The smoke test first reads the current process token's native `TokenElevationType`. With `TokenElevationTypeFull (2)`, it runs the trusted-path lifecycle and untrusted-path refusal assertions. With `TokenElevationTypeDefault (1)`, Windows reports no linked token, so the lifecycle is explicitly deferred to an interactive UAC session; a failed or unexpected query is a failure. | Yes | | Cleanup path | Direct termination from Block stops the owned background instance; a final cleanup helper handles test failures | No | | Sleep/display cleanup | `powercfg /requests` no longer mentions Wardoff after cleanup, when the session allows that query | No | @@ -94,6 +94,14 @@ Run these in an elevated session when you want manual confidence beyond the auto | Autostart tray/menu honesty | In an elevated session, change autostart with `wardoff --autostart on|off` or the tray `Start with Windows` checkbox, then reopen the tray menu and confirm with `schtasks /query /tn "Wardoff"` | The tray checkbox reflects the real scheduled-task state after the change, and after an untrusted-location refusal it does not pretend autostart changed | | Honest non-admin behavior | Retry an admin-only command from a non-elevated console | Wardoff reports the requirement instead of pretending success | +The automated autostart lifecycle requires an elevated process with +`TokenElevationTypeFull (2)`. `TokenElevationTypeDefault (1)` is a valid +elevated-token shape without a linked token; the smoke test records that value +and skips only the lifecycle, which must then be checked manually from an +interactive UAC session. `TokenElevationTypeLimited (3)`, an unexpected value, +or a native query error is a smoke-test failure. The token query runs before +the scheduler checks and does not change scheduled-task state. + ## Disruptive manual checks for a disposable VM Do these only in a disposable VM or similarly safe environment. diff --git a/tests/smoke_test.ps1 b/tests/smoke_test.ps1 index aa07752..5ee77b7 100644 --- a/tests/smoke_test.ps1 +++ b/tests/smoke_test.ps1 @@ -41,6 +41,79 @@ function Test-IsAdministrator { return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } +function Get-CurrentTokenElevationType { + if (-not ('Wardoff.TokenElevationTypeReader' -as [type])) { + Add-Type @" +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace Wardoff { + public static class TokenElevationTypeReader { + private const int TokenElevationTypeInformation = 18; + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern bool GetTokenInformation( + IntPtr tokenHandle, + int tokenInformationClass, + out int tokenInformation, + int tokenInformationLength, + out int returnLength); + + public static int GetCurrentValue() { + using (WindowsIdentity identity = WindowsIdentity.GetCurrent()) { + int value; + int returnLength; + if (!GetTokenInformation( + identity.Token, + TokenElevationTypeInformation, + out value, + sizeof(int), + out returnLength)) { + throw new Win32Exception( + Marshal.GetLastWin32Error(), + "GetTokenInformation(TokenElevationType) failed"); + } + + if (returnLength < sizeof(int)) { + throw new InvalidOperationException( + "GetTokenInformation(TokenElevationType) returned an incomplete value"); + } + + return value; + } + } + } +} +"@ + } + + $value = [Wardoff.TokenElevationTypeReader]::GetCurrentValue() + if ($value -notin @(1, 2, 3)) { + throw "Unexpected TokenElevationType value $value." + } + + return $value +} + +function Get-AutostartValidationDecision { + param( + [AllowNull()] + [Nullable[int]] $TokenElevationType + ) + + if ($null -eq $TokenElevationType) { + return 'query-failed' + } + + switch ($TokenElevationType) { + 1 { return 'skip-no-linked-token' } + 2 { return 'run-lifecycle' } + default { return 'fail-unexpected-value' } + } +} + function Test-RebootTaskPresent { $scheduler = $null $folder = $null @@ -827,6 +900,15 @@ try { } if ($isAdmin) { + $tokenElevationType = $null + try { + $tokenElevationType = Get-CurrentTokenElevationType + Write-Host "Observed native TokenElevationType: $tokenElevationType" -ForegroundColor Cyan + } + catch { + Add-TestResult -Status 'FAIL' -Description 'native TokenElevationType query succeeds in the elevated smoke session' -Details $_.Exception.Message + } + $layer3Description = 'wardoff --status reports Layer 3 UpdateOrchestrator protection as active when run as admin' try { if (Test-RebootTaskPresent) { @@ -852,10 +934,20 @@ try { $null = Invoke-ExternalCommand -FilePath 'schtasks' -Arguments @('/query', '/tn', 'Microsoft\Windows\UpdateOrchestrator\Reboot') } - $smokeSessionId = [int](Get-Process -Id $PID -ErrorAction Stop).SessionId $autostartDescription = 'wardoff --autostart on creates the Wardoff task and --autostart off removes it' - if ($smokeSessionId -eq 0) { - Skip-TestCase $autostartDescription "Skipped: smoke process is running in Windows session 0; an interactive user session is required (observed session ID $smokeSessionId)." + $autostartDecision = Get-AutostartValidationDecision $tokenElevationType + if ($autostartDecision -eq 'query-failed') { + Invoke-TestCase $autostartDescription { + throw 'Cannot classify the elevated token because the native TokenElevationType query failed.' + } + } + elseif ($autostartDecision -eq 'skip-no-linked-token') { + Skip-TestCase $autostartDescription 'Skipped: native TokenElevationTypeDefault (1) means this elevated token has no linked token; validate the autostart lifecycle manually from an interactive UAC session.' + } + elseif ($autostartDecision -eq 'fail-unexpected-value') { + Invoke-TestCase $autostartDescription { + throw "Unexpected TokenElevationType value $tokenElevationType in the elevated smoke session; interactive UAC validation is required." + } } else { Invoke-TestCase $autostartDescription {